mirror of
https://github.com/bitwarden/server.git
synced 2025-05-08 13:12:16 -05:00
Merge branch 'main' into arch/seed-org-users
This commit is contained in:
commit
5c7fa7aae6
15
.github/workflows/code-references.yml
vendored
15
.github/workflows/code-references.yml
vendored
@ -1,7 +1,10 @@
|
||||
name: Collect code references
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
on:
|
||||
push:
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-ld-secret:
|
||||
@ -37,12 +40,10 @@ jobs:
|
||||
|
||||
- name: Collect
|
||||
id: collect
|
||||
uses: launchdarkly/find-code-references-in-pull-request@30f4c4ab2949bbf258b797ced2fbf6dea34df9ce # v2.1.0
|
||||
uses: launchdarkly/find-code-references@e3e9da201b87ada54eb4c550c14fb783385c5c8a # v2.13.0
|
||||
with:
|
||||
project-key: default
|
||||
environment-key: dev
|
||||
access-token: ${{ secrets.LD_ACCESS_TOKEN }}
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
accessToken: ${{ secrets.LD_ACCESS_TOKEN }}
|
||||
projKey: default
|
||||
|
||||
- name: Add label
|
||||
if: steps.collect.outputs.any-changed == 'true'
|
||||
|
@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2025.4.3</Version>
|
||||
<Version>2025.5.0</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
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.Context;
|
||||
@ -82,7 +83,7 @@ public class ProviderService : IProviderService
|
||||
_pricingClient = pricingClient;
|
||||
}
|
||||
|
||||
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo = null)
|
||||
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null)
|
||||
{
|
||||
var owner = await _userService.GetUserByIdAsync(ownerUserId);
|
||||
if (owner == null)
|
||||
@ -111,7 +112,20 @@ public class ProviderService : IProviderService
|
||||
{
|
||||
throw new BadRequestException("Both address and postal code are required to set up your provider.");
|
||||
}
|
||||
var customer = await _providerBillingService.SetupCustomer(provider, taxInfo);
|
||||
|
||||
var requireProviderPaymentMethodDuringSetup =
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
|
||||
|
||||
if (requireProviderPaymentMethodDuringSetup && tokenizedPaymentSource is not
|
||||
{
|
||||
Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
|
||||
Token: not null and not ""
|
||||
})
|
||||
{
|
||||
throw new BadRequestException("A payment method is required to set up your provider.");
|
||||
}
|
||||
|
||||
var customer = await _providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
|
||||
provider.GatewayCustomerId = customer.Id;
|
||||
var subscription = await _providerBillingService.SetupSubscription(provider);
|
||||
provider.GatewaySubscriptionId = subscription.Id;
|
||||
|
@ -6,9 +6,11 @@ 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.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;
|
||||
@ -21,14 +23,20 @@ using Bit.Core.Models.Business;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Braintree;
|
||||
using CsvHelper;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
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;
|
||||
|
||||
public class ProviderBillingService(
|
||||
IBraintreeGateway braintreeGateway,
|
||||
IEventService eventService,
|
||||
IFeatureService featureService,
|
||||
IGlobalSettings globalSettings,
|
||||
@ -39,6 +47,7 @@ public class ProviderBillingService(
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IProviderUserRepository providerUserRepository,
|
||||
ISetupIntentCache setupIntentCache,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService,
|
||||
ITaxService taxService,
|
||||
@ -463,7 +472,8 @@ public class ProviderBillingService(
|
||||
|
||||
public async Task<Customer> SetupCustomer(
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
TaxInfo taxInfo,
|
||||
TokenizedPaymentSource tokenizedPaymentSource = null)
|
||||
{
|
||||
if (taxInfo is not
|
||||
{
|
||||
@ -532,13 +542,97 @@ public class ProviderBillingService(
|
||||
options.Coupon = provider.DiscountId;
|
||||
}
|
||||
|
||||
var requireProviderPaymentMethodDuringSetup =
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
|
||||
|
||||
var braintreeCustomerId = "";
|
||||
|
||||
if (requireProviderPaymentMethodDuringSetup)
|
||||
{
|
||||
if (tokenizedPaymentSource is not
|
||||
{
|
||||
Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
|
||||
Token: not null and not ""
|
||||
})
|
||||
{
|
||||
logger.LogError("Cannot create customer for provider ({ProviderID}) without a payment method", provider.Id);
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var (type, token) = tokenizedPaymentSource;
|
||||
|
||||
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
|
||||
switch (type)
|
||||
{
|
||||
case PaymentMethodType.BankAccount:
|
||||
{
|
||||
var setupIntent =
|
||||
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token }))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (setupIntent == null)
|
||||
{
|
||||
logger.LogError("Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account", provider.Id);
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
await setupIntentCache.Set(provider.Id, setupIntent.Id);
|
||||
break;
|
||||
}
|
||||
case PaymentMethodType.Card:
|
||||
{
|
||||
options.PaymentMethod = token;
|
||||
options.InvoiceSettings.DefaultPaymentMethod = token;
|
||||
break;
|
||||
}
|
||||
case PaymentMethodType.PayPal:
|
||||
{
|
||||
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, token);
|
||||
options.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await stripeAdapter.CustomerCreateAsync(options);
|
||||
}
|
||||
catch (StripeException stripeException) when (stripeException.StripeError?.Code == StripeConstants.ErrorCodes.TaxIdInvalid)
|
||||
catch (StripeException stripeException) when (stripeException.StripeError?.Code ==
|
||||
StripeConstants.ErrorCodes.TaxIdInvalid)
|
||||
{
|
||||
throw new BadRequestException("Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.");
|
||||
await Revert();
|
||||
throw new BadRequestException(
|
||||
"Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
await Revert();
|
||||
throw;
|
||||
}
|
||||
|
||||
async Task Revert()
|
||||
{
|
||||
if (requireProviderPaymentMethodDuringSetup && tokenizedPaymentSource != null)
|
||||
{
|
||||
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
|
||||
switch (tokenizedPaymentSource.Type)
|
||||
{
|
||||
case PaymentMethodType.BankAccount:
|
||||
{
|
||||
var setupIntentId = await setupIntentCache.Get(provider.Id);
|
||||
await stripeAdapter.SetupIntentCancel(setupIntentId,
|
||||
new SetupIntentCancelOptions { CancellationReason = "abandoned" });
|
||||
await setupIntentCache.Remove(provider.Id);
|
||||
break;
|
||||
}
|
||||
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
|
||||
{
|
||||
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -580,18 +674,38 @@ public class ProviderBillingService(
|
||||
});
|
||||
}
|
||||
|
||||
var requireProviderPaymentMethodDuringSetup =
|
||||
featureService.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup);
|
||||
|
||||
var setupIntentId = await setupIntentCache.Get(provider.Id);
|
||||
|
||||
var setupIntent = !string.IsNullOrEmpty(setupIntentId)
|
||||
? await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions
|
||||
{
|
||||
Expand = ["payment_method"]
|
||||
})
|
||||
: null;
|
||||
|
||||
var usePaymentMethod =
|
||||
requireProviderPaymentMethodDuringSetup &&
|
||||
(!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
|
||||
customer.Metadata.ContainsKey(BraintreeCustomerIdKey) ||
|
||||
setupIntent.IsUnverifiedBankAccount());
|
||||
|
||||
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
||||
{
|
||||
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
||||
CollectionMethod = usePaymentMethod ?
|
||||
StripeConstants.CollectionMethod.ChargeAutomatically : StripeConstants.CollectionMethod.SendInvoice,
|
||||
Customer = customer.Id,
|
||||
DaysUntilDue = 30,
|
||||
DaysUntilDue = usePaymentMethod ? null : 30,
|
||||
Items = subscriptionItemOptionsList,
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "providerId", provider.Id.ToString() }
|
||||
},
|
||||
OffSession = true,
|
||||
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations
|
||||
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
|
||||
TrialPeriodDays = usePaymentMethod ? 14 : null
|
||||
};
|
||||
|
||||
if (featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements))
|
||||
@ -607,7 +721,10 @@ public class ProviderBillingService(
|
||||
{
|
||||
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||
|
||||
if (subscription.Status == StripeConstants.SubscriptionStatus.Active)
|
||||
if (subscription is
|
||||
{
|
||||
Status: StripeConstants.SubscriptionStatus.Active or StripeConstants.SubscriptionStatus.Trialing
|
||||
})
|
||||
{
|
||||
return subscription;
|
||||
}
|
||||
|
@ -1,9 +1,14 @@
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Licenses;
|
||||
using Bit.Core.Billing.Licenses.Extensions;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.SecretsManager.Queries.Projects.Interfaces;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Commercial.Core.SecretsManager.Queries.Projects;
|
||||
|
||||
@ -11,13 +16,22 @@ public class MaxProjectsQuery : IMaxProjectsQuery
|
||||
{
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IProjectRepository _projectRepository;
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
private readonly ILicensingService _licensingService;
|
||||
private readonly IPricingClient _pricingClient;
|
||||
|
||||
public MaxProjectsQuery(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IProjectRepository projectRepository)
|
||||
IProjectRepository projectRepository,
|
||||
IGlobalSettings globalSettings,
|
||||
ILicensingService licensingService,
|
||||
IPricingClient pricingClient)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_projectRepository = projectRepository;
|
||||
_globalSettings = globalSettings;
|
||||
_licensingService = licensingService;
|
||||
_pricingClient = pricingClient;
|
||||
}
|
||||
|
||||
public async Task<(short? max, bool? overMax)> GetByOrgIdAsync(Guid organizationId, int projectsToAdd)
|
||||
@ -28,19 +42,47 @@ public class MaxProjectsQuery : IMaxProjectsQuery
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// TODO: PRICING -> https://bitwarden.atlassian.net/browse/PM-17122
|
||||
var plan = StaticStore.GetPlan(org.PlanType);
|
||||
if (plan?.SecretsManager == null)
|
||||
var (planType, maxProjects) = await GetPlanTypeAndMaxProjectsAsync(org);
|
||||
|
||||
if (planType != PlanType.Free)
|
||||
{
|
||||
throw new BadRequestException("Existing plan not found.");
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
if (plan.Type == PlanType.Free)
|
||||
var projects = await _projectRepository.GetProjectCountByOrganizationIdAsync(organizationId);
|
||||
return ((short? max, bool? overMax))(projects + projectsToAdd > maxProjects ? (maxProjects, true) : (maxProjects, false));
|
||||
}
|
||||
|
||||
private async Task<(PlanType planType, int maxProjects)> GetPlanTypeAndMaxProjectsAsync(Organization organization)
|
||||
{
|
||||
if (_globalSettings.SelfHosted)
|
||||
{
|
||||
var projects = await _projectRepository.GetProjectCountByOrganizationIdAsync(organizationId);
|
||||
return ((short? max, bool? overMax))(projects + projectsToAdd > plan.SecretsManager.MaxProjects ? (plan.SecretsManager.MaxProjects, true) : (plan.SecretsManager.MaxProjects, false));
|
||||
var license = await _licensingService.ReadOrganizationLicenseAsync(organization);
|
||||
|
||||
if (license == null)
|
||||
{
|
||||
throw new BadRequestException("License not found.");
|
||||
}
|
||||
|
||||
var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license);
|
||||
var maxProjects = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.SmMaxProjects);
|
||||
|
||||
if (!maxProjects.HasValue)
|
||||
{
|
||||
throw new BadRequestException("License does not contain a value for max Secrets Manager projects");
|
||||
}
|
||||
|
||||
var planType = claimsPrincipal.GetValue<PlanType>(OrganizationLicenseConstants.PlanType);
|
||||
return (planType, maxProjects.Value);
|
||||
}
|
||||
|
||||
return (null, null);
|
||||
var plan = await _pricingClient.GetPlan(organization.PlanType);
|
||||
|
||||
if (plan is { SupportsSecretsManager: true })
|
||||
{
|
||||
return (plan.Type, plan.SecretsManager.MaxProjects);
|
||||
}
|
||||
|
||||
throw new BadRequestException("Existing plan not found.");
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
using Bit.Commercial.Core.AdminConsole.Services;
|
||||
using Bit.Commercial.Core.Test.AdminConsole.AutoFixture;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
@ -7,6 +8,7 @@ using Bit.Core.AdminConsole.Models.Business.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
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.Context;
|
||||
@ -38,7 +40,7 @@ public class ProviderServiceTests
|
||||
public async Task CompleteSetupAsync_UserIdIsInvalid_Throws(SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.CompleteSetupAsync(default, default, default, default));
|
||||
() => sutProvider.Sut.CompleteSetupAsync(default, default, default, default, null));
|
||||
Assert.Contains("Invalid owner.", exception.Message);
|
||||
}
|
||||
|
||||
@ -50,12 +52,85 @@ public class ProviderServiceTests
|
||||
userService.GetUserByIdAsync(user.Id).Returns(user);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default));
|
||||
() => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default, null));
|
||||
Assert.Contains("Invalid token.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo,
|
||||
public async Task CompleteSetupAsync_InvalidTaxInfo_ThrowsBadRequestException(
|
||||
User user,
|
||||
Provider provider,
|
||||
string key,
|
||||
TaxInfo taxInfo,
|
||||
TokenizedPaymentSource tokenizedPaymentSource,
|
||||
[ProviderUser] ProviderUser providerUser,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
providerUser.ProviderId = provider.Id;
|
||||
providerUser.UserId = user.Id;
|
||||
var userService = sutProvider.GetDependency<IUserService>();
|
||||
userService.GetUserByIdAsync(user.Id).Returns(user);
|
||||
|
||||
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
|
||||
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
|
||||
|
||||
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
|
||||
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
|
||||
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
|
||||
.Returns(protector);
|
||||
|
||||
sutProvider.Create();
|
||||
|
||||
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||
|
||||
taxInfo.BillingAddressCountry = null;
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource));
|
||||
|
||||
Assert.Equal("Both address and postal code are required to set up your provider.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CompleteSetupAsync_InvalidTokenizedPaymentSource_ThrowsBadRequestException(
|
||||
User user,
|
||||
Provider provider,
|
||||
string key,
|
||||
TaxInfo taxInfo,
|
||||
TokenizedPaymentSource tokenizedPaymentSource,
|
||||
[ProviderUser] ProviderUser providerUser,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
providerUser.ProviderId = provider.Id;
|
||||
providerUser.UserId = user.Id;
|
||||
var userService = sutProvider.GetDependency<IUserService>();
|
||||
userService.GetUserByIdAsync(user.Id).Returns(user);
|
||||
|
||||
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
|
||||
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
|
||||
|
||||
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
|
||||
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
|
||||
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
|
||||
.Returns(protector);
|
||||
|
||||
sutProvider.Create();
|
||||
|
||||
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
|
||||
tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay };
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource));
|
||||
|
||||
Assert.Equal("A payment method is required to set up your provider.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource,
|
||||
[ProviderUser] ProviderUser providerUser,
|
||||
SutProvider<ProviderService> sutProvider)
|
||||
{
|
||||
@ -75,7 +150,7 @@ public class ProviderServiceTests
|
||||
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
|
||||
|
||||
var customer = new Customer { Id = "customer_id" };
|
||||
providerBillingService.SetupCustomer(provider, taxInfo).Returns(customer);
|
||||
providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource).Returns(customer);
|
||||
|
||||
var subscription = new Subscription { Id = "subscription_id" };
|
||||
providerBillingService.SetupSubscription(provider).Returns(subscription);
|
||||
@ -84,7 +159,7 @@ public class ProviderServiceTests
|
||||
|
||||
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||
|
||||
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo);
|
||||
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource);
|
||||
|
||||
await sutProvider.GetDependency<IProviderRepository>().Received().UpsertAsync(Arg.Is<Provider>(
|
||||
p =>
|
||||
|
@ -2,14 +2,17 @@
|
||||
using System.Net;
|
||||
using Bit.Commercial.Core.Billing;
|
||||
using Bit.Commercial.Core.Billing.Models;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
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.Services;
|
||||
@ -24,11 +27,17 @@ using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Braintree;
|
||||
using CsvHelper;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
using static Bit.Core.Test.Billing.Utilities;
|
||||
using Address = Stripe.Address;
|
||||
using Customer = Stripe.Customer;
|
||||
using PaymentMethod = Stripe.PaymentMethod;
|
||||
using Subscription = Stripe.Subscription;
|
||||
|
||||
namespace Bit.Commercial.Core.Test.Billing;
|
||||
|
||||
@ -833,7 +842,7 @@ public class ProviderBillingServiceTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetupCustomer_Success(
|
||||
public async Task SetupCustomer_NoPaymentMethod_Success(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
@ -877,6 +886,301 @@ public class ProviderBillingServiceTests
|
||||
Assert.Equivalent(expected, actual);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetupCustomer_InvalidRequiredPaymentMethod_ThrowsBillingException(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo,
|
||||
TokenizedPaymentSource tokenizedPaymentSource)
|
||||
{
|
||||
provider.Name = "MSP";
|
||||
|
||||
sutProvider.GetDependency<ITaxService>()
|
||||
.GetStripeTaxCode(Arg.Is<string>(
|
||||
p => p == taxInfo.BillingAddressCountry),
|
||||
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||
.Returns(taxInfo.TaxIdType);
|
||||
|
||||
taxInfo.BillingAddressCountry = "AD";
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
|
||||
tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay };
|
||||
|
||||
await ThrowsBillingExceptionAsync(() =>
|
||||
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetupCustomer_WithBankAccount_Error_Reverts(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
provider.Name = "MSP";
|
||||
|
||||
sutProvider.GetDependency<ITaxService>()
|
||||
.GetStripeTaxCode(Arg.Is<string>(
|
||||
p => p == taxInfo.BillingAddressCountry),
|
||||
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||
.Returns(taxInfo.TaxIdType);
|
||||
|
||||
taxInfo.BillingAddressCountry = "AD";
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token");
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
|
||||
stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
|
||||
options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([
|
||||
new SetupIntent { Id = "setup_intent_id" }
|
||||
]);
|
||||
|
||||
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||
o.Address.City == taxInfo.BillingAddressCity &&
|
||||
o.Address.State == taxInfo.BillingAddressState &&
|
||||
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||
o.Email == provider.BillingEmail &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||
o.Metadata["region"] == "" &&
|
||||
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||
.Throws<StripeException>();
|
||||
|
||||
sutProvider.GetDependency<ISetupIntentCache>().Get(provider.Id).Returns("setup_intent_id");
|
||||
|
||||
await Assert.ThrowsAsync<StripeException>(() =>
|
||||
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
|
||||
|
||||
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Set(provider.Id, "setup_intent_id");
|
||||
|
||||
await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_id", Arg.Is<SetupIntentCancelOptions>(options =>
|
||||
options.CancellationReason == "abandoned"));
|
||||
|
||||
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Remove(provider.Id);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetupCustomer_WithPayPal_Error_Reverts(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
provider.Name = "MSP";
|
||||
|
||||
sutProvider.GetDependency<ITaxService>()
|
||||
.GetStripeTaxCode(Arg.Is<string>(
|
||||
p => p == taxInfo.BillingAddressCountry),
|
||||
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||
.Returns(taxInfo.TaxIdType);
|
||||
|
||||
taxInfo.BillingAddressCountry = "AD";
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token");
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token)
|
||||
.Returns("braintree_customer_id");
|
||||
|
||||
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||
o.Address.City == taxInfo.BillingAddressCity &&
|
||||
o.Address.State == taxInfo.BillingAddressState &&
|
||||
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||
o.Email == provider.BillingEmail &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||
o.Metadata["region"] == "" &&
|
||||
o.Metadata["btCustomerId"] == "braintree_customer_id" &&
|
||||
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||
.Throws<StripeException>();
|
||||
|
||||
await Assert.ThrowsAsync<StripeException>(() =>
|
||||
sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource));
|
||||
|
||||
await sutProvider.GetDependency<IBraintreeGateway>().Customer.Received(1).DeleteAsync("braintree_customer_id");
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetupCustomer_WithBankAccount_Success(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
provider.Name = "MSP";
|
||||
|
||||
sutProvider.GetDependency<ITaxService>()
|
||||
.GetStripeTaxCode(Arg.Is<string>(
|
||||
p => p == taxInfo.BillingAddressCountry),
|
||||
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||
.Returns(taxInfo.TaxIdType);
|
||||
|
||||
taxInfo.BillingAddressCountry = "AD";
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
var expected = new Customer
|
||||
{
|
||||
Id = "customer_id",
|
||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||
};
|
||||
|
||||
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token");
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
|
||||
stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
|
||||
options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([
|
||||
new SetupIntent { Id = "setup_intent_id" }
|
||||
]);
|
||||
|
||||
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||
o.Address.City == taxInfo.BillingAddressCity &&
|
||||
o.Address.State == taxInfo.BillingAddressState &&
|
||||
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||
o.Email == provider.BillingEmail &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||
o.Metadata["region"] == "" &&
|
||||
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||
.Returns(expected);
|
||||
|
||||
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
|
||||
|
||||
Assert.Equivalent(expected, actual);
|
||||
|
||||
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Set(provider.Id, "setup_intent_id");
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetupCustomer_WithPayPal_Success(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
provider.Name = "MSP";
|
||||
|
||||
sutProvider.GetDependency<ITaxService>()
|
||||
.GetStripeTaxCode(Arg.Is<string>(
|
||||
p => p == taxInfo.BillingAddressCountry),
|
||||
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||
.Returns(taxInfo.TaxIdType);
|
||||
|
||||
taxInfo.BillingAddressCountry = "AD";
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
var expected = new Customer
|
||||
{
|
||||
Id = "customer_id",
|
||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||
};
|
||||
|
||||
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token");
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token)
|
||||
.Returns("braintree_customer_id");
|
||||
|
||||
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||
o.Address.City == taxInfo.BillingAddressCity &&
|
||||
o.Address.State == taxInfo.BillingAddressState &&
|
||||
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||
o.Email == provider.BillingEmail &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||
o.Metadata["region"] == "" &&
|
||||
o.Metadata["btCustomerId"] == "braintree_customer_id" &&
|
||||
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||
.Returns(expected);
|
||||
|
||||
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
|
||||
|
||||
Assert.Equivalent(expected, actual);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetupCustomer_WithCard_Success(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
provider.Name = "MSP";
|
||||
|
||||
sutProvider.GetDependency<ITaxService>()
|
||||
.GetStripeTaxCode(Arg.Is<string>(
|
||||
p => p == taxInfo.BillingAddressCountry),
|
||||
Arg.Is<string>(p => p == taxInfo.TaxIdNumber))
|
||||
.Returns(taxInfo.TaxIdType);
|
||||
|
||||
taxInfo.BillingAddressCountry = "AD";
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
var expected = new Customer
|
||||
{
|
||||
Id = "customer_id",
|
||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||
};
|
||||
|
||||
var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token");
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
|
||||
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
|
||||
o.Address.Country == taxInfo.BillingAddressCountry &&
|
||||
o.Address.PostalCode == taxInfo.BillingAddressPostalCode &&
|
||||
o.Address.Line1 == taxInfo.BillingAddressLine1 &&
|
||||
o.Address.Line2 == taxInfo.BillingAddressLine2 &&
|
||||
o.Address.City == taxInfo.BillingAddressCity &&
|
||||
o.Address.State == taxInfo.BillingAddressState &&
|
||||
o.Description == WebUtility.HtmlDecode(provider.BusinessName) &&
|
||||
o.Email == provider.BillingEmail &&
|
||||
o.PaymentMethod == tokenizedPaymentSource.Token &&
|
||||
o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" &&
|
||||
o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" &&
|
||||
o.Metadata["region"] == "" &&
|
||||
o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType &&
|
||||
o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber))
|
||||
.Returns(expected);
|
||||
|
||||
var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource);
|
||||
|
||||
Assert.Equivalent(expected, actual);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
@ -1044,7 +1348,7 @@ public class ProviderBillingServiceTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetupSubscription_Succeeds(
|
||||
public async Task SetupSubscription_SendInvoice_Succeeds(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider)
|
||||
{
|
||||
@ -1127,6 +1431,303 @@ public class ProviderBillingServiceTests
|
||||
Assert.Equivalent(expected, actual);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetupSubscription_ChargeAutomatically_HasCard_Succeeds(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider)
|
||||
{
|
||||
provider.Type = ProviderType.Msp;
|
||||
provider.GatewaySubscriptionId = null;
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = "customer_id",
|
||||
InvoiceSettings = new CustomerInvoiceSettings
|
||||
{
|
||||
DefaultPaymentMethodId = "pm_123"
|
||||
},
|
||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetCustomerOrThrow(
|
||||
provider,
|
||||
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))).Returns(customer);
|
||||
|
||||
var providerPlans = new List<ProviderPlan>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
PlanType = PlanType.TeamsMonthly,
|
||||
SeatMinimum = 100,
|
||||
PurchasedSeats = 0,
|
||||
AllocatedSeats = 0
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
PlanType = PlanType.EnterpriseMonthly,
|
||||
SeatMinimum = 100,
|
||||
PurchasedSeats = 0,
|
||||
AllocatedSeats = 0
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var plan in providerPlans)
|
||||
{
|
||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||
.Returns(StaticStore.GetPlan(plan.PlanType));
|
||||
}
|
||||
|
||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||
.Returns(providerPlans);
|
||||
|
||||
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
|
||||
|
||||
sutProvider.GetDependency<IAutomaticTaxStrategy>()
|
||||
.When(x => x.SetCreateOptions(
|
||||
Arg.Is<SubscriptionCreateOptions>(options =>
|
||||
options.Customer == "customer_id")
|
||||
, Arg.Is<Customer>(p => p == customer)))
|
||||
.Do(x =>
|
||||
{
|
||||
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = true
|
||||
};
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
|
||||
sub =>
|
||||
sub.AutomaticTax.Enabled == true &&
|
||||
sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically &&
|
||||
sub.Customer == "customer_id" &&
|
||||
sub.DaysUntilDue == null &&
|
||||
sub.Items.Count == 2 &&
|
||||
sub.Items.ElementAt(0).Price == ProviderPriceAdapter.MSP.Active.Teams &&
|
||||
sub.Items.ElementAt(0).Quantity == 100 &&
|
||||
sub.Items.ElementAt(1).Price == ProviderPriceAdapter.MSP.Active.Enterprise &&
|
||||
sub.Items.ElementAt(1).Quantity == 100 &&
|
||||
sub.Metadata["providerId"] == provider.Id.ToString() &&
|
||||
sub.OffSession == true &&
|
||||
sub.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
|
||||
sub.TrialPeriodDays == 14)).Returns(expected);
|
||||
|
||||
var actual = await sutProvider.Sut.SetupSubscription(provider);
|
||||
|
||||
Assert.Equivalent(expected, actual);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetupSubscription_ChargeAutomatically_HasBankAccount_Succeeds(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider)
|
||||
{
|
||||
provider.Type = ProviderType.Msp;
|
||||
provider.GatewaySubscriptionId = null;
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = "customer_id",
|
||||
InvoiceSettings = new CustomerInvoiceSettings(),
|
||||
Metadata = new Dictionary<string, string>(),
|
||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetCustomerOrThrow(
|
||||
provider,
|
||||
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))).Returns(customer);
|
||||
|
||||
var providerPlans = new List<ProviderPlan>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
PlanType = PlanType.TeamsMonthly,
|
||||
SeatMinimum = 100,
|
||||
PurchasedSeats = 0,
|
||||
AllocatedSeats = 0
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
PlanType = PlanType.EnterpriseMonthly,
|
||||
SeatMinimum = 100,
|
||||
PurchasedSeats = 0,
|
||||
AllocatedSeats = 0
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var plan in providerPlans)
|
||||
{
|
||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||
.Returns(StaticStore.GetPlan(plan.PlanType));
|
||||
}
|
||||
|
||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||
.Returns(providerPlans);
|
||||
|
||||
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
|
||||
|
||||
sutProvider.GetDependency<IAutomaticTaxStrategy>()
|
||||
.When(x => x.SetCreateOptions(
|
||||
Arg.Is<SubscriptionCreateOptions>(options =>
|
||||
options.Customer == "customer_id")
|
||||
, Arg.Is<Customer>(p => p == customer)))
|
||||
.Do(x =>
|
||||
{
|
||||
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = true
|
||||
};
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
|
||||
const string setupIntentId = "seti_123";
|
||||
|
||||
sutProvider.GetDependency<ISetupIntentCache>().Get(provider.Id).Returns(setupIntentId);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().SetupIntentGet(setupIntentId, Arg.Is<SetupIntentGetOptions>(options =>
|
||||
options.Expand.Contains("payment_method"))).Returns(new SetupIntent
|
||||
{
|
||||
Id = setupIntentId,
|
||||
Status = "requires_action",
|
||||
NextAction = new SetupIntentNextAction
|
||||
{
|
||||
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
|
||||
},
|
||||
PaymentMethod = new PaymentMethod
|
||||
{
|
||||
UsBankAccount = new PaymentMethodUsBankAccount()
|
||||
}
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
|
||||
sub =>
|
||||
sub.AutomaticTax.Enabled == true &&
|
||||
sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically &&
|
||||
sub.Customer == "customer_id" &&
|
||||
sub.DaysUntilDue == null &&
|
||||
sub.Items.Count == 2 &&
|
||||
sub.Items.ElementAt(0).Price == ProviderPriceAdapter.MSP.Active.Teams &&
|
||||
sub.Items.ElementAt(0).Quantity == 100 &&
|
||||
sub.Items.ElementAt(1).Price == ProviderPriceAdapter.MSP.Active.Enterprise &&
|
||||
sub.Items.ElementAt(1).Quantity == 100 &&
|
||||
sub.Metadata["providerId"] == provider.Id.ToString() &&
|
||||
sub.OffSession == true &&
|
||||
sub.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
|
||||
sub.TrialPeriodDays == 14)).Returns(expected);
|
||||
|
||||
var actual = await sutProvider.Sut.SetupSubscription(provider);
|
||||
|
||||
Assert.Equivalent(expected, actual);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SetupSubscription_ChargeAutomatically_HasPayPal_Succeeds(
|
||||
SutProvider<ProviderBillingService> sutProvider,
|
||||
Provider provider)
|
||||
{
|
||||
provider.Type = ProviderType.Msp;
|
||||
provider.GatewaySubscriptionId = null;
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = "customer_id",
|
||||
InvoiceSettings = new CustomerInvoiceSettings(),
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["btCustomerId"] = "braintree_customer_id"
|
||||
},
|
||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetCustomerOrThrow(
|
||||
provider,
|
||||
Arg.Is<CustomerGetOptions>(p => p.Expand.Contains("tax") || p.Expand.Contains("tax_ids"))).Returns(customer);
|
||||
|
||||
var providerPlans = new List<ProviderPlan>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
PlanType = PlanType.TeamsMonthly,
|
||||
SeatMinimum = 100,
|
||||
PurchasedSeats = 0,
|
||||
AllocatedSeats = 0
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = provider.Id,
|
||||
PlanType = PlanType.EnterpriseMonthly,
|
||||
SeatMinimum = 100,
|
||||
PurchasedSeats = 0,
|
||||
AllocatedSeats = 0
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var plan in providerPlans)
|
||||
{
|
||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
|
||||
.Returns(StaticStore.GetPlan(plan.PlanType));
|
||||
}
|
||||
|
||||
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
|
||||
.Returns(providerPlans);
|
||||
|
||||
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
|
||||
|
||||
sutProvider.GetDependency<IAutomaticTaxStrategy>()
|
||||
.When(x => x.SetCreateOptions(
|
||||
Arg.Is<SubscriptionCreateOptions>(options =>
|
||||
options.Customer == "customer_id")
|
||||
, Arg.Is<Customer>(p => p == customer)))
|
||||
.Do(x =>
|
||||
{
|
||||
x.Arg<SubscriptionCreateOptions>().AutomaticTax = new SubscriptionAutomaticTaxOptions
|
||||
{
|
||||
Enabled = true
|
||||
};
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PM19956_RequireProviderPaymentMethodDuringSetup).Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
|
||||
sub =>
|
||||
sub.AutomaticTax.Enabled == true &&
|
||||
sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically &&
|
||||
sub.Customer == "customer_id" &&
|
||||
sub.DaysUntilDue == null &&
|
||||
sub.Items.Count == 2 &&
|
||||
sub.Items.ElementAt(0).Price == ProviderPriceAdapter.MSP.Active.Teams &&
|
||||
sub.Items.ElementAt(0).Quantity == 100 &&
|
||||
sub.Items.ElementAt(1).Price == ProviderPriceAdapter.MSP.Active.Enterprise &&
|
||||
sub.Items.ElementAt(1).Quantity == 100 &&
|
||||
sub.Metadata["providerId"] == provider.Id.ToString() &&
|
||||
sub.OffSession == true &&
|
||||
sub.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
|
||||
sub.TrialPeriodDays == 14)).Returns(expected);
|
||||
|
||||
var actual = await sutProvider.Sut.SetupSubscription(provider);
|
||||
|
||||
Assert.Equivalent(expected, actual);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateSeatMinimums
|
||||
|
@ -1,9 +1,16 @@
|
||||
using Bit.Commercial.Core.SecretsManager.Queries.Projects;
|
||||
using System.Security.Claims;
|
||||
using Bit.Commercial.Core.SecretsManager.Queries.Projects;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Licenses;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
@ -32,7 +39,7 @@ public class MaxProjectsQueryTests
|
||||
[BitAutoData(PlanType.FamiliesAnnually2019)]
|
||||
[BitAutoData(PlanType.Custom)]
|
||||
[BitAutoData(PlanType.FamiliesAnnually)]
|
||||
public async Task GetByOrgIdAsync_SmPlanIsNull_ThrowsBadRequest(PlanType planType,
|
||||
public async Task GetByOrgIdAsync_Cloud_SmPlanIsNull_ThrowsBadRequest(PlanType planType,
|
||||
SutProvider<MaxProjectsQuery> sutProvider, Organization organization)
|
||||
{
|
||||
organization.PlanType = planType;
|
||||
@ -40,6 +47,34 @@ public class MaxProjectsQueryTests
|
||||
.GetByIdAsync(organization.Id)
|
||||
.Returns(organization);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
|
||||
var plan = StaticStore.GetPlan(planType);
|
||||
sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType).Returns(plan);
|
||||
|
||||
await Assert.ThrowsAsync<BadRequestException>(
|
||||
async () => await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1));
|
||||
|
||||
await sutProvider.GetDependency<IProjectRepository>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.GetProjectCountByOrganizationIdAsync(organization.Id);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetByOrgIdAsync_SelfHosted_NoMaxProjectsClaim_ThrowsBadRequest(
|
||||
SutProvider<MaxProjectsQuery> sutProvider, Organization organization)
|
||||
{
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(organization.Id)
|
||||
.Returns(organization);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(true);
|
||||
|
||||
var license = new OrganizationLicense();
|
||||
var claimsPrincipal = new ClaimsPrincipal();
|
||||
sutProvider.GetDependency<ILicensingService>().ReadOrganizationLicenseAsync(organization).Returns(license);
|
||||
sutProvider.GetDependency<ILicensingService>().GetClaimsPrincipalFromLicense(license).Returns(claimsPrincipal);
|
||||
|
||||
await Assert.ThrowsAsync<BadRequestException>(
|
||||
async () => await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1));
|
||||
|
||||
@ -62,12 +97,58 @@ public class MaxProjectsQueryTests
|
||||
[BitAutoData(PlanType.EnterpriseAnnually2019)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually2020)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually)]
|
||||
public async Task GetByOrgIdAsync_SmNoneFreePlans_ReturnsNull(PlanType planType,
|
||||
public async Task GetByOrgIdAsync_Cloud_SmNoneFreePlans_ReturnsNull(PlanType planType,
|
||||
SutProvider<MaxProjectsQuery> sutProvider, Organization organization)
|
||||
{
|
||||
organization.PlanType = planType;
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
|
||||
var plan = StaticStore.GetPlan(planType);
|
||||
sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType).Returns(plan);
|
||||
|
||||
var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1);
|
||||
|
||||
Assert.Null(limit);
|
||||
Assert.Null(overLimit);
|
||||
|
||||
await sutProvider.GetDependency<IProjectRepository>().DidNotReceiveWithAnyArgs()
|
||||
.GetProjectCountByOrganizationIdAsync(organization.Id);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(PlanType.TeamsMonthly2019)]
|
||||
[BitAutoData(PlanType.TeamsMonthly2020)]
|
||||
[BitAutoData(PlanType.TeamsMonthly)]
|
||||
[BitAutoData(PlanType.TeamsAnnually2019)]
|
||||
[BitAutoData(PlanType.TeamsAnnually2020)]
|
||||
[BitAutoData(PlanType.TeamsAnnually)]
|
||||
[BitAutoData(PlanType.TeamsStarter)]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly2019)]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly2020)]
|
||||
[BitAutoData(PlanType.EnterpriseMonthly)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually2019)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually2020)]
|
||||
[BitAutoData(PlanType.EnterpriseAnnually)]
|
||||
public async Task GetByOrgIdAsync_SelfHosted_SmNoneFreePlans_ReturnsNull(PlanType planType,
|
||||
SutProvider<MaxProjectsQuery> sutProvider, Organization organization)
|
||||
{
|
||||
organization.PlanType = planType;
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(true);
|
||||
|
||||
var license = new OrganizationLicense();
|
||||
var plan = StaticStore.GetPlan(planType);
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new (nameof(OrganizationLicenseConstants.PlanType), organization.PlanType.ToString()),
|
||||
new (nameof(OrganizationLicenseConstants.SmMaxProjects), plan.SecretsManager.MaxProjects.ToString())
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, "TestAuthenticationType");
|
||||
var claimsPrincipal = new ClaimsPrincipal(identity);
|
||||
sutProvider.GetDependency<ILicensingService>().ReadOrganizationLicenseAsync(organization).Returns(license);
|
||||
sutProvider.GetDependency<ILicensingService>().GetClaimsPrincipalFromLicense(license).Returns(claimsPrincipal);
|
||||
|
||||
var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1);
|
||||
|
||||
Assert.Null(limit);
|
||||
@ -102,7 +183,7 @@ public class MaxProjectsQueryTests
|
||||
[BitAutoData(PlanType.Free, 3, 4, true)]
|
||||
[BitAutoData(PlanType.Free, 4, 4, true)]
|
||||
[BitAutoData(PlanType.Free, 40, 4, true)]
|
||||
public async Task GetByOrgIdAsync_SmFreePlan__Success(PlanType planType, int projects, int projectsToAdd, bool expectedOverMax,
|
||||
public async Task GetByOrgIdAsync_Cloud_SmFreePlan__Success(PlanType planType, int projects, int projectsToAdd, bool expectedOverMax,
|
||||
SutProvider<MaxProjectsQuery> sutProvider, Organization organization)
|
||||
{
|
||||
organization.PlanType = planType;
|
||||
@ -110,6 +191,67 @@ public class MaxProjectsQueryTests
|
||||
sutProvider.GetDependency<IProjectRepository>().GetProjectCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(projects);
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
|
||||
var plan = StaticStore.GetPlan(planType);
|
||||
sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType).Returns(plan);
|
||||
|
||||
var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, projectsToAdd);
|
||||
|
||||
Assert.NotNull(max);
|
||||
Assert.NotNull(overMax);
|
||||
Assert.Equal(3, max.Value);
|
||||
Assert.Equal(expectedOverMax, overMax);
|
||||
|
||||
await sutProvider.GetDependency<IProjectRepository>().Received(1)
|
||||
.GetProjectCountByOrganizationIdAsync(organization.Id);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(PlanType.Free, 0, 1, false)]
|
||||
[BitAutoData(PlanType.Free, 1, 1, false)]
|
||||
[BitAutoData(PlanType.Free, 2, 1, false)]
|
||||
[BitAutoData(PlanType.Free, 3, 1, true)]
|
||||
[BitAutoData(PlanType.Free, 4, 1, true)]
|
||||
[BitAutoData(PlanType.Free, 40, 1, true)]
|
||||
[BitAutoData(PlanType.Free, 0, 2, false)]
|
||||
[BitAutoData(PlanType.Free, 1, 2, false)]
|
||||
[BitAutoData(PlanType.Free, 2, 2, true)]
|
||||
[BitAutoData(PlanType.Free, 3, 2, true)]
|
||||
[BitAutoData(PlanType.Free, 4, 2, true)]
|
||||
[BitAutoData(PlanType.Free, 40, 2, true)]
|
||||
[BitAutoData(PlanType.Free, 0, 3, false)]
|
||||
[BitAutoData(PlanType.Free, 1, 3, true)]
|
||||
[BitAutoData(PlanType.Free, 2, 3, true)]
|
||||
[BitAutoData(PlanType.Free, 3, 3, true)]
|
||||
[BitAutoData(PlanType.Free, 4, 3, true)]
|
||||
[BitAutoData(PlanType.Free, 40, 3, true)]
|
||||
[BitAutoData(PlanType.Free, 0, 4, true)]
|
||||
[BitAutoData(PlanType.Free, 1, 4, true)]
|
||||
[BitAutoData(PlanType.Free, 2, 4, true)]
|
||||
[BitAutoData(PlanType.Free, 3, 4, true)]
|
||||
[BitAutoData(PlanType.Free, 4, 4, true)]
|
||||
[BitAutoData(PlanType.Free, 40, 4, true)]
|
||||
public async Task GetByOrgIdAsync_SelfHosted_SmFreePlan__Success(PlanType planType, int projects, int projectsToAdd, bool expectedOverMax,
|
||||
SutProvider<MaxProjectsQuery> sutProvider, Organization organization)
|
||||
{
|
||||
organization.PlanType = planType;
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
sutProvider.GetDependency<IProjectRepository>().GetProjectCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(projects);
|
||||
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(true);
|
||||
|
||||
var license = new OrganizationLicense();
|
||||
var plan = StaticStore.GetPlan(planType);
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new (nameof(OrganizationLicenseConstants.PlanType), organization.PlanType.ToString()),
|
||||
new (nameof(OrganizationLicenseConstants.SmMaxProjects), plan.SecretsManager.MaxProjects.ToString())
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, "TestAuthenticationType");
|
||||
var claimsPrincipal = new ClaimsPrincipal(identity);
|
||||
sutProvider.GetDependency<ILicensingService>().ReadOrganizationLicenseAsync(organization).Returns(license);
|
||||
sutProvider.GetDependency<ILicensingService>().GetClaimsPrincipalFromLicense(license).Returns(claimsPrincipal);
|
||||
|
||||
var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, projectsToAdd);
|
||||
|
||||
Assert.NotNull(max);
|
||||
|
@ -9,7 +9,7 @@ namespace Bit.Api.AdminConsole.Authorization;
|
||||
public static class HttpContextExtensions
|
||||
{
|
||||
public const string NoOrgIdError =
|
||||
"A route decorated with with '[Authorize<Requirement>]' must include a route value named 'orgId' either through the [Controller] attribute or through a '[Http*]' attribute.";
|
||||
"A route decorated with with '[Authorize<Requirement>]' must include a route value named 'orgId' or 'organizationId' either through the [Controller] attribute or through a '[Http*]' attribute.";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the result of the callback, caching it in HttpContext.Features for the lifetime of the request.
|
||||
@ -61,19 +61,27 @@ public static class HttpContextExtensions
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Parses the {orgId} route parameter into a Guid, or throws if the {orgId} is not present or not a valid guid.
|
||||
/// Parses the {orgId} or {organizationId} route parameter into a Guid, or throws if neither are present or are not valid guids.
|
||||
/// </summary>
|
||||
/// <param name="httpContext"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="InvalidOperationException"></exception>
|
||||
public static Guid GetOrganizationId(this HttpContext httpContext)
|
||||
{
|
||||
httpContext.GetRouteData().Values.TryGetValue("orgId", out var orgIdParam);
|
||||
if (orgIdParam == null || !Guid.TryParse(orgIdParam.ToString(), out var orgId))
|
||||
var routeValues = httpContext.GetRouteData().Values;
|
||||
|
||||
routeValues.TryGetValue("orgId", out var orgIdParam);
|
||||
if (orgIdParam != null && Guid.TryParse(orgIdParam.ToString(), out var orgId))
|
||||
{
|
||||
throw new InvalidOperationException(NoOrgIdError);
|
||||
return orgId;
|
||||
}
|
||||
|
||||
return orgId;
|
||||
routeValues.TryGetValue("organizationId", out var organizationIdParam);
|
||||
if (organizationIdParam != null && Guid.TryParse(organizationIdParam.ToString(), out var organizationId))
|
||||
{
|
||||
return organizationId;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(NoOrgIdError);
|
||||
}
|
||||
}
|
||||
|
@ -63,6 +63,7 @@ public class OrganizationUsersController : Controller
|
||||
private readonly IPricingClient _pricingClient;
|
||||
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
|
||||
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
|
||||
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
|
||||
|
||||
public OrganizationUsersController(
|
||||
IOrganizationRepository organizationRepository,
|
||||
@ -89,7 +90,8 @@ public class OrganizationUsersController : Controller
|
||||
IFeatureService featureService,
|
||||
IPricingClient pricingClient,
|
||||
IConfirmOrganizationUserCommand confirmOrganizationUserCommand,
|
||||
IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
|
||||
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
|
||||
IInitPendingOrganizationCommand initPendingOrganizationCommand)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
@ -116,6 +118,7 @@ public class OrganizationUsersController : Controller
|
||||
_pricingClient = pricingClient;
|
||||
_confirmOrganizationUserCommand = confirmOrganizationUserCommand;
|
||||
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
|
||||
_initPendingOrganizationCommand = initPendingOrganizationCommand;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@ -313,7 +316,7 @@ public class OrganizationUsersController : Controller
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
await _organizationService.InitPendingOrganization(user, orgId, organizationUserId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName, model.Token);
|
||||
await _initPendingOrganizationCommand.InitPendingOrganizationAsync(user, orgId, organizationUserId, model.Keys.PublicKey, model.Keys.EncryptedPrivateKey, model.CollectionName, model.Token);
|
||||
await _acceptOrgUserCommand.AcceptOrgUserByEmailTokenAsync(organizationUserId, user, model.Token, _userService);
|
||||
await _confirmOrganizationUserCommand.ConfirmUserAsync(orgId, organizationUserId, model.Key, user.Id);
|
||||
}
|
||||
|
@ -84,22 +84,22 @@ public class ProvidersController : Controller
|
||||
|
||||
var userId = _userService.GetProperUserId(User).Value;
|
||||
|
||||
var taxInfo = model.TaxInfo != null
|
||||
? new TaxInfo
|
||||
{
|
||||
BillingAddressCountry = model.TaxInfo.Country,
|
||||
BillingAddressPostalCode = model.TaxInfo.PostalCode,
|
||||
TaxIdNumber = model.TaxInfo.TaxId,
|
||||
BillingAddressLine1 = model.TaxInfo.Line1,
|
||||
BillingAddressLine2 = model.TaxInfo.Line2,
|
||||
BillingAddressCity = model.TaxInfo.City,
|
||||
BillingAddressState = model.TaxInfo.State
|
||||
}
|
||||
: null;
|
||||
var taxInfo = new TaxInfo
|
||||
{
|
||||
BillingAddressCountry = model.TaxInfo.Country,
|
||||
BillingAddressPostalCode = model.TaxInfo.PostalCode,
|
||||
TaxIdNumber = model.TaxInfo.TaxId,
|
||||
BillingAddressLine1 = model.TaxInfo.Line1,
|
||||
BillingAddressLine2 = model.TaxInfo.Line2,
|
||||
BillingAddressCity = model.TaxInfo.City,
|
||||
BillingAddressState = model.TaxInfo.State
|
||||
};
|
||||
|
||||
var tokenizedPaymentSource = model.PaymentSource?.ToDomain();
|
||||
|
||||
var response =
|
||||
await _providerService.CompleteSetupAsync(model.ToProvider(provider), userId, model.Token, model.Key,
|
||||
taxInfo);
|
||||
taxInfo, tokenizedPaymentSource);
|
||||
|
||||
return new ProviderResponseModel(response);
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Api.Billing.Models.Requests;
|
||||
using Bit.Api.Models.Request;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.Utilities;
|
||||
@ -23,7 +24,9 @@ public class ProviderSetupRequestModel
|
||||
public string Token { get; set; }
|
||||
[Required]
|
||||
public string Key { get; set; }
|
||||
[Required]
|
||||
public ExpandedTaxInfoUpdateRequestModel TaxInfo { get; set; }
|
||||
public TokenizedPaymentSourceRequestBody PaymentSource { get; set; }
|
||||
|
||||
public virtual Provider ToProvider(Provider provider)
|
||||
{
|
||||
|
@ -2,6 +2,7 @@
|
||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||
using Bit.Api.Billing.Models.Requests;
|
||||
using Bit.Api.Billing.Models.Responses;
|
||||
using Bit.Api.Billing.Queries.Organizations;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Models.Sales;
|
||||
@ -24,6 +25,7 @@ public class OrganizationBillingController(
|
||||
IFeatureService featureService,
|
||||
IOrganizationBillingService organizationBillingService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationWarningsQuery organizationWarningsQuery,
|
||||
IPaymentService paymentService,
|
||||
IPricingClient pricingClient,
|
||||
ISubscriberService subscriberService,
|
||||
@ -335,4 +337,28 @@ public class OrganizationBillingController(
|
||||
|
||||
return TypedResults.Ok(providerId);
|
||||
}
|
||||
|
||||
[HttpGet("warnings")]
|
||||
public async Task<IResult> GetWarningsAsync([FromRoute] Guid organizationId)
|
||||
{
|
||||
/*
|
||||
* We'll keep these available at the User level, because we're hiding any pertinent information and
|
||||
* we want to throw as few errors as possible since these are not core features.
|
||||
*/
|
||||
if (!await currentContext.OrganizationUser(organizationId))
|
||||
{
|
||||
return Error.Unauthorized();
|
||||
}
|
||||
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
return Error.NotFound();
|
||||
}
|
||||
|
||||
var response = await organizationWarningsQuery.Run(organization);
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Bit.Api.Models.Request.Organizations;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.Models.Response.Organizations;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
|
||||
@ -8,6 +9,7 @@ using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Api.Request.OrganizationSponsorships;
|
||||
using Bit.Core.Models.Api.Response.OrganizationSponsorships;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationSponsorships;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@ -105,7 +107,10 @@ public class OrganizationSponsorshipsController : Controller
|
||||
model.FriendlyName,
|
||||
model.IsAdminInitiated.GetValueOrDefault(),
|
||||
model.Notes);
|
||||
await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync(sponsorship, sponsoringOrg.Name);
|
||||
if (sponsorship.OfferedToEmail != null)
|
||||
{
|
||||
await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync(sponsorship, sponsoringOrg.Name);
|
||||
}
|
||||
}
|
||||
|
||||
[Authorize("Application")]
|
||||
@ -246,5 +251,27 @@ public class OrganizationSponsorshipsController : Controller
|
||||
return new OrganizationSponsorshipSyncStatusResponseModel(lastSyncDate);
|
||||
}
|
||||
|
||||
[Authorize("Application")]
|
||||
[HttpGet("{sponsoringOrgId}/sponsored")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<ListResponseModel<OrganizationSponsorshipInvitesResponseModel>> GetSponsoredOrganizations(Guid sponsoringOrgId)
|
||||
{
|
||||
var sponsoringOrg = await _organizationRepository.GetByIdAsync(sponsoringOrgId);
|
||||
if (sponsoringOrg == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
var organization = _currentContext.Organizations.First(x => x.Id == sponsoringOrg.Id);
|
||||
if (!await _currentContext.OrganizationOwner(sponsoringOrg.Id) && !await _currentContext.OrganizationAdmin(sponsoringOrg.Id) && !organization.Permissions.ManageUsers)
|
||||
{
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
var sponsorships = await _organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(sponsoringOrgId);
|
||||
return new ListResponseModel<OrganizationSponsorshipInvitesResponseModel>(sponsorships.Select(s =>
|
||||
new OrganizationSponsorshipInvitesResponseModel(new OrganizationSponsorshipData(s))));
|
||||
|
||||
}
|
||||
|
||||
private Task<User> CurrentUser => _userService.GetUserByIdAsync(_currentContext.UserId.Value);
|
||||
}
|
||||
|
@ -0,0 +1,43 @@
|
||||
#nullable enable
|
||||
namespace Bit.Api.Billing.Models.Responses.Organizations;
|
||||
|
||||
public record OrganizationWarningsResponse
|
||||
{
|
||||
public FreeTrialWarning? FreeTrial { get; set; }
|
||||
public InactiveSubscriptionWarning? InactiveSubscription { get; set; }
|
||||
public ResellerRenewalWarning? ResellerRenewal { get; set; }
|
||||
|
||||
public record FreeTrialWarning
|
||||
{
|
||||
public int RemainingTrialDays { get; set; }
|
||||
}
|
||||
|
||||
public record InactiveSubscriptionWarning
|
||||
{
|
||||
public required string Resolution { get; set; }
|
||||
}
|
||||
|
||||
public record ResellerRenewalWarning
|
||||
{
|
||||
public required string Type { get; set; }
|
||||
public UpcomingRenewal? Upcoming { get; set; }
|
||||
public IssuedRenewal? Issued { get; set; }
|
||||
public PastDueRenewal? PastDue { get; set; }
|
||||
|
||||
public record UpcomingRenewal
|
||||
{
|
||||
public required DateTime RenewalDate { get; set; }
|
||||
}
|
||||
|
||||
public record IssuedRenewal
|
||||
{
|
||||
public required DateTime IssuedDate { get; set; }
|
||||
public required DateTime DueDate { get; set; }
|
||||
}
|
||||
|
||||
public record PastDueRenewal
|
||||
{
|
||||
public required DateTime SuspensionDate { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,214 @@
|
||||
// ReSharper disable InconsistentNaming
|
||||
|
||||
#nullable enable
|
||||
|
||||
using Bit.Api.Billing.Models.Responses.Organizations;
|
||||
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.Constants;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Services;
|
||||
using Stripe;
|
||||
using FreeTrialWarning = Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.FreeTrialWarning;
|
||||
using InactiveSubscriptionWarning =
|
||||
Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.InactiveSubscriptionWarning;
|
||||
using ResellerRenewalWarning =
|
||||
Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.ResellerRenewalWarning;
|
||||
|
||||
namespace Bit.Api.Billing.Queries.Organizations;
|
||||
|
||||
public interface IOrganizationWarningsQuery
|
||||
{
|
||||
Task<OrganizationWarningsResponse> Run(
|
||||
Organization organization);
|
||||
}
|
||||
|
||||
public class OrganizationWarningsQuery(
|
||||
ICurrentContext currentContext,
|
||||
IProviderRepository providerRepository,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService) : IOrganizationWarningsQuery
|
||||
{
|
||||
public async Task<OrganizationWarningsResponse> Run(
|
||||
Organization organization)
|
||||
{
|
||||
var response = new OrganizationWarningsResponse();
|
||||
|
||||
var subscription =
|
||||
await subscriberService.GetSubscription(organization,
|
||||
new SubscriptionGetOptions { Expand = ["customer", "latest_invoice", "test_clock"] });
|
||||
|
||||
if (subscription == null)
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
response.FreeTrial = await GetFreeTrialWarning(organization, subscription);
|
||||
|
||||
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||
|
||||
response.InactiveSubscription = await GetInactiveSubscriptionWarning(organization, provider, subscription);
|
||||
|
||||
response.ResellerRenewal = await GetResellerRenewalWarning(provider, subscription);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async Task<FreeTrialWarning?> GetFreeTrialWarning(
|
||||
Organization organization,
|
||||
Subscription subscription)
|
||||
{
|
||||
if (!await currentContext.EditSubscription(organization.Id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (subscription is not
|
||||
{
|
||||
Status: StripeConstants.SubscriptionStatus.Trialing,
|
||||
TrialEnd: not null,
|
||||
Customer: not null
|
||||
})
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var customer = subscription.Customer;
|
||||
|
||||
var hasPaymentMethod =
|
||||
!string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) ||
|
||||
!string.IsNullOrEmpty(customer.DefaultSourceId) ||
|
||||
customer.Metadata.ContainsKey(StripeConstants.MetadataKeys.BraintreeCustomerId);
|
||||
|
||||
if (hasPaymentMethod)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
|
||||
|
||||
var remainingTrialDays = (int)Math.Ceiling((subscription.TrialEnd.Value - now).TotalDays);
|
||||
|
||||
return new FreeTrialWarning { RemainingTrialDays = remainingTrialDays };
|
||||
}
|
||||
|
||||
private async Task<InactiveSubscriptionWarning?> GetInactiveSubscriptionWarning(
|
||||
Organization organization,
|
||||
Provider? provider,
|
||||
Subscription subscription)
|
||||
{
|
||||
if (organization.Enabled ||
|
||||
subscription.Status is not StripeConstants.SubscriptionStatus.Unpaid
|
||||
and not StripeConstants.SubscriptionStatus.Canceled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (provider != null)
|
||||
{
|
||||
return new InactiveSubscriptionWarning { Resolution = "contact_provider" };
|
||||
}
|
||||
|
||||
if (await currentContext.OrganizationOwner(organization.Id))
|
||||
{
|
||||
return subscription.Status switch
|
||||
{
|
||||
StripeConstants.SubscriptionStatus.Unpaid => new InactiveSubscriptionWarning
|
||||
{
|
||||
Resolution = "add_payment_method"
|
||||
},
|
||||
StripeConstants.SubscriptionStatus.Canceled => new InactiveSubscriptionWarning
|
||||
{
|
||||
Resolution = "resubscribe"
|
||||
},
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
return new InactiveSubscriptionWarning { Resolution = "contact_owner" };
|
||||
}
|
||||
|
||||
private async Task<ResellerRenewalWarning?> GetResellerRenewalWarning(
|
||||
Provider? provider,
|
||||
Subscription subscription)
|
||||
{
|
||||
if (provider is not
|
||||
{
|
||||
Type: ProviderType.Reseller
|
||||
})
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (subscription.CollectionMethod != StripeConstants.CollectionMethod.SendInvoice)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
|
||||
|
||||
// ReSharper disable once ConvertIfStatementToSwitchStatement
|
||||
if (subscription is
|
||||
{
|
||||
Status: StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.Active,
|
||||
LatestInvoice: null or { Status: StripeConstants.InvoiceStatus.Paid }
|
||||
} && (subscription.CurrentPeriodEnd - now).TotalDays <= 14)
|
||||
{
|
||||
return new ResellerRenewalWarning
|
||||
{
|
||||
Type = "upcoming",
|
||||
Upcoming = new ResellerRenewalWarning.UpcomingRenewal
|
||||
{
|
||||
RenewalDate = subscription.CurrentPeriodEnd
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (subscription is
|
||||
{
|
||||
Status: StripeConstants.SubscriptionStatus.Active,
|
||||
LatestInvoice: { Status: StripeConstants.InvoiceStatus.Open, DueDate: not null }
|
||||
} && subscription.LatestInvoice.DueDate > now)
|
||||
{
|
||||
return new ResellerRenewalWarning
|
||||
{
|
||||
Type = "issued",
|
||||
Issued = new ResellerRenewalWarning.IssuedRenewal
|
||||
{
|
||||
IssuedDate = subscription.LatestInvoice.Created,
|
||||
DueDate = subscription.LatestInvoice.DueDate.Value
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ReSharper disable once InvertIf
|
||||
if (subscription.Status == StripeConstants.SubscriptionStatus.PastDue)
|
||||
{
|
||||
var openInvoices = await stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions
|
||||
{
|
||||
Query = $"subscription:'{subscription.Id}' status:'open'"
|
||||
});
|
||||
|
||||
var earliestOverdueInvoice = openInvoices
|
||||
.Where(invoice => invoice.DueDate != null && invoice.DueDate < now)
|
||||
.MinBy(invoice => invoice.Created);
|
||||
|
||||
if (earliestOverdueInvoice != null)
|
||||
{
|
||||
return new ResellerRenewalWarning
|
||||
{
|
||||
Type = "past_due",
|
||||
PastDue = new ResellerRenewalWarning.PastDueRenewal
|
||||
{
|
||||
SuspensionDate = earliestOverdueInvoice.DueDate!.Value.AddDays(30)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
11
src/Api/Billing/Registrations.cs
Normal file
11
src/Api/Billing/Registrations.cs
Normal file
@ -0,0 +1,11 @@
|
||||
using Bit.Api.Billing.Queries.Organizations;
|
||||
|
||||
namespace Bit.Api.Billing;
|
||||
|
||||
public static class Registrations
|
||||
{
|
||||
public static void AddBillingQueries(this IServiceCollection services)
|
||||
{
|
||||
services.AddTransient<IOrganizationWarningsQuery, OrganizationWarningsQuery>();
|
||||
}
|
||||
}
|
@ -27,6 +27,7 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Api.Auth.Models.Request.WebAuthn;
|
||||
using Bit.Api.Billing;
|
||||
using Bit.Core.AdminConsole.Services.NoopImplementations;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Identity.TokenProviders;
|
||||
@ -184,6 +185,8 @@ public class Startup
|
||||
services.AddImportServices();
|
||||
services.AddPhishingDomainServices(globalSettings);
|
||||
|
||||
services.AddBillingQueries();
|
||||
|
||||
// Authorization Handlers
|
||||
services.AddAuthorizationHandlers();
|
||||
|
||||
|
@ -0,0 +1,37 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Models.Data.Integrations;
|
||||
|
||||
public class IntegrationTemplateContext(EventMessage eventMessage)
|
||||
{
|
||||
public EventMessage Event { get; } = eventMessage;
|
||||
|
||||
public string DomainName => Event.DomainName;
|
||||
public string IpAddress => Event.IpAddress;
|
||||
public DeviceType? DeviceType => Event.DeviceType;
|
||||
public Guid? ActingUserId => Event.ActingUserId;
|
||||
public Guid? OrganizationUserId => Event.OrganizationUserId;
|
||||
public DateTime Date => Event.Date;
|
||||
public EventType Type => Event.Type;
|
||||
public Guid? UserId => Event.UserId;
|
||||
public Guid? OrganizationId => Event.OrganizationId;
|
||||
public Guid? CipherId => Event.CipherId;
|
||||
public Guid? CollectionId => Event.CollectionId;
|
||||
public Guid? GroupId => Event.GroupId;
|
||||
public Guid? PolicyId => Event.PolicyId;
|
||||
|
||||
public User? User { get; set; }
|
||||
public string? UserName => User?.Name;
|
||||
public string? UserEmail => User?.Email;
|
||||
|
||||
public User? ActingUser { get; set; }
|
||||
public string? ActingUserName => ActingUser?.Name;
|
||||
public string? ActingUserEmail => ActingUser?.Email;
|
||||
|
||||
public Organization? Organization { get; set; }
|
||||
public string? OrganizationName => Organization?.DisplayName();
|
||||
}
|
@ -0,0 +1,128 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tokens;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
|
||||
|
||||
public class InitPendingOrganizationCommand : IInitPendingOrganizationCommand
|
||||
{
|
||||
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||
private readonly IDataProtector _dataProtector;
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
private readonly IPolicyService _policyService;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
|
||||
public InitPendingOrganizationCommand(
|
||||
IOrganizationService organizationService,
|
||||
ICollectionRepository collectionRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
IGlobalSettings globalSettings,
|
||||
IPolicyService policyService,
|
||||
IOrganizationUserRepository organizationUserRepository
|
||||
)
|
||||
{
|
||||
_organizationService = organizationService;
|
||||
_collectionRepository = collectionRepository;
|
||||
_organizationRepository = organizationRepository;
|
||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||
_dataProtector = dataProtectionProvider.CreateProtector(OrgUserInviteTokenable.DataProtectorPurpose);
|
||||
_globalSettings = globalSettings;
|
||||
_policyService = policyService;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
}
|
||||
|
||||
public async Task InitPendingOrganizationAsync(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken)
|
||||
{
|
||||
await ValidateSignUpPoliciesAsync(user.Id);
|
||||
|
||||
var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId);
|
||||
if (orgUser == null)
|
||||
{
|
||||
throw new BadRequestException("User invalid.");
|
||||
}
|
||||
|
||||
var tokenValid = ValidateInviteToken(orgUser, user, emailToken);
|
||||
|
||||
if (!tokenValid)
|
||||
{
|
||||
throw new BadRequestException("Invalid token");
|
||||
}
|
||||
|
||||
var org = await _organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (org.Enabled)
|
||||
{
|
||||
throw new BadRequestException("Organization is already enabled.");
|
||||
}
|
||||
|
||||
if (org.Status != OrganizationStatusType.Pending)
|
||||
{
|
||||
throw new BadRequestException("Organization is not on a Pending status.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(org.PublicKey))
|
||||
{
|
||||
throw new BadRequestException("Organization already has a Public Key.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(org.PrivateKey))
|
||||
{
|
||||
throw new BadRequestException("Organization already has a Private Key.");
|
||||
}
|
||||
|
||||
org.Enabled = true;
|
||||
org.Status = OrganizationStatusType.Created;
|
||||
org.PublicKey = publicKey;
|
||||
org.PrivateKey = privateKey;
|
||||
|
||||
await _organizationService.UpdateAsync(org);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(collectionName))
|
||||
{
|
||||
// give the owner Can Manage access over the default collection
|
||||
List<CollectionAccessSelection> defaultOwnerAccess =
|
||||
[new CollectionAccessSelection { Id = orgUser.Id, HidePasswords = false, ReadOnly = false, Manage = true }];
|
||||
|
||||
var defaultCollection = new Collection
|
||||
{
|
||||
Name = collectionName,
|
||||
OrganizationId = org.Id
|
||||
};
|
||||
await _collectionRepository.CreateAsync(defaultCollection, null, defaultOwnerAccess);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ValidateSignUpPoliciesAsync(Guid ownerId)
|
||||
{
|
||||
var anySingleOrgPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(ownerId, PolicyType.SingleOrg);
|
||||
if (anySingleOrgPolicies)
|
||||
{
|
||||
throw new BadRequestException("You may not create an organization. You belong to an organization " +
|
||||
"which has a policy that prohibits you from being a member of any other organization.");
|
||||
}
|
||||
}
|
||||
|
||||
private bool ValidateInviteToken(OrganizationUser orgUser, User user, string emailToken)
|
||||
{
|
||||
var tokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken(
|
||||
_orgUserInviteTokenDataFactory, emailToken, orgUser);
|
||||
|
||||
return tokenValid;
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
using Bit.Core.Entities;
|
||||
namespace Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
|
||||
public interface IInitPendingOrganizationCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Update an Organization entry by setting the public/private keys, set it as 'Enabled' and move the Status from 'Pending' to 'Created'.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method must target a disabled Organization that has null keys and status as 'Pending'.
|
||||
/// </remarks>
|
||||
Task InitPendingOrganizationAsync(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken);
|
||||
}
|
@ -48,14 +48,8 @@ public interface IOrganizationService
|
||||
Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
|
||||
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId);
|
||||
Task CreatePendingOrganization(Organization organization, string ownerEmail, ClaimsPrincipal user, IUserService userService, bool salesAssistedTrialStarted);
|
||||
/// <summary>
|
||||
/// Update an Organization entry by setting the public/private keys, set it as 'Enabled' and move the Status from 'Pending' to 'Created'.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method must target a disabled Organization that has null keys and status as 'Pending'.
|
||||
/// </remarks>
|
||||
Task InitPendingOrganization(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken);
|
||||
Task ReplaceAndUpdateCacheAsync(Organization org, EventType? orgEvent = null);
|
||||
Task<(bool canScale, string failureReason)> CanScaleAsync(Organization organization, int seatsToAdd);
|
||||
|
||||
void ValidatePasswordManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade);
|
||||
void ValidateSecretsManagerPlan(Models.StaticStore.Plan plan, OrganizationUpgrade upgrade);
|
||||
|
@ -1,5 +1,6 @@
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business.Provider;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Business;
|
||||
|
||||
@ -7,7 +8,8 @@ namespace Bit.Core.AdminConsole.Services;
|
||||
|
||||
public interface IProviderService
|
||||
{
|
||||
Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo = null);
|
||||
Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo,
|
||||
TokenizedPaymentSource tokenizedPaymentSource = null);
|
||||
Task UpdateAsync(Provider provider, bool updateBilling = false);
|
||||
|
||||
Task<List<ProviderUser>> InviteUserAsync(ProviderUserInvite<string> invite);
|
||||
|
@ -0,0 +1,34 @@
|
||||
using Bit.Core.Models.Data;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class EventRouteService(
|
||||
[FromKeyedServices("broadcast")] IEventWriteService broadcastEventWriteService,
|
||||
[FromKeyedServices("storage")] IEventWriteService storageEventWriteService,
|
||||
IFeatureService _featureService) : IEventWriteService
|
||||
{
|
||||
public async Task CreateAsync(IEvent e)
|
||||
{
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations))
|
||||
{
|
||||
await broadcastEventWriteService.CreateAsync(e);
|
||||
}
|
||||
else
|
||||
{
|
||||
await storageEventWriteService.CreateAsync(e);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CreateManyAsync(IEnumerable<IEvent> e)
|
||||
{
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations))
|
||||
{
|
||||
await broadcastEventWriteService.CreateManyAsync(e);
|
||||
}
|
||||
else
|
||||
{
|
||||
await storageEventWriteService.CreateManyAsync(e);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,66 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Bit.Core.AdminConsole.Utilities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Models.Data.Integrations;
|
||||
using Bit.Core.Repositories;
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public abstract class IntegrationEventHandlerBase(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationIntegrationConfigurationRepository configurationRepository)
|
||||
: IEventMessageHandler
|
||||
{
|
||||
public async Task HandleEventAsync(EventMessage eventMessage)
|
||||
{
|
||||
var organizationId = eventMessage.OrganizationId ?? Guid.Empty;
|
||||
var configurations = await configurationRepository.GetConfigurationDetailsAsync(
|
||||
organizationId,
|
||||
GetIntegrationType(),
|
||||
eventMessage.Type);
|
||||
|
||||
foreach (var configuration in configurations)
|
||||
{
|
||||
var context = await BuildContextAsync(eventMessage, configuration.Template);
|
||||
var renderedTemplate = IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, context);
|
||||
|
||||
await ProcessEventIntegrationAsync(configuration.MergedConfiguration, renderedTemplate);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task HandleManyEventsAsync(IEnumerable<EventMessage> eventMessages)
|
||||
{
|
||||
foreach (var eventMessage in eventMessages)
|
||||
{
|
||||
await HandleEventAsync(eventMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IntegrationTemplateContext> BuildContextAsync(EventMessage eventMessage, string template)
|
||||
{
|
||||
var context = new IntegrationTemplateContext(eventMessage);
|
||||
|
||||
if (IntegrationTemplateProcessor.TemplateRequiresUser(template) && eventMessage.UserId.HasValue)
|
||||
{
|
||||
context.User = await userRepository.GetByIdAsync(eventMessage.UserId.Value);
|
||||
}
|
||||
|
||||
if (IntegrationTemplateProcessor.TemplateRequiresActingUser(template) && eventMessage.ActingUserId.HasValue)
|
||||
{
|
||||
context.ActingUser = await userRepository.GetByIdAsync(eventMessage.ActingUserId.Value);
|
||||
}
|
||||
|
||||
if (IntegrationTemplateProcessor.TemplateRequiresOrganization(template) && eventMessage.OrganizationId.HasValue)
|
||||
{
|
||||
context.Organization = await organizationRepository.GetByIdAsync(eventMessage.OrganizationId.Value);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
protected abstract IntegrationType GetIntegrationType();
|
||||
|
||||
protected abstract Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, string renderedTemplate);
|
||||
}
|
@ -13,7 +13,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Billing.Constants;
|
||||
@ -31,12 +30,10 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Tokens;
|
||||
using Bit.Core.Tools.Enums;
|
||||
using Bit.Core.Tools.Models.Business;
|
||||
using Bit.Core.Tools.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Stripe;
|
||||
using OrganizationUserInvite = Bit.Core.Models.Business.OrganizationUserInvite;
|
||||
@ -77,8 +74,6 @@ public class OrganizationService : IOrganizationService
|
||||
private readonly IPricingClient _pricingClient;
|
||||
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
||||
private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand;
|
||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||
private readonly IDataProtector _dataProtector;
|
||||
|
||||
public OrganizationService(
|
||||
IOrganizationRepository organizationRepository,
|
||||
@ -112,9 +107,7 @@ public class OrganizationService : IOrganizationService
|
||||
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
|
||||
IPricingClient pricingClient,
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand,
|
||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||
IDataProtectionProvider dataProtectionProvider
|
||||
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand
|
||||
)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
@ -149,8 +142,6 @@ public class OrganizationService : IOrganizationService
|
||||
_pricingClient = pricingClient;
|
||||
_policyRequirementQuery = policyRequirementQuery;
|
||||
_sendOrganizationInvitesCommand = sendOrganizationInvitesCommand;
|
||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||
_dataProtector = dataProtectionProvider.CreateProtector(OrgUserInviteTokenable.DataProtectorPurpose);
|
||||
}
|
||||
|
||||
public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken,
|
||||
@ -1067,7 +1058,7 @@ public class OrganizationService : IOrganizationService
|
||||
organization: organization,
|
||||
initOrganization: initOrganization));
|
||||
|
||||
internal async Task<(bool canScale, string failureReason)> CanScaleAsync(
|
||||
public async Task<(bool canScale, string failureReason)> CanScaleAsync(
|
||||
Organization organization,
|
||||
int seatsToAdd)
|
||||
{
|
||||
@ -1921,71 +1912,4 @@ public class OrganizationService : IOrganizationService
|
||||
SalesAssistedTrialStarted = salesAssistedTrialStarted,
|
||||
});
|
||||
}
|
||||
|
||||
public async Task InitPendingOrganization(User user, Guid organizationId, Guid organizationUserId, string publicKey, string privateKey, string collectionName, string emailToken)
|
||||
{
|
||||
await ValidateSignUpPoliciesAsync(user.Id);
|
||||
|
||||
var orgUser = await _organizationUserRepository.GetByIdAsync(organizationUserId);
|
||||
if (orgUser == null)
|
||||
{
|
||||
throw new BadRequestException("User invalid.");
|
||||
}
|
||||
|
||||
// TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete
|
||||
var newTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken(
|
||||
_orgUserInviteTokenDataFactory, emailToken, orgUser);
|
||||
|
||||
var tokenValid = newTokenValid ||
|
||||
CoreHelpers.UserInviteTokenIsValid(_dataProtector, emailToken, user.Email, orgUser.Id,
|
||||
_globalSettings);
|
||||
|
||||
if (!tokenValid)
|
||||
{
|
||||
throw new BadRequestException("Invalid token.");
|
||||
}
|
||||
|
||||
var org = await GetOrgById(organizationId);
|
||||
|
||||
if (org.Enabled)
|
||||
{
|
||||
throw new BadRequestException("Organization is already enabled.");
|
||||
}
|
||||
|
||||
if (org.Status != OrganizationStatusType.Pending)
|
||||
{
|
||||
throw new BadRequestException("Organization is not on a Pending status.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(org.PublicKey))
|
||||
{
|
||||
throw new BadRequestException("Organization already has a Public Key.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(org.PrivateKey))
|
||||
{
|
||||
throw new BadRequestException("Organization already has a Private Key.");
|
||||
}
|
||||
|
||||
org.Enabled = true;
|
||||
org.Status = OrganizationStatusType.Created;
|
||||
org.PublicKey = publicKey;
|
||||
org.PrivateKey = privateKey;
|
||||
|
||||
await UpdateAsync(org);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(collectionName))
|
||||
{
|
||||
// give the owner Can Manage access over the default collection
|
||||
List<CollectionAccessSelection> defaultOwnerAccess =
|
||||
[new CollectionAccessSelection { Id = organizationUserId, HidePasswords = false, ReadOnly = false, Manage = true }];
|
||||
|
||||
var defaultCollection = new Collection
|
||||
{
|
||||
Name = collectionName,
|
||||
OrganizationId = org.Id
|
||||
};
|
||||
await _collectionRepository.CreateAsync(defaultCollection, null, defaultOwnerAccess);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,46 +1,35 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Utilities;
|
||||
using System.Text.Json.Nodes;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Models.Data.Integrations;
|
||||
using Bit.Core.Repositories;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Services;
|
||||
|
||||
public class SlackEventHandler(
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationIntegrationConfigurationRepository configurationRepository,
|
||||
ISlackService slackService)
|
||||
: IEventMessageHandler
|
||||
: IntegrationEventHandlerBase(userRepository, organizationRepository, configurationRepository)
|
||||
{
|
||||
public async Task HandleEventAsync(EventMessage eventMessage)
|
||||
protected override IntegrationType GetIntegrationType() => IntegrationType.Slack;
|
||||
|
||||
protected override async Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration,
|
||||
string renderedTemplate)
|
||||
{
|
||||
var organizationId = eventMessage.OrganizationId ?? Guid.Empty;
|
||||
var configurations = await configurationRepository.GetConfigurationDetailsAsync(
|
||||
organizationId,
|
||||
IntegrationType.Slack,
|
||||
eventMessage.Type);
|
||||
|
||||
foreach (var configuration in configurations)
|
||||
var config = mergedConfiguration.Deserialize<SlackIntegrationConfigurationDetails>();
|
||||
if (config is null)
|
||||
{
|
||||
var config = configuration.MergedConfiguration.Deserialize<SlackIntegrationConfigurationDetails>();
|
||||
if (config is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await slackService.SendSlackMessageByChannelIdAsync(
|
||||
config.token,
|
||||
IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, eventMessage),
|
||||
config.channelId
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task HandleManyEventsAsync(IEnumerable<EventMessage> eventMessages)
|
||||
{
|
||||
foreach (var eventMessage in eventMessages)
|
||||
{
|
||||
await HandleEventAsync(eventMessage);
|
||||
}
|
||||
await slackService.SendSlackMessageByChannelIdAsync(
|
||||
config.token,
|
||||
renderedTemplate,
|
||||
config.channelId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,7 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Bit.Core.AdminConsole.Utilities;
|
||||
using System.Text.Json.Nodes;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Models.Data.Integrations;
|
||||
using Bit.Core.Repositories;
|
||||
|
||||
@ -12,46 +11,28 @@ namespace Bit.Core.Services;
|
||||
|
||||
public class WebhookEventHandler(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationIntegrationConfigurationRepository configurationRepository)
|
||||
: IEventMessageHandler
|
||||
: IntegrationEventHandlerBase(userRepository, organizationRepository, configurationRepository)
|
||||
{
|
||||
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
|
||||
|
||||
public const string HttpClientName = "WebhookEventHandlerHttpClient";
|
||||
|
||||
public async Task HandleEventAsync(EventMessage eventMessage)
|
||||
protected override IntegrationType GetIntegrationType() => IntegrationType.Webhook;
|
||||
|
||||
protected override async Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration,
|
||||
string renderedTemplate)
|
||||
{
|
||||
var organizationId = eventMessage.OrganizationId ?? Guid.Empty;
|
||||
var configurations = await configurationRepository.GetConfigurationDetailsAsync(
|
||||
organizationId,
|
||||
IntegrationType.Webhook,
|
||||
eventMessage.Type);
|
||||
|
||||
foreach (var configuration in configurations)
|
||||
var config = mergedConfiguration.Deserialize<WebhookIntegrationConfigurationDetils>();
|
||||
if (config is null || string.IsNullOrEmpty(config.url))
|
||||
{
|
||||
var config = configuration.MergedConfiguration.Deserialize<WebhookIntegrationConfigurationDetils>();
|
||||
if (config is null || string.IsNullOrEmpty(config.url))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var content = new StringContent(
|
||||
IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, eventMessage),
|
||||
Encoding.UTF8,
|
||||
"application/json"
|
||||
);
|
||||
var response = await _httpClient.PostAsync(
|
||||
config.url,
|
||||
content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task HandleManyEventsAsync(IEnumerable<EventMessage> eventMessages)
|
||||
{
|
||||
foreach (var eventMessage in eventMessages)
|
||||
{
|
||||
await HandleEventAsync(eventMessage);
|
||||
}
|
||||
var content = new StringContent(renderedTemplate, Encoding.UTF8, "application/json");
|
||||
var response = await _httpClient.PostAsync(config.url, content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Models.Business.Provider;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Business;
|
||||
|
||||
@ -7,7 +8,7 @@ namespace Bit.Core.AdminConsole.Services.NoopImplementations;
|
||||
|
||||
public class NoopProviderService : IProviderService
|
||||
{
|
||||
public Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo = null) => throw new NotImplementedException();
|
||||
public Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null) => throw new NotImplementedException();
|
||||
|
||||
public Task UpdateAsync(Provider provider, bool updateBilling = false) => throw new NotImplementedException();
|
||||
|
||||
|
@ -10,8 +10,9 @@ public static partial class IntegrationTemplateProcessor
|
||||
public static string ReplaceTokens(string template, object values)
|
||||
{
|
||||
if (string.IsNullOrEmpty(template) || values == null)
|
||||
{
|
||||
return template;
|
||||
|
||||
}
|
||||
var type = values.GetType();
|
||||
return TokenRegex().Replace(template, match =>
|
||||
{
|
||||
@ -20,4 +21,36 @@ public static partial class IntegrationTemplateProcessor
|
||||
return property?.GetValue(values)?.ToString() ?? match.Value;
|
||||
});
|
||||
}
|
||||
|
||||
public static bool TemplateRequiresUser(string template)
|
||||
{
|
||||
if (string.IsNullOrEmpty(template))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return template.Contains("#UserName#", StringComparison.Ordinal)
|
||||
|| template.Contains("#UserEmail#", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public static bool TemplateRequiresActingUser(string template)
|
||||
{
|
||||
if (string.IsNullOrEmpty(template))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return template.Contains("#ActingUserName#", StringComparison.Ordinal)
|
||||
|| template.Contains("#ActingUserEmail#", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public static bool TemplateRequiresOrganization(string template)
|
||||
{
|
||||
if (string.IsNullOrEmpty(template))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return template.Contains("#OrganizationName#", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,10 @@
|
||||
|
||||
public static class StripeConstants
|
||||
{
|
||||
public static class Prices
|
||||
{
|
||||
public const string StoragePlanPersonal = "personal-storage-gb-annually";
|
||||
}
|
||||
public static class AutomaticTaxStatus
|
||||
{
|
||||
public const string Failed = "failed";
|
||||
@ -42,10 +46,12 @@ public static class StripeConstants
|
||||
{
|
||||
public const string Draft = "draft";
|
||||
public const string Open = "open";
|
||||
public const string Paid = "paid";
|
||||
}
|
||||
|
||||
public static class MetadataKeys
|
||||
{
|
||||
public const string BraintreeCustomerId = "btCustomerId";
|
||||
public const string InvoiceApproved = "invoice_approved";
|
||||
public const string OrganizationId = "organizationId";
|
||||
public const string ProviderId = "providerId";
|
||||
|
@ -34,6 +34,7 @@ public static class OrganizationLicenseConstants
|
||||
public const string UseSecretsManager = nameof(UseSecretsManager);
|
||||
public const string SmSeats = nameof(SmSeats);
|
||||
public const string SmServiceAccounts = nameof(SmServiceAccounts);
|
||||
public const string SmMaxProjects = nameof(SmMaxProjects);
|
||||
public const string LimitCollectionCreationDeletion = nameof(LimitCollectionCreationDeletion);
|
||||
public const string AllowAdminAccessToAllCollectionItems = nameof(AllowAdminAccessToAllCollectionItems);
|
||||
public const string UseRiskInsights = nameof(UseRiskInsights);
|
||||
|
@ -7,4 +7,5 @@ public class LicenseContext
|
||||
{
|
||||
public Guid? InstallationId { get; init; }
|
||||
public required SubscriptionInfo SubscriptionInfo { get; init; }
|
||||
public int? SmMaxProjects { get; set; }
|
||||
}
|
||||
|
@ -112,6 +112,11 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory<Organizati
|
||||
}
|
||||
claims.Add(new Claim(nameof(OrganizationLicenseConstants.UseAdminSponsoredFamilies), entity.UseAdminSponsoredFamilies.ToString()));
|
||||
|
||||
if (licenseContext.SmMaxProjects.HasValue)
|
||||
{
|
||||
claims.Add(new Claim(nameof(OrganizationLicenseConstants.SmMaxProjects), licenseContext.SmMaxProjects.ToString()));
|
||||
}
|
||||
|
||||
return Task.FromResult(claims);
|
||||
}
|
||||
|
||||
|
@ -38,7 +38,7 @@ public record Families2019Plan : Plan
|
||||
HasPremiumAccessOption = true;
|
||||
|
||||
StripePlanId = "personal-org-annually";
|
||||
StripeStoragePlanId = "storage-gb-annually";
|
||||
StripeStoragePlanId = "personal-storage-gb-annually";
|
||||
StripePremiumAccessPlanId = "personal-org-premium-access-annually";
|
||||
BasePrice = 12;
|
||||
AdditionalStoragePricePerGb = 4;
|
||||
|
@ -37,7 +37,7 @@ public record FamiliesPlan : Plan
|
||||
HasAdditionalStorageOption = true;
|
||||
|
||||
StripePlanId = "2020-families-org-annually";
|
||||
StripeStoragePlanId = "storage-gb-annually";
|
||||
StripeStoragePlanId = "personal-storage-gb-annually";
|
||||
BasePrice = 40;
|
||||
AdditionalStoragePricePerGb = 4;
|
||||
|
||||
|
@ -79,10 +79,12 @@ public interface IProviderBillingService
|
||||
/// </summary>
|
||||
/// <param name="provider">The <see cref="Provider"/> to create a Stripe customer for.</param>
|
||||
/// <param name="taxInfo">The <see cref="TaxInfo"/> to use for calculating the customer's automatic tax.</param>
|
||||
/// <param name="tokenizedPaymentSource">The <see cref="TokenizedPaymentSource"/> (ex. Credit Card) to attach to the customer.</param>
|
||||
/// <returns>The newly created <see cref="Stripe.Customer"/> for the <paramref name="provider"/>.</returns>
|
||||
Task<Customer> SetupCustomer(
|
||||
Provider provider,
|
||||
TaxInfo taxInfo);
|
||||
TaxInfo taxInfo,
|
||||
TokenizedPaymentSource tokenizedPaymentSource = null);
|
||||
|
||||
/// <summary>
|
||||
/// For use during the provider setup process, this method starts a Stripe <see cref="Stripe.Subscription"/> for the given <paramref name="provider"/>.
|
||||
|
@ -313,7 +313,7 @@ public class PremiumUserBillingService(
|
||||
{
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Price = "storage-gb-annually",
|
||||
Price = StripeConstants.Prices.StoragePlanPersonal,
|
||||
Quantity = storage
|
||||
});
|
||||
}
|
||||
|
@ -149,6 +149,8 @@ public static class FeatureFlagKeys
|
||||
public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates";
|
||||
public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion";
|
||||
public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically";
|
||||
public const string PM19956_RequireProviderPaymentMethodDuringSetup = "pm-19956-require-provider-payment-method-during-setup";
|
||||
public const string UseOrganizationWarningsService = "use-organization-warnings-service";
|
||||
|
||||
/* Data Insights and Reporting Team */
|
||||
public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application";
|
||||
|
@ -0,0 +1,37 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationSponsorships;
|
||||
|
||||
namespace Bit.Core.Models.Api.Response.OrganizationSponsorships;
|
||||
|
||||
public class OrganizationSponsorshipInvitesResponseModel : ResponseModel
|
||||
{
|
||||
public OrganizationSponsorshipInvitesResponseModel(OrganizationSponsorshipData sponsorshipData, string obj = "organizationSponsorship") : base(obj)
|
||||
{
|
||||
if (sponsorshipData == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(sponsorshipData));
|
||||
}
|
||||
|
||||
SponsoringOrganizationUserId = sponsorshipData.SponsoringOrganizationUserId;
|
||||
FriendlyName = sponsorshipData.FriendlyName;
|
||||
OfferedToEmail = sponsorshipData.OfferedToEmail;
|
||||
PlanSponsorshipType = sponsorshipData.PlanSponsorshipType;
|
||||
LastSyncDate = sponsorshipData.LastSyncDate;
|
||||
ValidUntil = sponsorshipData.ValidUntil;
|
||||
ToDelete = sponsorshipData.ToDelete;
|
||||
IsAdminInitiated = sponsorshipData.IsAdminInitiated;
|
||||
Notes = sponsorshipData.Notes;
|
||||
CloudSponsorshipRemoved = sponsorshipData.CloudSponsorshipRemoved;
|
||||
}
|
||||
|
||||
public Guid SponsoringOrganizationUserId { get; set; }
|
||||
public string FriendlyName { get; set; }
|
||||
public string OfferedToEmail { get; set; }
|
||||
public PlanSponsorshipType PlanSponsorshipType { get; set; }
|
||||
public DateTime? LastSyncDate { get; set; }
|
||||
public DateTime? ValidUntil { get; set; }
|
||||
public bool ToDelete { get; set; }
|
||||
public bool IsAdminInitiated { get; set; }
|
||||
public string Notes { get; set; }
|
||||
public bool CloudSponsorshipRemoved { get; set; }
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
@ -16,19 +17,22 @@ public class CloudGetOrganizationLicenseQuery : ICloudGetOrganizationLicenseQuer
|
||||
private readonly ILicensingService _licensingService;
|
||||
private readonly IProviderRepository _providerRepository;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IPricingClient _pricingClient;
|
||||
|
||||
public CloudGetOrganizationLicenseQuery(
|
||||
IInstallationRepository installationRepository,
|
||||
IPaymentService paymentService,
|
||||
ILicensingService licensingService,
|
||||
IProviderRepository providerRepository,
|
||||
IFeatureService featureService)
|
||||
IFeatureService featureService,
|
||||
IPricingClient pricingClient)
|
||||
{
|
||||
_installationRepository = installationRepository;
|
||||
_paymentService = paymentService;
|
||||
_licensingService = licensingService;
|
||||
_providerRepository = providerRepository;
|
||||
_featureService = featureService;
|
||||
_pricingClient = pricingClient;
|
||||
}
|
||||
|
||||
public async Task<OrganizationLicense> GetLicenseAsync(Organization organization, Guid installationId,
|
||||
@ -42,7 +46,11 @@ public class CloudGetOrganizationLicenseQuery : ICloudGetOrganizationLicenseQuer
|
||||
|
||||
var subscriptionInfo = await GetSubscriptionAsync(organization);
|
||||
var license = new OrganizationLicense(organization, subscriptionInfo, installationId, _licensingService, version);
|
||||
license.Token = await _licensingService.CreateOrganizationTokenAsync(organization, installationId, subscriptionInfo);
|
||||
var plan = await _pricingClient.GetPlan(organization.PlanType);
|
||||
int? smMaxProjects = plan?.SupportsSecretsManager ?? false
|
||||
? plan.SecretsManager.MaxProjects
|
||||
: null;
|
||||
license.Token = await _licensingService.CreateOrganizationTokenAsync(organization, installationId, subscriptionInfo, smMaxProjects);
|
||||
|
||||
return license;
|
||||
}
|
||||
|
@ -193,6 +193,7 @@ public static class OrganizationServiceCollectionExtensions
|
||||
services.AddScoped<IInviteUsersOrganizationValidator, InviteUsersOrganizationValidator>();
|
||||
services.AddScoped<IInviteUsersPasswordManagerValidator, InviteUsersPasswordManagerValidator>();
|
||||
services.AddScoped<IInviteUsersEnvironmentValidator, InviteUsersEnvironmentValidator>();
|
||||
services.AddScoped<IInitPendingOrganizationCommand, InitPendingOrganizationCommand>();
|
||||
}
|
||||
|
||||
// TODO: move to OrganizationSubscriptionServiceCollectionExtensions when OrganizationUser methods are moved out of
|
||||
|
@ -15,7 +15,8 @@ public class CreateSponsorshipCommand(
|
||||
ICurrentContext currentContext,
|
||||
IOrganizationSponsorshipRepository organizationSponsorshipRepository,
|
||||
IUserService userService,
|
||||
IOrganizationService organizationService) : ICreateSponsorshipCommand
|
||||
IOrganizationService organizationService,
|
||||
IOrganizationUserRepository organizationUserRepository) : ICreateSponsorshipCommand
|
||||
{
|
||||
public async Task<OrganizationSponsorship> CreateSponsorshipAsync(
|
||||
Organization sponsoringOrganization,
|
||||
@ -47,11 +48,12 @@ public class CreateSponsorshipCommand(
|
||||
throw new BadRequestException("Only confirmed users can sponsor other organizations.");
|
||||
}
|
||||
|
||||
var existingOrgSponsorship = await organizationSponsorshipRepository
|
||||
.GetBySponsoringOrganizationUserIdAsync(sponsoringMember.Id);
|
||||
if (existingOrgSponsorship?.SponsoredOrganizationId != null)
|
||||
var sponsorships =
|
||||
await organizationSponsorshipRepository.GetManyBySponsoringOrganizationAsync(sponsoringOrganization.Id);
|
||||
var existingSponsorship = sponsorships.FirstOrDefault(s => s.FriendlyName == friendlyName);
|
||||
if (existingSponsorship != null)
|
||||
{
|
||||
throw new BadRequestException("Can only sponsor one organization per Organization User.");
|
||||
return existingSponsorship;
|
||||
}
|
||||
|
||||
if (isAdminInitiated)
|
||||
@ -70,15 +72,37 @@ public class CreateSponsorshipCommand(
|
||||
Notes = notes
|
||||
};
|
||||
|
||||
if (existingOrgSponsorship != null)
|
||||
if (!isAdminInitiated)
|
||||
{
|
||||
// Replace existing invalid offer with our new sponsorship offer
|
||||
sponsorship.Id = existingOrgSponsorship.Id;
|
||||
var existingOrgSponsorship = await organizationSponsorshipRepository
|
||||
.GetBySponsoringOrganizationUserIdAsync(sponsoringMember.Id);
|
||||
if (existingOrgSponsorship?.SponsoredOrganizationId != null)
|
||||
{
|
||||
throw new BadRequestException("Can only sponsor one organization per Organization User.");
|
||||
}
|
||||
|
||||
if (existingOrgSponsorship != null)
|
||||
{
|
||||
sponsorship.Id = existingOrgSponsorship.Id;
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdminInitiated && sponsoringOrganization.Seats.HasValue)
|
||||
{
|
||||
await organizationService.AutoAddSeatsAsync(sponsoringOrganization, 1);
|
||||
var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrganization.Id);
|
||||
var availableSeats = sponsoringOrganization.Seats.Value - occupiedSeats;
|
||||
|
||||
if (availableSeats <= 0)
|
||||
{
|
||||
var newSeatsRequired = 1;
|
||||
var (canScale, failureReason) = await organizationService.CanScaleAsync(sponsoringOrganization, newSeatsRequired);
|
||||
if (!canScale)
|
||||
{
|
||||
throw new BadRequestException(failureReason);
|
||||
}
|
||||
|
||||
await organizationService.AutoAddSeatsAsync(sponsoringOrganization, newSeatsRequired);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
|
@ -39,7 +39,7 @@ public class AzurePhishingDomainStorageService
|
||||
var content = await streamReader.ReadToEndAsync();
|
||||
|
||||
return [.. content
|
||||
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
|
||||
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line => line.Trim())
|
||||
.Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith('#'))];
|
||||
}
|
||||
|
@ -92,7 +92,7 @@ public class CloudPhishingDomainDirectQuery : ICloudPhishingDomainQuery
|
||||
}
|
||||
|
||||
return content
|
||||
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
|
||||
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line => line.Trim())
|
||||
.Where(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith("#"))
|
||||
.ToList();
|
||||
|
@ -21,7 +21,8 @@ public interface ILicensingService
|
||||
Task<string?> CreateOrganizationTokenAsync(
|
||||
Organization organization,
|
||||
Guid installationId,
|
||||
SubscriptionInfo subscriptionInfo);
|
||||
SubscriptionInfo subscriptionInfo,
|
||||
int? smMaxProjects);
|
||||
|
||||
Task<string?> CreateUserTokenAsync(User user, SubscriptionInfo subscriptionInfo);
|
||||
}
|
||||
|
@ -339,12 +339,13 @@ public class LicensingService : ILicensingService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> CreateOrganizationTokenAsync(Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo)
|
||||
public async Task<string> CreateOrganizationTokenAsync(Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo, int? smMaxProjects)
|
||||
{
|
||||
var licenseContext = new LicenseContext
|
||||
{
|
||||
InstallationId = installationId,
|
||||
SubscriptionInfo = subscriptionInfo,
|
||||
SmMaxProjects = smMaxProjects
|
||||
};
|
||||
|
||||
var claims = await _organizationLicenseClaimsFactory.GenerateClaims(organization, licenseContext);
|
||||
|
@ -11,6 +11,7 @@ using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Models.Sales;
|
||||
using Bit.Core.Billing.Services;
|
||||
@ -45,7 +46,6 @@ namespace Bit.Core.Services;
|
||||
public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
{
|
||||
private const string PremiumPlanId = "premium-annually";
|
||||
private const string StoragePlanId = "storage-gb-annually";
|
||||
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly ICipherRepository _cipherRepository;
|
||||
@ -1106,12 +1106,12 @@ public class UserService : UserManager<User>, IUserService, IDisposable
|
||||
}
|
||||
|
||||
var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, user, storageAdjustmentGb,
|
||||
StoragePlanId);
|
||||
StripeConstants.Prices.StoragePlanPersonal);
|
||||
await _referenceEventService.RaiseEventAsync(
|
||||
new ReferenceEvent(ReferenceEventType.AdjustStorage, user, _currentContext)
|
||||
{
|
||||
Storage = storageAdjustmentGb,
|
||||
PlanName = StoragePlanId,
|
||||
PlanName = StripeConstants.Prices.StoragePlanPersonal,
|
||||
});
|
||||
await SaveUserAsync(user);
|
||||
return secret;
|
||||
|
@ -62,7 +62,7 @@ public class NoopLicensingService : ILicensingService
|
||||
return null;
|
||||
}
|
||||
|
||||
public Task<string?> CreateOrganizationTokenAsync(Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo)
|
||||
public Task<string?> CreateOrganizationTokenAsync(Organization organization, Guid installationId, SubscriptionInfo subscriptionInfo, int? smMaxProjects)
|
||||
{
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
using System.Globalization;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Services.Implementations;
|
||||
using Bit.Core.AdminConsole.Services.NoopImplementations;
|
||||
using Bit.Core.Context;
|
||||
@ -94,13 +93,7 @@ public class Startup
|
||||
services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
|
||||
}
|
||||
}
|
||||
services.AddScoped<IEventWriteService>(sp =>
|
||||
{
|
||||
var featureService = sp.GetRequiredService<IFeatureService>();
|
||||
var key = featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations)
|
||||
? "broadcast" : "storage";
|
||||
return sp.GetRequiredKeyedService<IEventWriteService>(key);
|
||||
});
|
||||
services.AddScoped<IEventWriteService, EventRouteService>();
|
||||
services.AddScoped<IEventService, EventService>();
|
||||
|
||||
services.AddOptionality();
|
||||
|
@ -103,6 +103,7 @@ public static class EntityFrameworkServiceCollectionExtensions
|
||||
services.AddSingleton<IPasswordHealthReportApplicationRepository, PasswordHealthReportApplicationRepository>();
|
||||
services.AddSingleton<ISecurityTaskRepository, SecurityTaskRepository>();
|
||||
services.AddSingleton<IUserAsymmetricKeysRepository, UserAsymmetricKeysRepository>();
|
||||
services.AddSingleton<IOrganizationInstallationRepository, OrganizationInstallationRepository>();
|
||||
|
||||
if (selfHosted)
|
||||
{
|
||||
|
@ -4,7 +4,6 @@ using System.Security.Claims;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using AspNetCoreRateLimit;
|
||||
using Azure.Storage.Queues;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
@ -366,13 +365,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("storage");
|
||||
services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
|
||||
}
|
||||
services.AddScoped<IEventWriteService>(sp =>
|
||||
{
|
||||
var featureService = sp.GetRequiredService<IFeatureService>();
|
||||
var key = featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations)
|
||||
? "broadcast" : "storage";
|
||||
return sp.GetRequiredKeyedService<IEventWriteService>(key);
|
||||
});
|
||||
services.AddScoped<IEventWriteService, EventRouteService>();
|
||||
|
||||
if (CoreHelpers.SettingHasValue(globalSettings.Attachment.ConnectionString))
|
||||
{
|
||||
|
@ -1,5 +1,7 @@
|
||||
using Bit.Api.AdminConsole.Authorization;
|
||||
using AutoFixture.Xunit2;
|
||||
using Bit.Api.AdminConsole.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
@ -25,4 +27,42 @@ public class HttpContextExtensionsTests
|
||||
await callback.ReceivedWithAnyArgs(1).Invoke();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineAutoData("orgId")]
|
||||
[InlineAutoData("organizationId")]
|
||||
public void GetOrganizationId_GivenValidParameter_ReturnsOrganizationId(string paramName, Guid orgId)
|
||||
{
|
||||
var httpContext = new DefaultHttpContext
|
||||
{
|
||||
Request = { RouteValues = new RouteValueDictionary
|
||||
{
|
||||
{ "userId", "someGuid" },
|
||||
{ paramName, orgId.ToString() }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = httpContext.GetOrganizationId();
|
||||
Assert.Equal(orgId, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineAutoData("orgId")]
|
||||
[InlineAutoData("organizationId")]
|
||||
[InlineAutoData("missingParameter")]
|
||||
public void GetOrganizationId_GivenMissingOrInvalidGuid_Throws(string paramName)
|
||||
{
|
||||
var httpContext = new DefaultHttpContext
|
||||
{
|
||||
Request = { RouteValues = new RouteValueDictionary
|
||||
{
|
||||
{ "userId", "someGuid" },
|
||||
{ paramName, "invalidGuid" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => httpContext.GetOrganizationId());
|
||||
Assert.Equal(HttpContextExtensions.NoOrgIdError, exception.Message);
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@ -13,6 +14,7 @@ using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Api.Test.Billing.Controllers;
|
||||
@ -146,4 +148,80 @@ public class OrganizationSponsorshipsControllerTests
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.RemoveSponsorshipAsync(default);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSponsoredOrganizations_OrganizationNotFound_ThrowsNotFound(
|
||||
Guid sponsoringOrgId,
|
||||
SutProvider<OrganizationSponsorshipsController> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(sponsoringOrgId).ReturnsNull();
|
||||
|
||||
await Assert.ThrowsAsync<NotFoundException>(() =>
|
||||
sutProvider.Sut.GetSponsoredOrganizations(sponsoringOrgId));
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.GetManyBySponsoringOrganizationAsync(default);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSponsoredOrganizations_NotOrganizationOwner_ThrowsNotFound(
|
||||
Organization sponsoringOrg,
|
||||
SutProvider<OrganizationSponsorshipsController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(sponsoringOrg.Id).Returns(sponsoringOrg);
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(sponsoringOrg.Id).Returns(false);
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(sponsoringOrg.Id).Returns(false);
|
||||
|
||||
// Create a CurrentContextOrganization with ManageUsers set to false
|
||||
var currentContextOrg = new CurrentContextOrganization
|
||||
{
|
||||
Id = sponsoringOrg.Id,
|
||||
Permissions = new Permissions { ManageUsers = false }
|
||||
};
|
||||
sutProvider.GetDependency<ICurrentContext>().Organizations.Returns(new List<CurrentContextOrganization> { currentContextOrg });
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
|
||||
sutProvider.Sut.GetSponsoredOrganizations(sponsoringOrg.Id));
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.GetManyBySponsoringOrganizationAsync(default);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSponsoredOrganizations_Success_ReturnsSponsorships(
|
||||
Organization sponsoringOrg,
|
||||
List<OrganizationSponsorship> sponsorships,
|
||||
SutProvider<OrganizationSponsorshipsController> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(sponsoringOrg.Id).Returns(sponsoringOrg);
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(sponsoringOrg.Id).Returns(true);
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationAdmin(sponsoringOrg.Id).Returns(false);
|
||||
|
||||
// Create a CurrentContextOrganization from the sponsoringOrg
|
||||
var currentContextOrg = new CurrentContextOrganization
|
||||
{
|
||||
Id = sponsoringOrg.Id,
|
||||
Permissions = new Permissions { ManageUsers = true }
|
||||
};
|
||||
sutProvider.GetDependency<ICurrentContext>().Organizations.Returns(new List<CurrentContextOrganization> { currentContextOrg });
|
||||
|
||||
sutProvider.GetDependency<IOrganizationSponsorshipRepository>()
|
||||
.GetManyBySponsoringOrganizationAsync(sponsoringOrg.Id).Returns(sponsorships);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetSponsoredOrganizations(sponsoringOrg.Id);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(sponsorships.Count, result.Data.Count());
|
||||
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().Received(1)
|
||||
.GetManyBySponsoringOrganizationAsync(sponsoringOrg.Id);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,315 @@
|
||||
using Bit.Api.Billing.Queries.Organizations;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
using Stripe;
|
||||
using Stripe.TestHelpers;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Api.Test.Billing.Queries.Organizations;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class OrganizationWarningsQueryTests
|
||||
{
|
||||
private static readonly string[] _requiredExpansions = ["customer", "latest_invoice", "test_clock"];
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_NoSubscription_NoWarnings(
|
||||
Organization organization,
|
||||
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||
options.Expand.SequenceEqual(_requiredExpansions)
|
||||
))
|
||||
.ReturnsNull();
|
||||
|
||||
var response = await sutProvider.Sut.Run(organization);
|
||||
|
||||
Assert.True(response is
|
||||
{
|
||||
FreeTrial: null,
|
||||
InactiveSubscription: null,
|
||||
ResellerRenewal: null
|
||||
});
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_Has_FreeTrialWarning(
|
||||
Organization organization,
|
||||
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||
options.Expand.SequenceEqual(_requiredExpansions)
|
||||
))
|
||||
.Returns(new Subscription
|
||||
{
|
||||
Status = StripeConstants.SubscriptionStatus.Trialing,
|
||||
TrialEnd = now.AddDays(7),
|
||||
Customer = new Customer
|
||||
{
|
||||
InvoiceSettings = new CustomerInvoiceSettings(),
|
||||
Metadata = new Dictionary<string, string>()
|
||||
},
|
||||
TestClock = new TestClock
|
||||
{
|
||||
FrozenTime = now
|
||||
}
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().EditSubscription(organization.Id).Returns(true);
|
||||
|
||||
var response = await sutProvider.Sut.Run(organization);
|
||||
|
||||
Assert.True(response is
|
||||
{
|
||||
FreeTrial.RemainingTrialDays: 7
|
||||
});
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_Has_InactiveSubscriptionWarning_ContactProvider(
|
||||
Organization organization,
|
||||
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||
{
|
||||
organization.Enabled = false;
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||
options.Expand.SequenceEqual(_requiredExpansions)
|
||||
))
|
||||
.Returns(new Subscription
|
||||
{
|
||||
Status = StripeConstants.SubscriptionStatus.Unpaid
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new Provider());
|
||||
|
||||
var response = await sutProvider.Sut.Run(organization);
|
||||
|
||||
Assert.True(response is
|
||||
{
|
||||
InactiveSubscription.Resolution: "contact_provider"
|
||||
});
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_Has_InactiveSubscriptionWarning_AddPaymentMethod(
|
||||
Organization organization,
|
||||
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||
{
|
||||
organization.Enabled = false;
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||
options.Expand.SequenceEqual(_requiredExpansions)
|
||||
))
|
||||
.Returns(new Subscription
|
||||
{
|
||||
Status = StripeConstants.SubscriptionStatus.Unpaid
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(true);
|
||||
|
||||
var response = await sutProvider.Sut.Run(organization);
|
||||
|
||||
Assert.True(response is
|
||||
{
|
||||
InactiveSubscription.Resolution: "add_payment_method"
|
||||
});
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_Has_InactiveSubscriptionWarning_Resubscribe(
|
||||
Organization organization,
|
||||
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||
{
|
||||
organization.Enabled = false;
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||
options.Expand.SequenceEqual(_requiredExpansions)
|
||||
))
|
||||
.Returns(new Subscription
|
||||
{
|
||||
Status = StripeConstants.SubscriptionStatus.Canceled
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(true);
|
||||
|
||||
var response = await sutProvider.Sut.Run(organization);
|
||||
|
||||
Assert.True(response is
|
||||
{
|
||||
InactiveSubscription.Resolution: "resubscribe"
|
||||
});
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_Has_InactiveSubscriptionWarning_ContactOwner(
|
||||
Organization organization,
|
||||
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||
{
|
||||
organization.Enabled = false;
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||
options.Expand.SequenceEqual(_requiredExpansions)
|
||||
))
|
||||
.Returns(new Subscription
|
||||
{
|
||||
Status = StripeConstants.SubscriptionStatus.Unpaid
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(false);
|
||||
|
||||
var response = await sutProvider.Sut.Run(organization);
|
||||
|
||||
Assert.True(response is
|
||||
{
|
||||
InactiveSubscription.Resolution: "contact_owner"
|
||||
});
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_Has_ResellerRenewalWarning_Upcoming(
|
||||
Organization organization,
|
||||
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||
options.Expand.SequenceEqual(_requiredExpansions)
|
||||
))
|
||||
.Returns(new Subscription
|
||||
{
|
||||
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
||||
Status = StripeConstants.SubscriptionStatus.Active,
|
||||
CurrentPeriodEnd = now.AddDays(10),
|
||||
TestClock = new TestClock
|
||||
{
|
||||
FrozenTime = now
|
||||
}
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new Provider
|
||||
{
|
||||
Type = ProviderType.Reseller
|
||||
});
|
||||
|
||||
var response = await sutProvider.Sut.Run(organization);
|
||||
|
||||
Assert.True(response is
|
||||
{
|
||||
ResellerRenewal.Type: "upcoming"
|
||||
});
|
||||
|
||||
Assert.Equal(now.AddDays(10), response.ResellerRenewal.Upcoming!.RenewalDate);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_Has_ResellerRenewalWarning_Issued(
|
||||
Organization organization,
|
||||
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||
options.Expand.SequenceEqual(_requiredExpansions)
|
||||
))
|
||||
.Returns(new Subscription
|
||||
{
|
||||
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
||||
Status = StripeConstants.SubscriptionStatus.Active,
|
||||
LatestInvoice = new Invoice
|
||||
{
|
||||
Status = StripeConstants.InvoiceStatus.Open,
|
||||
DueDate = now.AddDays(30),
|
||||
Created = now
|
||||
},
|
||||
TestClock = new TestClock
|
||||
{
|
||||
FrozenTime = now
|
||||
}
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new Provider
|
||||
{
|
||||
Type = ProviderType.Reseller
|
||||
});
|
||||
|
||||
var response = await sutProvider.Sut.Run(organization);
|
||||
|
||||
Assert.True(response is
|
||||
{
|
||||
ResellerRenewal.Type: "issued"
|
||||
});
|
||||
|
||||
Assert.Equal(now, response.ResellerRenewal.Issued!.IssuedDate);
|
||||
Assert.Equal(now.AddDays(30), response.ResellerRenewal.Issued!.DueDate);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_Has_ResellerRenewalWarning_PastDue(
|
||||
Organization organization,
|
||||
SutProvider<OrganizationWarningsQuery> sutProvider)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
const string subscriptionId = "subscription_id";
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||
options.Expand.SequenceEqual(_requiredExpansions)
|
||||
))
|
||||
.Returns(new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
||||
Status = StripeConstants.SubscriptionStatus.PastDue,
|
||||
TestClock = new TestClock
|
||||
{
|
||||
FrozenTime = now
|
||||
}
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new Provider
|
||||
{
|
||||
Type = ProviderType.Reseller
|
||||
});
|
||||
|
||||
var dueDate = now.AddDays(-10);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>().InvoiceSearchAsync(Arg.Is<InvoiceSearchOptions>(options =>
|
||||
options.Query == $"subscription:'{subscriptionId}' status:'open'")).Returns([
|
||||
new Invoice { DueDate = dueDate, Created = dueDate.AddDays(-30) }
|
||||
]);
|
||||
|
||||
var response = await sutProvider.Sut.Run(organization);
|
||||
|
||||
Assert.True(response is
|
||||
{
|
||||
ResellerRenewal.Type: "past_due"
|
||||
});
|
||||
|
||||
Assert.Equal(dueDate.AddDays(30), response.ResellerRenewal.PastDue!.SuspensionDate);
|
||||
}
|
||||
}
|
@ -0,0 +1,169 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
|
||||
using Bit.Core.Auth.Models.Business.Tokenables;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Tokens;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Bit.Test.Common.Fakes;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class InitPendingOrganizationCommandTests
|
||||
{
|
||||
|
||||
private readonly IOrgUserInviteTokenableFactory _orgUserInviteTokenableFactory = Substitute.For<IOrgUserInviteTokenableFactory>();
|
||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory = new FakeDataProtectorTokenFactory<OrgUserInviteTokenable>();
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Init_Organization_Success(User user, Guid orgId, Guid orgUserId, string publicKey,
|
||||
string privateKey, SutProvider<InitPendingOrganizationCommand> sutProvider, Organization org, OrganizationUser orgUser)
|
||||
{
|
||||
var token = CreateToken(orgUser, orgUserId, sutProvider);
|
||||
|
||||
org.PrivateKey = null;
|
||||
org.PublicKey = null;
|
||||
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
organizationRepository.GetByIdAsync(orgId).Returns(org);
|
||||
|
||||
var organizationServcie = sutProvider.GetDependency<IOrganizationService>();
|
||||
var collectionRepository = sutProvider.GetDependency<ICollectionRepository>();
|
||||
|
||||
await sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token);
|
||||
|
||||
await organizationRepository.Received().GetByIdAsync(orgId);
|
||||
await organizationServcie.Received().UpdateAsync(org);
|
||||
await collectionRepository.DidNotReceiveWithAnyArgs().CreateAsync(default);
|
||||
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Init_Organization_With_CollectionName_Success(User user, Guid orgId, Guid orgUserId, string publicKey,
|
||||
string privateKey, SutProvider<InitPendingOrganizationCommand> sutProvider, Organization org, string collectionName, OrganizationUser orgUser)
|
||||
{
|
||||
var token = CreateToken(orgUser, orgUserId, sutProvider);
|
||||
|
||||
org.PrivateKey = null;
|
||||
org.PublicKey = null;
|
||||
org.Id = orgId;
|
||||
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
organizationRepository.GetByIdAsync(orgId).Returns(org);
|
||||
|
||||
var organizationServcie = sutProvider.GetDependency<IOrganizationService>();
|
||||
var collectionRepository = sutProvider.GetDependency<ICollectionRepository>();
|
||||
|
||||
await sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, collectionName, token);
|
||||
|
||||
await organizationRepository.Received().GetByIdAsync(orgId);
|
||||
await organizationServcie.Received().UpdateAsync(org);
|
||||
|
||||
await collectionRepository.Received().CreateAsync(
|
||||
Arg.Any<Collection>(),
|
||||
Arg.Is<List<CollectionAccessSelection>>(l => l == null),
|
||||
Arg.Is<List<CollectionAccessSelection>>(l => l.Any(i => i.Manage == true)));
|
||||
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Init_Organization_When_Organization_Is_Enabled(User user, Guid orgId, Guid orgUserId, string publicKey,
|
||||
string privateKey, SutProvider<InitPendingOrganizationCommand> sutProvider, Organization org, OrganizationUser orgUser)
|
||||
|
||||
{
|
||||
var token = CreateToken(orgUser, orgUserId, sutProvider);
|
||||
|
||||
org.Enabled = true;
|
||||
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
organizationRepository.GetByIdAsync(orgId).Returns(org);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token));
|
||||
|
||||
Assert.Equal("Organization is already enabled.", exception.Message);
|
||||
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Init_Organization_When_Organization_Is_Not_Pending(User user, Guid orgId, Guid orgUserId, string publicKey,
|
||||
string privateKey, SutProvider<InitPendingOrganizationCommand> sutProvider, Organization org, OrganizationUser orgUser)
|
||||
|
||||
{
|
||||
|
||||
|
||||
var token = CreateToken(orgUser, orgUserId, sutProvider);
|
||||
|
||||
org.Status = Enums.OrganizationStatusType.Created;
|
||||
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
organizationRepository.GetByIdAsync(orgId).Returns(org);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token));
|
||||
|
||||
Assert.Equal("Organization is not on a Pending status.", exception.Message);
|
||||
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Init_Organization_When_Organization_Has_Public_Key(User user, Guid orgId, Guid orgUserId, string publicKey,
|
||||
string privateKey, SutProvider<InitPendingOrganizationCommand> sutProvider, Organization org, OrganizationUser orgUser)
|
||||
|
||||
{
|
||||
var token = CreateToken(orgUser, orgUserId, sutProvider);
|
||||
|
||||
org.PublicKey = publicKey;
|
||||
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
organizationRepository.GetByIdAsync(orgId).Returns(org);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token));
|
||||
|
||||
Assert.Equal("Organization already has a Public Key.", exception.Message);
|
||||
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Init_Organization_When_Organization_Has_Private_Key(User user, Guid orgId, Guid orgUserId, string publicKey,
|
||||
string privateKey, SutProvider<InitPendingOrganizationCommand> sutProvider, Organization org, OrganizationUser orgUser)
|
||||
|
||||
{
|
||||
|
||||
var token = CreateToken(orgUser, orgUserId, sutProvider);
|
||||
|
||||
org.PublicKey = null;
|
||||
org.PrivateKey = privateKey;
|
||||
org.Enabled = false;
|
||||
|
||||
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
|
||||
organizationRepository.GetByIdAsync(orgId).Returns(org);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.InitPendingOrganizationAsync(user, orgId, orgUserId, publicKey, privateKey, "", token));
|
||||
|
||||
Assert.Equal("Organization already has a Private Key.", exception.Message);
|
||||
|
||||
}
|
||||
|
||||
public string CreateToken(OrganizationUser orgUser, Guid orgUserId, SutProvider<InitPendingOrganizationCommand> sutProvider)
|
||||
{
|
||||
sutProvider.SetDependency(_orgUserInviteTokenDataFactory, "orgUserInviteTokenDataFactory");
|
||||
sutProvider.Create();
|
||||
|
||||
_orgUserInviteTokenableFactory.CreateToken(orgUser).Returns(new OrgUserInviteTokenable(orgUser)
|
||||
{
|
||||
ExpirationDate = DateTime.UtcNow.Add(TimeSpan.FromDays(5))
|
||||
});
|
||||
|
||||
var orgUserInviteTokenable = _orgUserInviteTokenableFactory.CreateToken(orgUser);
|
||||
var protectedToken = _orgUserInviteTokenDataFactory.Protect(orgUserInviteTokenable);
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns(orgUser);
|
||||
|
||||
return protectedToken;
|
||||
}
|
||||
}
|
@ -0,0 +1,65 @@
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Services;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class EventRouteServiceTests
|
||||
{
|
||||
private readonly IEventWriteService _broadcastEventWriteService = Substitute.For<IEventWriteService>();
|
||||
private readonly IEventWriteService _storageEventWriteService = Substitute.For<IEventWriteService>();
|
||||
private readonly IFeatureService _featureService = Substitute.For<IFeatureService>();
|
||||
private readonly EventRouteService Subject;
|
||||
|
||||
public EventRouteServiceTests()
|
||||
{
|
||||
Subject = new EventRouteService(_broadcastEventWriteService, _storageEventWriteService, _featureService);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateAsync_FlagDisabled_EventSentToStorageService(EventMessage eventMessage)
|
||||
{
|
||||
_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(false);
|
||||
|
||||
await Subject.CreateAsync(eventMessage);
|
||||
|
||||
_broadcastEventWriteService.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any<EventMessage>());
|
||||
_storageEventWriteService.Received(1).CreateAsync(eventMessage);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateAsync_FlagEnabled_EventSentToBroadcastService(EventMessage eventMessage)
|
||||
{
|
||||
_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(true);
|
||||
|
||||
await Subject.CreateAsync(eventMessage);
|
||||
|
||||
_broadcastEventWriteService.Received(1).CreateAsync(eventMessage);
|
||||
_storageEventWriteService.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any<EventMessage>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateManyAsync_FlagDisabled_EventsSentToStorageService(IEnumerable<EventMessage> eventMessages)
|
||||
{
|
||||
_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(false);
|
||||
|
||||
await Subject.CreateManyAsync(eventMessages);
|
||||
|
||||
_broadcastEventWriteService.DidNotReceiveWithAnyArgs().CreateManyAsync(Arg.Any<IEnumerable<EventMessage>>());
|
||||
_storageEventWriteService.Received(1).CreateManyAsync(eventMessages);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateManyAsync_FlagEnabled_EventsSentToBroadcastService(IEnumerable<EventMessage> eventMessages)
|
||||
{
|
||||
_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(true);
|
||||
|
||||
await Subject.CreateManyAsync(eventMessages);
|
||||
|
||||
_broadcastEventWriteService.Received(1).CreateManyAsync(eventMessages);
|
||||
_storageEventWriteService.DidNotReceiveWithAnyArgs().CreateManyAsync(Arg.Any<IEnumerable<EventMessage>>());
|
||||
}
|
||||
}
|
@ -0,0 +1,219 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Services;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class IntegrationEventHandlerBaseEventHandlerTests
|
||||
{
|
||||
private const string _templateBase = "Date: #Date#, Type: #Type#, UserId: #UserId#";
|
||||
private const string _templateWithOrganization = "Org: #OrganizationName#";
|
||||
private const string _templateWithUser = "#UserName#, #UserEmail#";
|
||||
private const string _templateWithActingUser = "#ActingUserName#, #ActingUserEmail#";
|
||||
private const string _url = "https://localhost";
|
||||
|
||||
private SutProvider<TestIntegrationEventHandlerBase> GetSutProvider(
|
||||
List<OrganizationIntegrationConfigurationDetails> configurations)
|
||||
{
|
||||
var configurationRepository = Substitute.For<IOrganizationIntegrationConfigurationRepository>();
|
||||
configurationRepository.GetConfigurationDetailsAsync(Arg.Any<Guid>(),
|
||||
IntegrationType.Webhook, Arg.Any<EventType>()).Returns(configurations);
|
||||
|
||||
return new SutProvider<TestIntegrationEventHandlerBase>()
|
||||
.SetDependency(configurationRepository)
|
||||
.Create();
|
||||
}
|
||||
|
||||
private static List<OrganizationIntegrationConfigurationDetails> NoConfigurations()
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
private static List<OrganizationIntegrationConfigurationDetails> OneConfiguration(string template)
|
||||
{
|
||||
var config = Substitute.For<OrganizationIntegrationConfigurationDetails>();
|
||||
config.Configuration = null;
|
||||
config.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _url });
|
||||
config.Template = template;
|
||||
|
||||
return [config];
|
||||
}
|
||||
|
||||
private static List<OrganizationIntegrationConfigurationDetails> TwoConfigurations(string template)
|
||||
{
|
||||
var config = Substitute.For<OrganizationIntegrationConfigurationDetails>();
|
||||
config.Configuration = null;
|
||||
config.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _url });
|
||||
config.Template = template;
|
||||
var config2 = Substitute.For<OrganizationIntegrationConfigurationDetails>();
|
||||
config2.Configuration = null;
|
||||
config2.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _url });
|
||||
config2.Template = template;
|
||||
|
||||
return [config, config2];
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleEventAsync_BaseTemplateNoConfigurations_DoesNothing(EventMessage eventMessage)
|
||||
{
|
||||
var sutProvider = GetSutProvider(NoConfigurations());
|
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage);
|
||||
Assert.Empty(sutProvider.Sut.CapturedCalls);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleEventAsync_BaseTemplateOneConfiguration_CallsProcessEventIntegrationAsync(EventMessage eventMessage)
|
||||
{
|
||||
var sutProvider = GetSutProvider(OneConfiguration(_templateBase));
|
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage);
|
||||
|
||||
Assert.Single(sutProvider.Sut.CapturedCalls);
|
||||
|
||||
var expectedTemplate = $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}";
|
||||
|
||||
Assert.Equal(expectedTemplate, sutProvider.Sut.CapturedCalls.Single().RenderedTemplate);
|
||||
await sutProvider.GetDependency<IOrganizationRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>());
|
||||
await sutProvider.GetDependency<IUserRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleEventAsync_ActingUserTemplate_LoadsUserFromRepository(EventMessage eventMessage)
|
||||
{
|
||||
var sutProvider = GetSutProvider(OneConfiguration(_templateWithActingUser));
|
||||
var user = Substitute.For<User>();
|
||||
user.Email = "test@example.com";
|
||||
user.Name = "Test";
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(Arg.Any<Guid>()).Returns(user);
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage);
|
||||
|
||||
Assert.Single(sutProvider.Sut.CapturedCalls);
|
||||
|
||||
var expectedTemplate = $"{user.Name}, {user.Email}";
|
||||
Assert.Equal(expectedTemplate, sutProvider.Sut.CapturedCalls.Single().RenderedTemplate);
|
||||
await sutProvider.GetDependency<IOrganizationRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>());
|
||||
await sutProvider.GetDependency<IUserRepository>().Received(1).GetByIdAsync(eventMessage.ActingUserId ?? Guid.Empty);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleEventAsync_OrganizationTemplate_LoadsOrganizationFromRepository(EventMessage eventMessage)
|
||||
{
|
||||
var sutProvider = GetSutProvider(OneConfiguration(_templateWithOrganization));
|
||||
var organization = Substitute.For<Organization>();
|
||||
organization.Name = "Test";
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(Arg.Any<Guid>()).Returns(organization);
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage);
|
||||
|
||||
Assert.Single(sutProvider.Sut.CapturedCalls);
|
||||
|
||||
var expectedTemplate = $"Org: {organization.Name}";
|
||||
Assert.Equal(expectedTemplate, sutProvider.Sut.CapturedCalls.Single().RenderedTemplate);
|
||||
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).GetByIdAsync(eventMessage.OrganizationId ?? Guid.Empty);
|
||||
await sutProvider.GetDependency<IUserRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleEventAsync_UserTemplate_LoadsUserFromRepository(EventMessage eventMessage)
|
||||
{
|
||||
var sutProvider = GetSutProvider(OneConfiguration(_templateWithUser));
|
||||
var user = Substitute.For<User>();
|
||||
user.Email = "test@example.com";
|
||||
user.Name = "Test";
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(Arg.Any<Guid>()).Returns(user);
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage);
|
||||
|
||||
Assert.Single(sutProvider.Sut.CapturedCalls);
|
||||
|
||||
var expectedTemplate = $"{user.Name}, {user.Email}";
|
||||
Assert.Equal(expectedTemplate, sutProvider.Sut.CapturedCalls.Single().RenderedTemplate);
|
||||
await sutProvider.GetDependency<IOrganizationRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>());
|
||||
await sutProvider.GetDependency<IUserRepository>().Received(1).GetByIdAsync(eventMessage.UserId ?? Guid.Empty);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleManyEventsAsync_BaseTemplateNoConfigurations_DoesNothing(List<EventMessage> eventMessages)
|
||||
{
|
||||
var sutProvider = GetSutProvider(NoConfigurations());
|
||||
|
||||
await sutProvider.Sut.HandleManyEventsAsync(eventMessages);
|
||||
Assert.Empty(sutProvider.Sut.CapturedCalls);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleManyEventsAsync_BaseTemplateOneConfiguration_CallsProcessEventIntegrationAsync(List<EventMessage> eventMessages)
|
||||
{
|
||||
var sutProvider = GetSutProvider(OneConfiguration(_templateBase));
|
||||
|
||||
await sutProvider.Sut.HandleManyEventsAsync(eventMessages);
|
||||
|
||||
Assert.Equal(eventMessages.Count, sutProvider.Sut.CapturedCalls.Count);
|
||||
var index = 0;
|
||||
foreach (var call in sutProvider.Sut.CapturedCalls)
|
||||
{
|
||||
var expected = eventMessages[index];
|
||||
var expectedTemplate = $"Date: {expected.Date}, Type: {expected.Type}, UserId: {expected.UserId}";
|
||||
|
||||
Assert.Equal(expectedTemplate, call.RenderedTemplate);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleManyEventsAsync_BaseTemplateTwoConfigurations_CallsProcessEventIntegrationAsyncMultipleTimes(
|
||||
List<EventMessage> eventMessages)
|
||||
{
|
||||
var sutProvider = GetSutProvider(TwoConfigurations(_templateBase));
|
||||
|
||||
await sutProvider.Sut.HandleManyEventsAsync(eventMessages);
|
||||
|
||||
Assert.Equal(eventMessages.Count * 2, sutProvider.Sut.CapturedCalls.Count);
|
||||
|
||||
var capturedCalls = sutProvider.Sut.CapturedCalls.GetEnumerator();
|
||||
foreach (var eventMessage in eventMessages)
|
||||
{
|
||||
var expectedTemplate = $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}";
|
||||
|
||||
Assert.True(capturedCalls.MoveNext());
|
||||
var call = capturedCalls.Current;
|
||||
Assert.Equal(expectedTemplate, call.RenderedTemplate);
|
||||
|
||||
Assert.True(capturedCalls.MoveNext());
|
||||
call = capturedCalls.Current;
|
||||
Assert.Equal(expectedTemplate, call.RenderedTemplate);
|
||||
}
|
||||
}
|
||||
|
||||
private class TestIntegrationEventHandlerBase : IntegrationEventHandlerBase
|
||||
{
|
||||
public TestIntegrationEventHandlerBase(IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationIntegrationConfigurationRepository configurationRepository)
|
||||
: base(userRepository, organizationRepository, configurationRepository)
|
||||
{ }
|
||||
|
||||
public List<(JsonObject MergedConfiguration, string RenderedTemplate)> CapturedCalls { get; } = new();
|
||||
|
||||
protected override IntegrationType GetIntegrationType() => IntegrationType.Webhook;
|
||||
|
||||
protected override Task ProcessEventIntegrationAsync(JsonObject mergedConfiguration, string renderedTemplate)
|
||||
{
|
||||
CapturedCalls.Add((mergedConfiguration, renderedTemplate));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
@ -89,4 +89,61 @@ public class IntegrationTemplateProcessorTests
|
||||
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("User name is #UserName#")]
|
||||
[InlineData("Email: #UserEmail#")]
|
||||
public void TemplateRequiresUser_ContainingKeys_ReturnsTrue(string template)
|
||||
{
|
||||
var result = IntegrationTemplateProcessor.TemplateRequiresUser(template);
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("#UserId#")] // This is on the base class, not fetched, so should be false
|
||||
[InlineData("No User Tokens")]
|
||||
[InlineData("")]
|
||||
public void TemplateRequiresUser_EmptyInputOrNoMatchingKeys_ReturnsFalse(string template)
|
||||
{
|
||||
var result = IntegrationTemplateProcessor.TemplateRequiresUser(template);
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Acting user is #ActingUserName#")]
|
||||
[InlineData("Acting user's email is #ActingUserEmail#")]
|
||||
public void TemplateRequiresActingUser_ContainingKeys_ReturnsTrue(string template)
|
||||
{
|
||||
var result = IntegrationTemplateProcessor.TemplateRequiresActingUser(template);
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("No ActiveUser tokens")]
|
||||
[InlineData("#ActiveUserId#")] // This is on the base class, not fetched, so should be false
|
||||
[InlineData("")]
|
||||
public void TemplateRequiresActingUser_EmptyInputOrNoMatchingKeys_ReturnsFalse(string template)
|
||||
{
|
||||
var result = IntegrationTemplateProcessor.TemplateRequiresActingUser(template);
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Organization: #OrganizationName#")]
|
||||
[InlineData("Welcome to #OrganizationName#")]
|
||||
public void TemplateRequiresOrganization_ContainingKeys_ReturnsTrue(string template)
|
||||
{
|
||||
var result = IntegrationTemplateProcessor.TemplateRequiresOrganization(template);
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("No organization tokens")]
|
||||
[InlineData("#OrganizationId#")] // This is on the base class, not fetched, so should be false
|
||||
[InlineData("")]
|
||||
public void TemplateRequiresOrganization_EmptyInputOrNoMatchingKeys_ReturnsFalse(string template)
|
||||
{
|
||||
var result = IntegrationTemplateProcessor.TemplateRequiresOrganization(template);
|
||||
Assert.False(result);
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
@ -8,6 +9,7 @@ using Bit.Core.OrganizationFeatures.OrganizationLicenses;
|
||||
using Bit.Core.Platform.Installations;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Test.AutoFixture;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
@ -76,8 +78,10 @@ public class CloudGetOrganizationLicenseQueryTests
|
||||
sutProvider.GetDependency<IInstallationRepository>().GetByIdAsync(installationId).Returns(installation);
|
||||
sutProvider.GetDependency<IPaymentService>().GetSubscriptionAsync(organization).Returns(subInfo);
|
||||
sutProvider.GetDependency<ILicensingService>().SignLicense(Arg.Any<ILicense>()).Returns(licenseSignature);
|
||||
var plan = StaticStore.GetPlan(organization.PlanType);
|
||||
sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType).Returns(plan);
|
||||
sutProvider.GetDependency<ILicensingService>()
|
||||
.CreateOrganizationTokenAsync(organization, installationId, subInfo)
|
||||
.CreateOrganizationTokenAsync(organization, installationId, subInfo, plan.SecretsManager.MaxProjects)
|
||||
.Returns(token);
|
||||
|
||||
var result = await sutProvider.Sut.GetLicenseAsync(organization, installationId);
|
||||
|
@ -168,6 +168,11 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
|
||||
});
|
||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(sponsoringOrgUser.UserId.Value);
|
||||
|
||||
// Setup for checking available seats
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id)
|
||||
.Returns(0);
|
||||
|
||||
|
||||
await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
|
||||
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, false, null);
|
||||
@ -293,6 +298,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
|
||||
{
|
||||
sponsoringOrg.PlanType = PlanType.EnterpriseAnnually;
|
||||
sponsoringOrg.UseAdminSponsoredFamilies = true;
|
||||
sponsoringOrg.Seats = 10;
|
||||
sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed;
|
||||
|
||||
sutProvider.GetDependency<IUserService>().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user);
|
||||
@ -311,6 +317,11 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
|
||||
}
|
||||
]);
|
||||
|
||||
// Setup for checking available seats - organization has plenty of seats
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id)
|
||||
.Returns(5);
|
||||
|
||||
var actual = await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
|
||||
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes);
|
||||
|
||||
@ -331,5 +342,121 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().Received(1)
|
||||
.CreateAsync(Arg.Is<OrganizationSponsorship>(s => SponsorshipValidator(s, expectedSponsorship)));
|
||||
|
||||
// Verify we didn't need to add seats
|
||||
await sutProvider.GetDependency<IOrganizationService>().DidNotReceive()
|
||||
.AutoAddSeatsAsync(Arg.Any<Organization>(), Arg.Any<int>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(OrganizationUserType.Admin)]
|
||||
[BitAutoData(OrganizationUserType.Owner)]
|
||||
public async Task CreateSponsorship_CreatesAdminInitiatedSponsorship_AutoscalesWhenNeeded(
|
||||
OrganizationUserType organizationUserType,
|
||||
Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, User user, string sponsoredEmail,
|
||||
string friendlyName, Guid sponsorshipId, Guid currentUserId, string notes, SutProvider<CreateSponsorshipCommand> sutProvider)
|
||||
{
|
||||
sponsoringOrg.PlanType = PlanType.EnterpriseAnnually;
|
||||
sponsoringOrg.UseAdminSponsoredFamilies = true;
|
||||
sponsoringOrg.Seats = 10;
|
||||
sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed;
|
||||
|
||||
sutProvider.GetDependency<IUserService>().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user);
|
||||
sutProvider.GetDependency<IOrganizationSponsorshipRepository>().WhenForAnyArgs(x => x.UpsertAsync(null!)).Do(callInfo =>
|
||||
{
|
||||
var sponsorship = callInfo.Arg<OrganizationSponsorship>();
|
||||
sponsorship.Id = sponsorshipId;
|
||||
});
|
||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(currentUserId);
|
||||
sutProvider.GetDependency<ICurrentContext>().Organizations.Returns([
|
||||
new()
|
||||
{
|
||||
Id = sponsoringOrg.Id,
|
||||
Permissions = new Permissions { ManageUsers = true },
|
||||
Type = organizationUserType
|
||||
}
|
||||
]);
|
||||
|
||||
// Setup for checking available seats - organization has no available seats
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id)
|
||||
.Returns(10);
|
||||
|
||||
// Setup for checking if can scale
|
||||
sutProvider.GetDependency<IOrganizationService>()
|
||||
.CanScaleAsync(sponsoringOrg, 1)
|
||||
.Returns((true, ""));
|
||||
|
||||
var actual = await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
|
||||
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes);
|
||||
|
||||
|
||||
var expectedSponsorship = new OrganizationSponsorship
|
||||
{
|
||||
Id = sponsorshipId,
|
||||
SponsoringOrganizationId = sponsoringOrg.Id,
|
||||
SponsoringOrganizationUserId = sponsoringOrgUser.Id,
|
||||
FriendlyName = friendlyName,
|
||||
OfferedToEmail = sponsoredEmail,
|
||||
PlanSponsorshipType = PlanSponsorshipType.FamiliesForEnterprise,
|
||||
IsAdminInitiated = true,
|
||||
Notes = notes
|
||||
};
|
||||
|
||||
Assert.True(SponsorshipValidator(expectedSponsorship, actual));
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().Received(1)
|
||||
.CreateAsync(Arg.Is<OrganizationSponsorship>(s => SponsorshipValidator(s, expectedSponsorship)));
|
||||
|
||||
// Verify we needed to add seats
|
||||
await sutProvider.GetDependency<IOrganizationService>().Received(1)
|
||||
.AutoAddSeatsAsync(sponsoringOrg, 1);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(OrganizationUserType.Admin)]
|
||||
[BitAutoData(OrganizationUserType.Owner)]
|
||||
public async Task CreateSponsorship_CreatesAdminInitiatedSponsorship_ThrowsWhenCannotAutoscale(
|
||||
OrganizationUserType organizationUserType,
|
||||
Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, User user, string sponsoredEmail,
|
||||
string friendlyName, Guid sponsorshipId, Guid currentUserId, string notes, SutProvider<CreateSponsorshipCommand> sutProvider)
|
||||
{
|
||||
sponsoringOrg.PlanType = PlanType.EnterpriseAnnually;
|
||||
sponsoringOrg.UseAdminSponsoredFamilies = true;
|
||||
sponsoringOrg.Seats = 10;
|
||||
sponsoringOrgUser.Status = OrganizationUserStatusType.Confirmed;
|
||||
|
||||
sutProvider.GetDependency<IUserService>().GetUserByIdAsync(sponsoringOrgUser.UserId!.Value).Returns(user);
|
||||
sutProvider.GetDependency<IOrganizationSponsorshipRepository>().WhenForAnyArgs(x => x.UpsertAsync(null!)).Do(callInfo =>
|
||||
{
|
||||
var sponsorship = callInfo.Arg<OrganizationSponsorship>();
|
||||
sponsorship.Id = sponsorshipId;
|
||||
});
|
||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(currentUserId);
|
||||
sutProvider.GetDependency<ICurrentContext>().Organizations.Returns([
|
||||
new()
|
||||
{
|
||||
Id = sponsoringOrg.Id,
|
||||
Permissions = new Permissions { ManageUsers = true },
|
||||
Type = organizationUserType
|
||||
}
|
||||
]);
|
||||
|
||||
// Setup for checking available seats - organization has no available seats
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id)
|
||||
.Returns(10);
|
||||
|
||||
// Setup for checking if can scale - cannot scale
|
||||
var failureReason = "Seat limit has been reached.";
|
||||
sutProvider.GetDependency<IOrganizationService>()
|
||||
.CanScaleAsync(sponsoringOrg, 1)
|
||||
.Returns((false, failureReason));
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
|
||||
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes));
|
||||
|
||||
Assert.Equal(failureReason, exception.Message);
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user