mirror of
https://github.com/bitwarden/server.git
synced 2025-07-01 16:12:49 -05:00
[AC-1758] Implement RemoveOrganizationFromProviderCommand
(#3515)
* Add RemovePaymentMethod to StripePaymentService * Add SendProviderUpdatePaymentMethod to HandlebarsMailService * Add RemoveOrganizationFromProviderCommand * Use RemoveOrganizationFromProviderCommand in ProviderOrganizationController * Remove RemoveOrganizationAsync from ProviderService * Add RemoveOrganizationFromProviderCommandTests * PR review feedback and refactoring * Remove RemovePaymentMethod from StripePaymentService * Review feedback * Add Organization RisksSubscriptionFailure endpoint * fix build error * Review feedback * [AC-1359] Bitwarden Portal Unlink Provider Buttons (#3588) * Added ability to unlink organization from provider from provider edit page * Refreshing provider edit page after removing an org * Added button to organization to remove the org from the provider * Updated based on product feedback * Removed organization name from alert message * Temporary logging * Remove coupon from Stripe org after disconnected from MSP * Updated test * Change payment terms on org disconnect from MSP * Set Stripe account email to new billing email * Remove logging --------- Co-authored-by: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Co-authored-by: Conner Turnbull <cturnbull@bitwarden.com>
This commit is contained in:
@ -0,0 +1,367 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Commands.Implementations;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
using Xunit;
|
||||
using BT = Braintree;
|
||||
using S = Stripe;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Commands;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class RemovePaymentMethodCommandTests
|
||||
{
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_NullOrganization_ArgumentNullException(
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider) =>
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => sutProvider.Sut.RemovePaymentMethod(null));
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_NonStripeGateway_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.BitPay;
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_NoGatewayCustomerId_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
organization.GatewayCustomerId = null;
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_NoStripeCustomer_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<S.CustomerGetOptions>())
|
||||
.ReturnsNull();
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Braintree_NoCustomer_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
|
||||
const string braintreeCustomerId = "1";
|
||||
|
||||
var stripeCustomer = new S.Customer
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "btCustomerId", braintreeCustomerId }
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<S.CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
var (braintreeGateway, customerGateway, paymentMethodGateway) = Setup(sutProvider.GetDependency<BT.IBraintreeGateway>());
|
||||
|
||||
customerGateway.FindAsync(braintreeCustomerId).ReturnsNull();
|
||||
|
||||
braintreeGateway.Customer.Returns(customerGateway);
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization));
|
||||
|
||||
await customerGateway.Received(1).FindAsync(braintreeCustomerId);
|
||||
|
||||
await customerGateway.DidNotReceiveWithAnyArgs()
|
||||
.UpdateAsync(Arg.Any<string>(), Arg.Any<BT.CustomerRequest>());
|
||||
|
||||
await paymentMethodGateway.DidNotReceiveWithAnyArgs().DeleteAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Braintree_NoPaymentMethod_NoOp(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
|
||||
const string braintreeCustomerId = "1";
|
||||
|
||||
var stripeCustomer = new S.Customer
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "btCustomerId", braintreeCustomerId }
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<S.CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
var (_, customerGateway, paymentMethodGateway) = Setup(sutProvider.GetDependency<BT.IBraintreeGateway>());
|
||||
|
||||
var braintreeCustomer = Substitute.For<BT.Customer>();
|
||||
|
||||
braintreeCustomer.PaymentMethods.Returns(Array.Empty<BT.PaymentMethod>());
|
||||
|
||||
customerGateway.FindAsync(braintreeCustomerId).Returns(braintreeCustomer);
|
||||
|
||||
await sutProvider.Sut.RemovePaymentMethod(organization);
|
||||
|
||||
await customerGateway.Received(1).FindAsync(braintreeCustomerId);
|
||||
|
||||
await customerGateway.DidNotReceiveWithAnyArgs().UpdateAsync(Arg.Any<string>(), Arg.Any<BT.CustomerRequest>());
|
||||
|
||||
await paymentMethodGateway.DidNotReceiveWithAnyArgs().DeleteAsync(Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Braintree_CustomerUpdateFails_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
|
||||
const string braintreeCustomerId = "1";
|
||||
const string braintreePaymentMethodToken = "TOKEN";
|
||||
|
||||
var stripeCustomer = new S.Customer
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "btCustomerId", braintreeCustomerId }
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<S.CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
var (_, customerGateway, paymentMethodGateway) = Setup(sutProvider.GetDependency<BT.IBraintreeGateway>());
|
||||
|
||||
var braintreeCustomer = Substitute.For<BT.Customer>();
|
||||
|
||||
var paymentMethod = Substitute.For<BT.PaymentMethod>();
|
||||
paymentMethod.Token.Returns(braintreePaymentMethodToken);
|
||||
paymentMethod.IsDefault.Returns(true);
|
||||
|
||||
braintreeCustomer.PaymentMethods.Returns(new[]
|
||||
{
|
||||
paymentMethod
|
||||
});
|
||||
|
||||
customerGateway.FindAsync(braintreeCustomerId).Returns(braintreeCustomer);
|
||||
|
||||
var updateBraintreeCustomerResult = Substitute.For<BT.Result<BT.Customer>>();
|
||||
updateBraintreeCustomerResult.IsSuccess().Returns(false);
|
||||
|
||||
customerGateway.UpdateAsync(
|
||||
braintreeCustomerId,
|
||||
Arg.Is<BT.CustomerRequest>(request => request.DefaultPaymentMethodToken == null))
|
||||
.Returns(updateBraintreeCustomerResult);
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization));
|
||||
|
||||
await customerGateway.Received(1).FindAsync(braintreeCustomerId);
|
||||
|
||||
await customerGateway.Received(1).UpdateAsync(braintreeCustomerId, Arg.Is<BT.CustomerRequest>(request =>
|
||||
request.DefaultPaymentMethodToken == null));
|
||||
|
||||
await paymentMethodGateway.DidNotReceiveWithAnyArgs().DeleteAsync(paymentMethod.Token);
|
||||
|
||||
await customerGateway.DidNotReceive().UpdateAsync(braintreeCustomerId, Arg.Is<BT.CustomerRequest>(request =>
|
||||
request.DefaultPaymentMethodToken == paymentMethod.Token));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Braintree_PaymentMethodDeleteFails_RollBack_ContactSupport(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
|
||||
const string braintreeCustomerId = "1";
|
||||
const string braintreePaymentMethodToken = "TOKEN";
|
||||
|
||||
var stripeCustomer = new S.Customer
|
||||
{
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "btCustomerId", braintreeCustomerId }
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<S.CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
var (_, customerGateway, paymentMethodGateway) = Setup(sutProvider.GetDependency<BT.IBraintreeGateway>());
|
||||
|
||||
var braintreeCustomer = Substitute.For<BT.Customer>();
|
||||
|
||||
var paymentMethod = Substitute.For<BT.PaymentMethod>();
|
||||
paymentMethod.Token.Returns(braintreePaymentMethodToken);
|
||||
paymentMethod.IsDefault.Returns(true);
|
||||
|
||||
braintreeCustomer.PaymentMethods.Returns(new[]
|
||||
{
|
||||
paymentMethod
|
||||
});
|
||||
|
||||
customerGateway.FindAsync(braintreeCustomerId).Returns(braintreeCustomer);
|
||||
|
||||
var updateBraintreeCustomerResult = Substitute.For<BT.Result<BT.Customer>>();
|
||||
updateBraintreeCustomerResult.IsSuccess().Returns(true);
|
||||
|
||||
customerGateway.UpdateAsync(braintreeCustomerId, Arg.Any<BT.CustomerRequest>())
|
||||
.Returns(updateBraintreeCustomerResult);
|
||||
|
||||
var deleteBraintreePaymentMethodResult = Substitute.For<BT.Result<BT.PaymentMethod>>();
|
||||
deleteBraintreePaymentMethodResult.IsSuccess().Returns(false);
|
||||
|
||||
paymentMethodGateway.DeleteAsync(paymentMethod.Token).Returns(deleteBraintreePaymentMethodResult);
|
||||
|
||||
await ThrowsContactSupportAsync(() => sutProvider.Sut.RemovePaymentMethod(organization));
|
||||
|
||||
await customerGateway.Received(1).FindAsync(braintreeCustomerId);
|
||||
|
||||
await customerGateway.Received(1).UpdateAsync(braintreeCustomerId, Arg.Is<BT.CustomerRequest>(request =>
|
||||
request.DefaultPaymentMethodToken == null));
|
||||
|
||||
await paymentMethodGateway.Received(1).DeleteAsync(paymentMethod.Token);
|
||||
|
||||
await customerGateway.Received(1).UpdateAsync(braintreeCustomerId, Arg.Is<BT.CustomerRequest>(request =>
|
||||
request.DefaultPaymentMethodToken == paymentMethod.Token));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Stripe_Legacy_RemovesSources(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
|
||||
const string bankAccountId = "bank_account_id";
|
||||
const string cardId = "card_id";
|
||||
|
||||
var sources = new List<S.IPaymentSource>
|
||||
{
|
||||
new S.BankAccount { Id = bankAccountId }, new S.Card { Id = cardId }
|
||||
};
|
||||
|
||||
var stripeCustomer = new S.Customer { Sources = new S.StripeList<S.IPaymentSource> { Data = sources } };
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
stripeAdapter
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<S.CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
stripeAdapter
|
||||
.PaymentMethodListAutoPagingAsync(Arg.Any<S.PaymentMethodListOptions>())
|
||||
.Returns(GetPaymentMethodsAsync(new List<S.PaymentMethod>()));
|
||||
|
||||
await sutProvider.Sut.RemovePaymentMethod(organization);
|
||||
|
||||
await stripeAdapter.Received(1).BankAccountDeleteAsync(stripeCustomer.Id, bankAccountId);
|
||||
|
||||
await stripeAdapter.Received(1).CardDeleteAsync(stripeCustomer.Id, cardId);
|
||||
|
||||
await stripeAdapter.DidNotReceiveWithAnyArgs()
|
||||
.PaymentMethodDetachAsync(Arg.Any<string>(), Arg.Any<S.PaymentMethodDetachOptions>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RemovePaymentMethod_Stripe_DetachesPaymentMethods(
|
||||
Organization organization,
|
||||
SutProvider<RemovePaymentMethodCommand> sutProvider)
|
||||
{
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
const string bankAccountId = "bank_account_id";
|
||||
const string cardId = "card_id";
|
||||
|
||||
var sources = new List<S.IPaymentSource>();
|
||||
|
||||
var stripeCustomer = new S.Customer { Sources = new S.StripeList<S.IPaymentSource> { Data = sources } };
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
|
||||
stripeAdapter
|
||||
.CustomerGetAsync(organization.GatewayCustomerId, Arg.Any<S.CustomerGetOptions>())
|
||||
.Returns(stripeCustomer);
|
||||
|
||||
stripeAdapter
|
||||
.PaymentMethodListAutoPagingAsync(Arg.Any<S.PaymentMethodListOptions>())
|
||||
.Returns(GetPaymentMethodsAsync(new List<S.PaymentMethod>
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Id = bankAccountId
|
||||
},
|
||||
new ()
|
||||
{
|
||||
Id = cardId
|
||||
}
|
||||
}));
|
||||
|
||||
await sutProvider.Sut.RemovePaymentMethod(organization);
|
||||
|
||||
await stripeAdapter.DidNotReceiveWithAnyArgs().BankAccountDeleteAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
|
||||
await stripeAdapter.DidNotReceiveWithAnyArgs().CardDeleteAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
|
||||
await stripeAdapter.Received(1)
|
||||
.PaymentMethodDetachAsync(bankAccountId, Arg.Any<S.PaymentMethodDetachOptions>());
|
||||
|
||||
await stripeAdapter.Received(1)
|
||||
.PaymentMethodDetachAsync(cardId, Arg.Any<S.PaymentMethodDetachOptions>());
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<S.PaymentMethod> GetPaymentMethodsAsync(
|
||||
IEnumerable<S.PaymentMethod> paymentMethods)
|
||||
{
|
||||
foreach (var paymentMethod in paymentMethods)
|
||||
{
|
||||
yield return paymentMethod;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static (BT.IBraintreeGateway, BT.ICustomerGateway, BT.IPaymentMethodGateway) Setup(
|
||||
BT.IBraintreeGateway braintreeGateway)
|
||||
{
|
||||
var customerGateway = Substitute.For<BT.ICustomerGateway>();
|
||||
var paymentMethodGateway = Substitute.For<BT.IPaymentMethodGateway>();
|
||||
|
||||
braintreeGateway.Customer.Returns(customerGateway);
|
||||
braintreeGateway.PaymentMethod.Returns(paymentMethodGateway);
|
||||
|
||||
return (braintreeGateway, customerGateway, paymentMethodGateway);
|
||||
}
|
||||
|
||||
private static async Task ThrowsContactSupportAsync(Func<Task> function)
|
||||
{
|
||||
const string message = "Could not remove your payment method. Please contact support for assistance.";
|
||||
|
||||
var exception = await Assert.ThrowsAsync<GatewayException>(function);
|
||||
|
||||
Assert.Equal(message, exception.Message);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user