1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-30 15:42:48 -05:00

[AC-1608] Send offboarding survey response to Stripe on subscription cancellation (#3734)

* Added offboarding survey response to cancellation when FF is on.

* Removed service methods to prevent unnecessary upstream registrations

* Forgot to actually remove the injected command in the services

* Rui's feedback

* Add missing summary

* Missed [FromBody]
This commit is contained in:
Alex Morask
2024-02-09 11:58:37 -05:00
committed by GitHub
parent b81f9ca749
commit 59fa6935b4
20 changed files with 656 additions and 12 deletions

View File

@ -11,6 +11,8 @@ using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.Services;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Queries;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
@ -21,6 +23,7 @@ using Bit.Core.OrganizationFeatures.OrganizationLicenses.Interfaces;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Tools.Services;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Xunit;
@ -51,6 +54,9 @@ public class OrganizationsControllerTests : IDisposable
private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand;
private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand;
private readonly IPushNotificationService _pushNotificationService;
private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand;
private readonly IGetSubscriptionQuery _getSubscriptionQuery;
private readonly IReferenceEventService _referenceEventService;
private readonly OrganizationsController _sut;
@ -77,6 +83,9 @@ public class OrganizationsControllerTests : IDisposable
_upgradeOrganizationPlanCommand = Substitute.For<IUpgradeOrganizationPlanCommand>();
_addSecretsManagerSubscriptionCommand = Substitute.For<IAddSecretsManagerSubscriptionCommand>();
_pushNotificationService = Substitute.For<IPushNotificationService>();
_cancelSubscriptionCommand = Substitute.For<ICancelSubscriptionCommand>();
_getSubscriptionQuery = Substitute.For<IGetSubscriptionQuery>();
_referenceEventService = Substitute.For<IReferenceEventService>();
_sut = new OrganizationsController(
_organizationRepository,
@ -99,7 +108,10 @@ public class OrganizationsControllerTests : IDisposable
_updateSecretsManagerSubscriptionCommand,
_upgradeOrganizationPlanCommand,
_addSecretsManagerSubscriptionCommand,
_pushNotificationService);
_pushNotificationService,
_cancelSubscriptionCommand,
_getSubscriptionQuery,
_referenceEventService);
}
public void Dispose()

View File

@ -14,6 +14,9 @@ using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Auth.Services;
using Bit.Core.Auth.UserFeatures.UserKey;
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Queries;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -53,6 +56,10 @@ public class AccountsControllerTests : IDisposable
private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand;
private readonly IRotateUserKeyCommand _rotateUserKeyCommand;
private readonly IFeatureService _featureService;
private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand;
private readonly IGetSubscriptionQuery _getSubscriptionQuery;
private readonly IReferenceEventService _referenceEventService;
private readonly ICurrentContext _currentContext;
private readonly IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> _cipherValidator;
private readonly IRotationValidator<IEnumerable<FolderWithIdRequestModel>, IEnumerable<Folder>> _folderValidator;
@ -82,6 +89,10 @@ public class AccountsControllerTests : IDisposable
_setInitialMasterPasswordCommand = Substitute.For<ISetInitialMasterPasswordCommand>();
_rotateUserKeyCommand = Substitute.For<IRotateUserKeyCommand>();
_featureService = Substitute.For<IFeatureService>();
_cancelSubscriptionCommand = Substitute.For<ICancelSubscriptionCommand>();
_getSubscriptionQuery = Substitute.For<IGetSubscriptionQuery>();
_referenceEventService = Substitute.For<IReferenceEventService>();
_currentContext = Substitute.For<ICurrentContext>();
_cipherValidator =
Substitute.For<IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>>>();
_folderValidator =
@ -110,6 +121,10 @@ public class AccountsControllerTests : IDisposable
_setInitialMasterPasswordCommand,
_rotateUserKeyCommand,
_featureService,
_cancelSubscriptionCommand,
_getSubscriptionQuery,
_referenceEventService,
_currentContext,
_cipherValidator,
_folderValidator,
_sendValidator,

View File

@ -0,0 +1,163 @@
using System.Linq.Expressions;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Commands.Implementations;
using Bit.Core.Billing.Models;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Stripe;
using Xunit;
using static Bit.Core.Test.Billing.Utilities;
namespace Bit.Core.Test.Billing.Commands;
[SutProviderCustomize]
public class CancelSubscriptionCommandTests
{
private const string _subscriptionId = "subscription_id";
private const string _cancellingUserIdKey = "cancellingUserId";
[Theory, BitAutoData]
public async Task CancelSubscription_SubscriptionInactive_ThrowsGatewayException(
SutProvider<CancelSubscriptionCommand> sutProvider)
{
var subscription = new Subscription
{
Status = "canceled"
};
await ThrowsContactSupportAsync(() =>
sutProvider.Sut.CancelSubscription(subscription, new OffboardingSurveyResponse(), false));
await DidNotUpdateSubscription(sutProvider);
await DidNotCancelSubscription(sutProvider);
}
[Theory, BitAutoData]
public async Task CancelSubscription_CancelImmediately_BelongsToOrganization_UpdatesSubscription_CancelSubscriptionImmediately(
SutProvider<CancelSubscriptionCommand> sutProvider)
{
var userId = Guid.NewGuid();
var subscription = new Subscription
{
Id = _subscriptionId,
Status = "active",
Metadata = new Dictionary<string, string>
{
{ "organizationId", "organization_id" }
}
};
var offboardingSurveyResponse = new OffboardingSurveyResponse
{
UserId = userId,
Reason = "missing_features",
Feedback = "Lorem ipsum"
};
await sutProvider.Sut.CancelSubscription(subscription, offboardingSurveyResponse, true);
await UpdatedSubscriptionWith(sutProvider, options => options.Metadata[_cancellingUserIdKey] == userId.ToString());
await CancelledSubscriptionWith(sutProvider, options =>
options.CancellationDetails.Comment == offboardingSurveyResponse.Feedback &&
options.CancellationDetails.Feedback == offboardingSurveyResponse.Reason);
}
[Theory, BitAutoData]
public async Task CancelSubscription_CancelImmediately_BelongsToUser_CancelSubscriptionImmediately(
SutProvider<CancelSubscriptionCommand> sutProvider)
{
var userId = Guid.NewGuid();
var subscription = new Subscription
{
Id = _subscriptionId,
Status = "active",
Metadata = new Dictionary<string, string>
{
{ "userId", "user_id" }
}
};
var offboardingSurveyResponse = new OffboardingSurveyResponse
{
UserId = userId,
Reason = "missing_features",
Feedback = "Lorem ipsum"
};
await sutProvider.Sut.CancelSubscription(subscription, offboardingSurveyResponse, true);
await DidNotUpdateSubscription(sutProvider);
await CancelledSubscriptionWith(sutProvider, options =>
options.CancellationDetails.Comment == offboardingSurveyResponse.Feedback &&
options.CancellationDetails.Feedback == offboardingSurveyResponse.Reason);
}
[Theory, BitAutoData]
public async Task CancelSubscription_DoNotCancelImmediately_UpdateSubscriptionToCancelAtEndOfPeriod(
Organization organization,
SutProvider<CancelSubscriptionCommand> sutProvider)
{
var userId = Guid.NewGuid();
organization.ExpirationDate = DateTime.UtcNow.AddDays(5);
var subscription = new Subscription
{
Id = _subscriptionId,
Status = "active"
};
var offboardingSurveyResponse = new OffboardingSurveyResponse
{
UserId = userId,
Reason = "missing_features",
Feedback = "Lorem ipsum"
};
await sutProvider.Sut.CancelSubscription(subscription, offboardingSurveyResponse, false);
await UpdatedSubscriptionWith(sutProvider, options =>
options.CancelAtPeriodEnd == true &&
options.CancellationDetails.Comment == offboardingSurveyResponse.Feedback &&
options.CancellationDetails.Feedback == offboardingSurveyResponse.Reason &&
options.Metadata[_cancellingUserIdKey] == userId.ToString());
await DidNotCancelSubscription(sutProvider);
}
private static Task<Subscription> DidNotCancelSubscription(SutProvider<CancelSubscriptionCommand> sutProvider)
=> sutProvider
.GetDependency<IStripeAdapter>()
.DidNotReceiveWithAnyArgs()
.SubscriptionCancelAsync(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
private static Task<Subscription> DidNotUpdateSubscription(SutProvider<CancelSubscriptionCommand> sutProvider)
=> sutProvider
.GetDependency<IStripeAdapter>()
.DidNotReceiveWithAnyArgs()
.SubscriptionUpdateAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
private static Task<Subscription> CancelledSubscriptionWith(
SutProvider<CancelSubscriptionCommand> sutProvider,
Expression<Predicate<SubscriptionCancelOptions>> predicate)
=> sutProvider
.GetDependency<IStripeAdapter>()
.Received(1)
.SubscriptionCancelAsync(_subscriptionId, Arg.Is(predicate));
private static Task<Subscription> UpdatedSubscriptionWith(
SutProvider<CancelSubscriptionCommand> sutProvider,
Expression<Predicate<SubscriptionUpdateOptions>> predicate)
=> sutProvider
.GetDependency<IStripeAdapter>()
.Received(1)
.SubscriptionUpdateAsync(_subscriptionId, Arg.Is(predicate));
}

View File

@ -0,0 +1,104 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Queries.Implementations;
using Bit.Core.Entities;
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 Stripe;
using Xunit;
namespace Bit.Core.Test.Billing.Queries;
[SutProviderCustomize]
public class GetSubscriptionQueryTests
{
[Theory, BitAutoData]
public async Task GetSubscription_NullSubscriber_ThrowsArgumentNullException(
SutProvider<GetSubscriptionQuery> sutProvider)
=> await Assert.ThrowsAsync<ArgumentNullException>(
async () => await sutProvider.Sut.GetSubscription(null));
[Theory, BitAutoData]
public async Task GetSubscription_Organization_NoGatewaySubscriptionId_ThrowsGatewayException(
Organization organization,
SutProvider<GetSubscriptionQuery> sutProvider)
{
organization.GatewaySubscriptionId = null;
await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetSubscription(organization));
}
[Theory, BitAutoData]
public async Task GetSubscription_Organization_NoSubscription_ThrowsGatewayException(
Organization organization,
SutProvider<GetSubscriptionQuery> sutProvider)
{
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId)
.ReturnsNull();
await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetSubscription(organization));
}
[Theory, BitAutoData]
public async Task GetSubscription_Organization_Succeeds(
Organization organization,
SutProvider<GetSubscriptionQuery> sutProvider)
{
var subscription = new Subscription();
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId)
.Returns(subscription);
var gotSubscription = await sutProvider.Sut.GetSubscription(organization);
Assert.Equivalent(subscription, gotSubscription);
}
[Theory, BitAutoData]
public async Task GetSubscription_User_NoGatewaySubscriptionId_ThrowsGatewayException(
User user,
SutProvider<GetSubscriptionQuery> sutProvider)
{
user.GatewaySubscriptionId = null;
await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetSubscription(user));
}
[Theory, BitAutoData]
public async Task GetSubscription_User_NoSubscription_ThrowsGatewayException(
User user,
SutProvider<GetSubscriptionQuery> sutProvider)
{
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(user.GatewaySubscriptionId)
.ReturnsNull();
await ThrowsContactSupportAsync(async () => await sutProvider.Sut.GetSubscription(user));
}
[Theory, BitAutoData]
public async Task GetSubscription_User_Succeeds(
User user,
SutProvider<GetSubscriptionQuery> sutProvider)
{
var subscription = new Subscription();
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(user.GatewaySubscriptionId)
.Returns(subscription);
var gotSubscription = await sutProvider.Sut.GetSubscription(user);
Assert.Equivalent(subscription, gotSubscription);
}
private static async Task ThrowsContactSupportAsync(Func<Task> function)
{
const string message = "Something went wrong with your request. Please contact support.";
var exception = await Assert.ThrowsAsync<GatewayException>(function);
Assert.Equal(message, exception.Message);
}
}

View File

@ -0,0 +1,18 @@
using Bit.Core.Exceptions;
using Xunit;
using static Bit.Core.Billing.Utilities;
namespace Bit.Core.Test.Billing;
public static class Utilities
{
public static async Task ThrowsContactSupportAsync(Func<Task> function)
{
var contactSupport = ContactSupport();
var exception = await Assert.ThrowsAsync<GatewayException>(function);
Assert.Equal(contactSupport.Message, exception.Message);
}
}