1
0
mirror of https://github.com/bitwarden/server synced 2026-01-11 21:13:50 +00:00

Merge branch 'main' into auth/pm-22975/client-version-validator

This commit is contained in:
Patrick-Pimentel-Bitwarden
2026-01-05 12:41:01 -05:00
committed by GitHub
10 changed files with 827 additions and 166 deletions

View File

@@ -680,22 +680,10 @@ public class AccountController : Controller
ApiKey = CoreHelpers.SecureRandomString(30)
};
/*
The feature flag is checked here so that we can send the new MJML welcome email templates.
The other organization invites flows have an OrganizationUser allowing the RegisterUserCommand the ability
to fetch the Organization. The old method RegisterUser(User) here does not have that context, so we need
to use a new method RegisterSSOAutoProvisionedUserAsync(User, Organization) to send the correct email.
[PM-28057]: Prefer RegisterSSOAutoProvisionedUserAsync for SSO auto-provisioned users.
TODO: Remove Feature flag: PM-28221
*/
if (_featureService.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates))
{
await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization);
}
else
{
await _registerUserCommand.RegisterUser(newUser);
}
// Always use RegisterSSOAutoProvisionedUserAsync to ensure organization context is available
// for domain validation (BlockClaimedDomainAccountCreation policy) and welcome emails.
// The feature flag logic for welcome email templates is handled internally by RegisterUserCommand.
await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization);
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
var twoFactorPolicy =

View File

@@ -6,7 +6,6 @@ using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.Registration;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
@@ -21,7 +20,6 @@ using Duende.IdentityServer.Models;
using Duende.IdentityServer.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
@@ -1013,133 +1011,6 @@ public class AccountControllerTest
}
}
[Theory, BitAutoData]
public async Task AutoProvisionUserAsync_WithFeatureFlagEnabled_CallsRegisterSSOAutoProvisionedUser(
SutProvider<AccountController> sutProvider)
{
// Arrange
var orgId = Guid.NewGuid();
var providerUserId = "ext-new-user";
var email = "newuser@example.com";
var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null };
// No existing user (JIT provisioning scenario)
sutProvider.GetDependency<IUserRepository>().GetByEmailAsync(email).Returns((User?)null);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(orgId).Returns(organization);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationEmailAsync(orgId, email)
.Returns((OrganizationUser?)null);
// Feature flag enabled
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
// Mock the RegisterSSOAutoProvisionedUserAsync to return success
sutProvider.GetDependency<IRegisterUserCommand>()
.RegisterSSOAutoProvisionedUserAsync(Arg.Any<User>(), Arg.Any<Organization>())
.Returns(IdentityResult.Success);
var claims = new[]
{
new Claim(JwtClaimTypes.Email, email),
new Claim(JwtClaimTypes.Name, "New User")
} as IEnumerable<Claim>;
var config = new SsoConfigurationData();
var method = typeof(AccountController).GetMethod(
"CreateUserAndOrgUserConditionallyAsync",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
// Act
var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke(
sutProvider.Sut,
new object[]
{
orgId.ToString(),
providerUserId,
claims,
null!,
config
})!;
var result = await task;
// Assert
await sutProvider.GetDependency<IRegisterUserCommand>().Received(1)
.RegisterSSOAutoProvisionedUserAsync(
Arg.Is<User>(u => u.Email == email && u.Name == "New User"),
Arg.Is<Organization>(o => o.Id == orgId && o.Name == "Test Org"));
Assert.NotNull(result.user);
Assert.Equal(email, result.user.Email);
Assert.Equal(organization.Id, result.organization.Id);
}
[Theory, BitAutoData]
public async Task AutoProvisionUserAsync_WithFeatureFlagDisabled_CallsRegisterUserInstead(
SutProvider<AccountController> sutProvider)
{
// Arrange
var orgId = Guid.NewGuid();
var providerUserId = "ext-legacy-user";
var email = "legacyuser@example.com";
var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null };
// No existing user (JIT provisioning scenario)
sutProvider.GetDependency<IUserRepository>().GetByEmailAsync(email).Returns((User?)null);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(orgId).Returns(organization);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationEmailAsync(orgId, email)
.Returns((OrganizationUser?)null);
// Feature flag disabled
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(false);
// Mock the RegisterUser to return success
sutProvider.GetDependency<IRegisterUserCommand>()
.RegisterUser(Arg.Any<User>())
.Returns(IdentityResult.Success);
var claims = new[]
{
new Claim(JwtClaimTypes.Email, email),
new Claim(JwtClaimTypes.Name, "Legacy User")
} as IEnumerable<Claim>;
var config = new SsoConfigurationData();
var method = typeof(AccountController).GetMethod(
"CreateUserAndOrgUserConditionallyAsync",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
// Act
var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke(
sutProvider.Sut,
new object[]
{
orgId.ToString(),
providerUserId,
claims,
null!,
config
})!;
var result = await task;
// Assert
await sutProvider.GetDependency<IRegisterUserCommand>().Received(1)
.RegisterUser(Arg.Is<User>(u => u.Email == email && u.Name == "Legacy User"));
// Verify the new method was NOT called
await sutProvider.GetDependency<IRegisterUserCommand>().DidNotReceive()
.RegisterSSOAutoProvisionedUserAsync(Arg.Any<User>(), Arg.Any<Organization>());
Assert.NotNull(result.user);
Assert.Equal(email, result.user.Email);
}
[Theory, BitAutoData]
public void ExternalChallenge_WithMatchingOrgId_Succeeds(
SutProvider<AccountController> sutProvider,

View File

@@ -1,6 +1,7 @@
using Bit.Api.Billing.Attributes;
using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Api.Billing.Models.Requests.Premium;
using Bit.Api.Billing.Models.Requests.Storage;
using Bit.Core;
using Bit.Core.Billing.Licenses.Queries;
using Bit.Core.Billing.Payment.Commands;
@@ -23,7 +24,8 @@ public class AccountBillingVNextController(
IGetCreditQuery getCreditQuery,
IGetPaymentMethodQuery getPaymentMethodQuery,
IGetUserLicenseQuery getUserLicenseQuery,
IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
IUpdatePremiumStorageCommand updatePremiumStorageCommand) : BaseBillingController
{
[HttpGet("credit")]
[InjectUser]
@@ -88,4 +90,15 @@ public class AccountBillingVNextController(
var response = await getUserLicenseQuery.Run(user);
return TypedResults.Ok(response);
}
[HttpPut("storage")]
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
[InjectUser]
public async Task<IResult> UpdateStorageAsync(
[BindNever] User user,
[FromBody] StorageUpdateRequest request)
{
var result = await updatePremiumStorageCommand.Run(user, request.AdditionalStorageGb);
return Handle(result);
}
}

View File

@@ -0,0 +1,35 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Api.Billing.Models.Requests.Storage;
/// <summary>
/// Request model for updating storage allocation on a user's premium subscription.
/// Allows for both increasing and decreasing storage in an idempotent manner.
/// </summary>
public class StorageUpdateRequest : IValidatableObject
{
/// <summary>
/// The additional storage in GB beyond the base storage.
/// Must be between 0 and the maximum allowed (minus base storage).
/// </summary>
[Required]
[Range(0, 99)]
public short AdditionalStorageGb { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (AdditionalStorageGb < 0)
{
yield return new ValidationResult(
"Additional storage cannot be negative.",
new[] { nameof(AdditionalStorageGb) });
}
if (AdditionalStorageGb > 99)
{
yield return new ValidationResult(
"Maximum additional storage is 99 GB.",
new[] { nameof(AdditionalStorageGb) });
}
}
}

View File

@@ -53,6 +53,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<ICreatePremiumCloudHostedSubscriptionCommand, CreatePremiumCloudHostedSubscriptionCommand>();
services.AddScoped<ICreatePremiumSelfHostedSubscriptionCommand, CreatePremiumSelfHostedSubscriptionCommand>();
services.AddTransient<IPreviewPremiumTaxCommand, PreviewPremiumTaxCommand>();
services.AddScoped<IUpdatePremiumStorageCommand, UpdatePremiumStorageCommand>();
}
private static void AddPremiumQueries(this IServiceCollection services)

View File

@@ -0,0 +1,144 @@
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
using OneOf.Types;
using Stripe;
namespace Bit.Core.Billing.Premium.Commands;
/// <summary>
/// Updates the storage allocation for a premium user's subscription.
/// Handles both increases and decreases in storage in an idempotent manner.
/// </summary>
public interface IUpdatePremiumStorageCommand
{
/// <summary>
/// Updates the user's storage by the specified additional amount.
/// </summary>
/// <param name="user">The premium user whose storage should be updated.</param>
/// <param name="additionalStorageGb">The additional storage amount in GB beyond base storage.</param>
/// <returns>A billing command result indicating success or failure.</returns>
Task<BillingCommandResult<None>> Run(User user, short additionalStorageGb);
}
public class UpdatePremiumStorageCommand(
IStripeAdapter stripeAdapter,
IUserService userService,
IPricingClient pricingClient,
ILogger<UpdatePremiumStorageCommand> logger)
: BaseBillingCommand<UpdatePremiumStorageCommand>(logger), IUpdatePremiumStorageCommand
{
public Task<BillingCommandResult<None>> Run(User user, short additionalStorageGb) => HandleAsync<None>(async () =>
{
if (!user.Premium)
{
return new BadRequest("User does not have a premium subscription.");
}
if (!user.MaxStorageGb.HasValue)
{
return new BadRequest("No access to storage.");
}
// Fetch all premium plans and the user's subscription to find which plan they're on
var premiumPlans = await pricingClient.ListPremiumPlans();
var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId);
// Find the password manager subscription item (seat, not storage) and match it to a plan
var passwordManagerItem = subscription.Items.Data.FirstOrDefault(i =>
premiumPlans.Any(p => p.Seat.StripePriceId == i.Price.Id));
if (passwordManagerItem == null)
{
return new BadRequest("Premium subscription item not found.");
}
var premiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id);
var baseStorageGb = (short)premiumPlan.Storage.Provided;
if (additionalStorageGb < 0)
{
return new BadRequest("Additional storage cannot be negative.");
}
var newTotalStorageGb = (short)(baseStorageGb + additionalStorageGb);
if (newTotalStorageGb > 100)
{
return new BadRequest("Maximum storage is 100 GB.");
}
// Idempotency check: if user already has the requested storage, return success
if (user.MaxStorageGb == newTotalStorageGb)
{
return new None();
}
var remainingStorage = user.StorageBytesRemaining(newTotalStorageGb);
if (remainingStorage < 0)
{
return new BadRequest(
$"You are currently using {CoreHelpers.ReadableBytesSize(user.Storage.GetValueOrDefault(0))} of storage. " +
"Delete some stored data first.");
}
// Find the storage line item in the subscription
var storageItem = subscription.Items.Data.FirstOrDefault(i => i.Price.Id == premiumPlan.Storage.StripePriceId);
var subscriptionItemOptions = new List<SubscriptionItemOptions>();
if (additionalStorageGb > 0)
{
if (storageItem != null)
{
// Update existing storage item
subscriptionItemOptions.Add(new SubscriptionItemOptions
{
Id = storageItem.Id,
Price = premiumPlan.Storage.StripePriceId,
Quantity = additionalStorageGb
});
}
else
{
// Add new storage item
subscriptionItemOptions.Add(new SubscriptionItemOptions
{
Price = premiumPlan.Storage.StripePriceId,
Quantity = additionalStorageGb
});
}
}
else if (storageItem != null)
{
// Remove storage item if setting to 0
subscriptionItemOptions.Add(new SubscriptionItemOptions
{
Id = storageItem.Id,
Deleted = true
});
}
// Update subscription with prorations
// Storage is billed annually, so we create prorations and invoice immediately
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
{
Items = subscriptionItemOptions,
ProrationBehavior = Core.Constants.CreateProrations
};
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, subscriptionUpdateOptions);
// Update the user's max storage
user.MaxStorageGb = newTotalStorageGb;
await userService.SaveUserAsync(user);
// No payment intent needed - the subscription update will automatically create and finalize the invoice
return new None();
});
}

View File

@@ -196,6 +196,7 @@ public static class FeatureFlagKeys
public const string PM26462_Milestone_3 = "pm-26462-milestone-3";
public const string PM28265_EnableReconcileAdditionalStorageJob = "pm-28265-enable-reconcile-additional-storage-job";
public const string PM28265_ReconcileAdditionalStorageJobEnableLiveMode = "pm-28265-reconcile-additional-storage-job-enable-live-mode";
public const string PM29594_UpdateIndividualSubscriptionPage = "pm-29594-update-individual-subscription-page";
/* Key Management Team */
public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration";

View File

@@ -1,57 +1,245 @@
using Bit.Api.Billing.Controllers.VNext;
using Bit.Api.Billing.Models.Requests.Storage;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Licenses.Queries;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Entities;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Http;
using NSubstitute;
using OneOf.Types;
using Xunit;
using BadRequest = Bit.Core.Billing.Commands.BadRequest;
namespace Bit.Api.Test.Billing.Controllers.VNext;
public class AccountBillingVNextControllerTests
{
private readonly ICreateBitPayInvoiceForCreditCommand _createBitPayInvoiceForCreditCommand;
private readonly ICreatePremiumCloudHostedSubscriptionCommand _createPremiumCloudHostedSubscriptionCommand;
private readonly IGetCreditQuery _getCreditQuery;
private readonly IGetPaymentMethodQuery _getPaymentMethodQuery;
private readonly IUpdatePremiumStorageCommand _updatePremiumStorageCommand;
private readonly IGetUserLicenseQuery _getUserLicenseQuery;
private readonly IUpdatePaymentMethodCommand _updatePaymentMethodCommand;
private readonly AccountBillingVNextController _sut;
public AccountBillingVNextControllerTests()
{
_createBitPayInvoiceForCreditCommand = Substitute.For<ICreateBitPayInvoiceForCreditCommand>();
_createPremiumCloudHostedSubscriptionCommand = Substitute.For<ICreatePremiumCloudHostedSubscriptionCommand>();
_getCreditQuery = Substitute.For<IGetCreditQuery>();
_getPaymentMethodQuery = Substitute.For<IGetPaymentMethodQuery>();
_updatePremiumStorageCommand = Substitute.For<IUpdatePremiumStorageCommand>();
_getUserLicenseQuery = Substitute.For<IGetUserLicenseQuery>();
_updatePaymentMethodCommand = Substitute.For<IUpdatePaymentMethodCommand>();
_sut = new AccountBillingVNextController(
_createBitPayInvoiceForCreditCommand,
_createPremiumCloudHostedSubscriptionCommand,
_getCreditQuery,
_getPaymentMethodQuery,
Substitute.For<Core.Billing.Payment.Commands.ICreateBitPayInvoiceForCreditCommand>(),
Substitute.For<Core.Billing.Premium.Commands.ICreatePremiumCloudHostedSubscriptionCommand>(),
Substitute.For<Core.Billing.Payment.Queries.IGetCreditQuery>(),
Substitute.For<Core.Billing.Payment.Queries.IGetPaymentMethodQuery>(),
_getUserLicenseQuery,
_updatePaymentMethodCommand);
Substitute.For<Core.Billing.Payment.Commands.IUpdatePaymentMethodCommand>(),
_updatePremiumStorageCommand);
}
[Theory, BitAutoData]
public async Task GetLicenseAsync_ValidUser_ReturnsLicenseResponse(User user,
public async Task GetLicenseAsync_ValidUser_ReturnsLicenseResponse(
User user,
Core.Billing.Licenses.Models.Api.Response.LicenseResponseModel licenseResponse)
{
// Arrange
_getUserLicenseQuery.Run(user).Returns(licenseResponse);
// Act
var result = await _sut.GetLicenseAsync(user);
// Assert
var okResult = Assert.IsAssignableFrom<IResult>(result);
await _getUserLicenseQuery.Received(1).Run(user);
}
[Theory, BitAutoData]
public async Task UpdateStorageAsync_Success_ReturnsOk(User user)
{
// Arrange
var request = new StorageUpdateRequest { AdditionalStorageGb = 10 };
_updatePremiumStorageCommand.Run(
Arg.Is<User>(u => u.Id == user.Id),
Arg.Is<short>(s => s == 10))
.Returns(new BillingCommandResult<None>(new None()));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
// Assert
var okResult = Assert.IsAssignableFrom<IResult>(result);
await _updatePremiumStorageCommand.Received(1).Run(user, 10);
}
[Theory, BitAutoData]
public async Task UpdateStorageAsync_UserNotPremium_ReturnsBadRequest(User user)
{
// Arrange
var request = new StorageUpdateRequest { AdditionalStorageGb = 10 };
var errorMessage = "User does not have a premium subscription.";
_updatePremiumStorageCommand.Run(
Arg.Is<User>(u => u.Id == user.Id),
Arg.Is<short>(s => s == 10))
.Returns(new BadRequest(errorMessage));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
// Assert
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
await _updatePremiumStorageCommand.Received(1).Run(user, 10);
}
[Theory, BitAutoData]
public async Task UpdateStorageAsync_NoPaymentMethod_ReturnsBadRequest(User user)
{
// Arrange
var request = new StorageUpdateRequest { AdditionalStorageGb = 10 };
var errorMessage = "No payment method found.";
_updatePremiumStorageCommand.Run(
Arg.Is<User>(u => u.Id == user.Id),
Arg.Is<short>(s => s == 10))
.Returns(new BadRequest(errorMessage));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
// Assert
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
await _updatePremiumStorageCommand.Received(1).Run(user, 10);
}
[Theory, BitAutoData]
public async Task UpdateStorageAsync_StorageLessThanBase_ReturnsBadRequest(User user)
{
// Arrange
var request = new StorageUpdateRequest { AdditionalStorageGb = 1 };
var errorMessage = "Storage cannot be less than the base amount of 1 GB.";
_updatePremiumStorageCommand.Run(
Arg.Is<User>(u => u.Id == user.Id),
Arg.Is<short>(s => s == 1))
.Returns(new BadRequest(errorMessage));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
// Assert
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
await _updatePremiumStorageCommand.Received(1).Run(user, 1);
}
[Theory, BitAutoData]
public async Task UpdateStorageAsync_StorageExceedsMaximum_ReturnsBadRequest(User user)
{
// Arrange
var request = new StorageUpdateRequest { AdditionalStorageGb = 100 };
var errorMessage = "Maximum storage is 100 GB.";
_updatePremiumStorageCommand.Run(
Arg.Is<User>(u => u.Id == user.Id),
Arg.Is<short>(s => s == 100))
.Returns(new BadRequest(errorMessage));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
// Assert
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
await _updatePremiumStorageCommand.Received(1).Run(user, 100);
}
[Theory, BitAutoData]
public async Task UpdateStorageAsync_StorageExceedsCurrentUsage_ReturnsBadRequest(User user)
{
// Arrange
var request = new StorageUpdateRequest { AdditionalStorageGb = 2 };
var errorMessage = "You are currently using 5.00 GB of storage. Delete some stored data first.";
_updatePremiumStorageCommand.Run(
Arg.Is<User>(u => u.Id == user.Id),
Arg.Is<short>(s => s == 2))
.Returns(new BadRequest(errorMessage));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
// Assert
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
await _updatePremiumStorageCommand.Received(1).Run(user, 2);
}
[Theory, BitAutoData]
public async Task UpdateStorageAsync_IncreaseStorage_Success(User user)
{
// Arrange
var request = new StorageUpdateRequest { AdditionalStorageGb = 15 };
_updatePremiumStorageCommand.Run(
Arg.Is<User>(u => u.Id == user.Id),
Arg.Is<short>(s => s == 15))
.Returns(new BillingCommandResult<None>(new None()));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
// Assert
var okResult = Assert.IsAssignableFrom<IResult>(result);
await _updatePremiumStorageCommand.Received(1).Run(user, 15);
}
[Theory, BitAutoData]
public async Task UpdateStorageAsync_DecreaseStorage_Success(User user)
{
// Arrange
var request = new StorageUpdateRequest { AdditionalStorageGb = 3 };
_updatePremiumStorageCommand.Run(
Arg.Is<User>(u => u.Id == user.Id),
Arg.Is<short>(s => s == 3))
.Returns(new BillingCommandResult<None>(new None()));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
// Assert
var okResult = Assert.IsAssignableFrom<IResult>(result);
await _updatePremiumStorageCommand.Received(1).Run(user, 3);
}
[Theory, BitAutoData]
public async Task UpdateStorageAsync_MaximumStorage_Success(User user)
{
// Arrange
var request = new StorageUpdateRequest { AdditionalStorageGb = 100 };
_updatePremiumStorageCommand.Run(
Arg.Is<User>(u => u.Id == user.Id),
Arg.Is<short>(s => s == 100))
.Returns(new BillingCommandResult<None>(new None()));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
// Assert
var okResult = Assert.IsAssignableFrom<IResult>(result);
await _updatePremiumStorageCommand.Received(1).Run(user, 100);
}
[Theory, BitAutoData]
public async Task UpdateStorageAsync_NullPaymentSecret_Success(User user)
{
// Arrange
var request = new StorageUpdateRequest { AdditionalStorageGb = 5 };
_updatePremiumStorageCommand.Run(
Arg.Is<User>(u => u.Id == user.Id),
Arg.Is<short>(s => s == 5))
.Returns(new BillingCommandResult<None>(new None()));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
// Assert
var okResult = Assert.IsAssignableFrom<IResult>(result);
await _updatePremiumStorageCommand.Received(1).Run(user, 5);
}
}

View File

@@ -407,4 +407,85 @@ public class UpdateBillingAddressCommandTests
options => options.Type == TaxIdType.SpanishNIF &&
options.Value == input.TaxId.Value));
}
[Fact]
public async Task Run_BusinessOrganization_UpdatingWithSameTaxId_DeletesBeforeCreating()
{
var organization = new Organization
{
PlanType = PlanType.EnterpriseAnnually,
GatewayCustomerId = "cus_123",
GatewaySubscriptionId = "sub_123"
};
var input = new BillingAddress
{
Country = "US",
PostalCode = "12345",
Line1 = "123 Main St.",
Line2 = "Suite 100",
City = "New York",
State = "NY",
TaxId = new TaxID("us_ein", "987654321")
};
var existingTaxId = new TaxId { Id = "tax_id_123", Type = "us_ein", Value = "987654321" };
var customer = new Customer
{
Address = new Address
{
Country = "US",
PostalCode = "12345",
Line1 = "123 Main St.",
Line2 = "Suite 100",
City = "New York",
State = "NY"
},
Id = organization.GatewayCustomerId,
Subscriptions = new StripeList<Subscription>
{
Data =
[
new Subscription
{
Id = organization.GatewaySubscriptionId,
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }
}
]
},
TaxIds = new StripeList<TaxId>
{
Data = [existingTaxId]
}
};
_stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Address.Matches(input) &&
options.HasExpansions("subscriptions", "tax_ids") &&
options.TaxExempt == TaxExempt.None
)).Returns(customer);
var newTaxId = new TaxId { Id = "tax_id_456", Type = "us_ein", Value = "987654321" };
_stripeAdapter.CreateTaxIdAsync(customer.Id, Arg.Is<TaxIdCreateOptions>(
options => options.Type == "us_ein" && options.Value == "987654321"
)).Returns(newTaxId);
var result = await _command.Run(organization, input);
Assert.True(result.IsT0);
var output = result.AsT0;
Assert.Equivalent(input, output);
// Verify that deletion happens before creation
Received.InOrder(() =>
{
_stripeAdapter.DeleteTaxIdAsync(customer.Id, existingTaxId.Id);
_stripeAdapter.CreateTaxIdAsync(customer.Id, Arg.Any<TaxIdCreateOptions>());
});
await _stripeAdapter.Received(1).DeleteTaxIdAsync(customer.Id, existingTaxId.Id);
await _stripeAdapter.Received(1).CreateTaxIdAsync(customer.Id, Arg.Is<TaxIdCreateOptions>(
options => options.Type == "us_ein" && options.Value == "987654321"));
}
}

View File

@@ -0,0 +1,339 @@
using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Stripe;
using Xunit;
using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan;
using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable;
namespace Bit.Core.Test.Billing.Premium.Commands;
public class UpdatePremiumStorageCommandTests
{
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
private readonly IUserService _userService = Substitute.For<IUserService>();
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
private readonly PremiumPlan _premiumPlan;
private readonly UpdatePremiumStorageCommand _command;
public UpdatePremiumStorageCommandTests()
{
// Setup default premium plan with standard pricing
_premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = "price_premium", Provided = 1 },
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "price_storage", Provided = 1 }
};
_pricingClient.ListPremiumPlans().Returns(new List<PremiumPlan> { _premiumPlan });
_command = new UpdatePremiumStorageCommand(
_stripeAdapter,
_userService,
_pricingClient,
Substitute.For<ILogger<UpdatePremiumStorageCommand>>());
}
private Subscription CreateMockSubscription(string subscriptionId, int? storageQuantity = null)
{
var items = new List<SubscriptionItem>();
// Always add the seat item
items.Add(new SubscriptionItem
{
Id = "si_seat",
Price = new Price { Id = "price_premium" },
Quantity = 1
});
// Add storage item if quantity is provided
if (storageQuantity.HasValue && storageQuantity.Value > 0)
{
items.Add(new SubscriptionItem
{
Id = "si_storage",
Price = new Price { Id = "price_storage" },
Quantity = storageQuantity.Value
});
}
return new Subscription
{
Id = subscriptionId,
Items = new StripeList<SubscriptionItem>
{
Data = items
}
};
}
[Theory, BitAutoData]
public async Task Run_UserNotPremium_ReturnsBadRequest(User user)
{
// Arrange
user.Premium = false;
// Act
var result = await _command.Run(user, 5);
// Assert
Assert.True(result.IsT1);
var badRequest = result.AsT1;
Assert.Equal("User does not have a premium subscription.", badRequest.Response);
}
[Theory, BitAutoData]
public async Task Run_NegativeStorage_ReturnsBadRequest(User user)
{
// Arrange
user.Premium = true;
user.MaxStorageGb = 5;
user.GatewaySubscriptionId = "sub_123";
var subscription = CreateMockSubscription("sub_123", 4);
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
// Act
var result = await _command.Run(user, -5);
// Assert
Assert.True(result.IsT1);
var badRequest = result.AsT1;
Assert.Equal("Additional storage cannot be negative.", badRequest.Response);
}
[Theory, BitAutoData]
public async Task Run_StorageExceedsMaximum_ReturnsBadRequest(User user)
{
// Arrange
user.Premium = true;
user.MaxStorageGb = 5;
user.GatewaySubscriptionId = "sub_123";
var subscription = CreateMockSubscription("sub_123", 4);
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
// Act
var result = await _command.Run(user, 100);
// Assert
Assert.True(result.IsT1);
var badRequest = result.AsT1;
Assert.Equal("Maximum storage is 100 GB.", badRequest.Response);
}
[Theory, BitAutoData]
public async Task Run_NoMaxStorageGb_ReturnsBadRequest(User user)
{
// Arrange
user.Premium = true;
user.MaxStorageGb = null;
// Act
var result = await _command.Run(user, 5);
// Assert
Assert.True(result.IsT1);
var badRequest = result.AsT1;
Assert.Equal("No access to storage.", badRequest.Response);
}
[Theory, BitAutoData]
public async Task Run_StorageExceedsCurrentUsage_ReturnsBadRequest(User user)
{
// Arrange
user.Premium = true;
user.MaxStorageGb = 10;
user.Storage = 5L * 1024 * 1024 * 1024; // 5 GB currently used
user.GatewaySubscriptionId = "sub_123";
var subscription = CreateMockSubscription("sub_123", 9);
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
// Act
var result = await _command.Run(user, 0);
// Assert
Assert.True(result.IsT1);
var badRequest = result.AsT1;
Assert.Contains("You are currently using", badRequest.Response);
Assert.Contains("Delete some stored data first", badRequest.Response);
}
[Theory, BitAutoData]
public async Task Run_SameStorageAmount_Idempotent(User user)
{
// Arrange
user.Premium = true;
user.MaxStorageGb = 5;
user.Storage = 2L * 1024 * 1024 * 1024;
user.GatewaySubscriptionId = "sub_123";
var subscription = CreateMockSubscription("sub_123", 4);
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
// Act
var result = await _command.Run(user, 4);
// Assert
Assert.True(result.IsT0);
// Verify subscription was fetched but NOT updated
await _stripeAdapter.Received(1).GetSubscriptionAsync("sub_123");
await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
await _userService.DidNotReceive().SaveUserAsync(Arg.Any<User>());
}
[Theory, BitAutoData]
public async Task Run_IncreaseStorage_Success(User user)
{
// Arrange
user.Premium = true;
user.MaxStorageGb = 5;
user.Storage = 2L * 1024 * 1024 * 1024;
user.GatewaySubscriptionId = "sub_123";
var subscription = CreateMockSubscription("sub_123", 4);
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
// Act
var result = await _command.Run(user, 9);
// Assert
Assert.True(result.IsT0);
// Verify subscription was updated
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.Items.Count == 1 &&
opts.Items[0].Id == "si_storage" &&
opts.Items[0].Quantity == 9 &&
opts.ProrationBehavior == "create_prorations"));
// Verify user was saved
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u =>
u.Id == user.Id &&
u.MaxStorageGb == 10));
}
[Theory, BitAutoData]
public async Task Run_AddStorageFromZero_Success(User user)
{
// Arrange
user.Premium = true;
user.MaxStorageGb = 1;
user.Storage = 500L * 1024 * 1024;
user.GatewaySubscriptionId = "sub_123";
var subscription = CreateMockSubscription("sub_123", null);
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
// Act
var result = await _command.Run(user, 9);
// Assert
Assert.True(result.IsT0);
// Verify subscription was updated with new storage item
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.Items.Count == 1 &&
opts.Items[0].Price == "price_storage" &&
opts.Items[0].Quantity == 9));
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u => u.MaxStorageGb == 10));
}
[Theory, BitAutoData]
public async Task Run_DecreaseStorage_Success(User user)
{
// Arrange
user.Premium = true;
user.MaxStorageGb = 10;
user.Storage = 2L * 1024 * 1024 * 1024;
user.GatewaySubscriptionId = "sub_123";
var subscription = CreateMockSubscription("sub_123", 9);
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
// Act
var result = await _command.Run(user, 2);
// Assert
Assert.True(result.IsT0);
// Verify subscription was updated
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.Items.Count == 1 &&
opts.Items[0].Id == "si_storage" &&
opts.Items[0].Quantity == 2));
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u => u.MaxStorageGb == 3));
}
[Theory, BitAutoData]
public async Task Run_RemoveAllAdditionalStorage_Success(User user)
{
// Arrange
user.Premium = true;
user.MaxStorageGb = 10;
user.Storage = 500L * 1024 * 1024;
user.GatewaySubscriptionId = "sub_123";
var subscription = CreateMockSubscription("sub_123", 9);
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
// Act
var result = await _command.Run(user, 0);
// Assert
Assert.True(result.IsT0);
// Verify subscription item was deleted
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.Items.Count == 1 &&
opts.Items[0].Id == "si_storage" &&
opts.Items[0].Deleted == true));
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u => u.MaxStorageGb == 1));
}
[Theory, BitAutoData]
public async Task Run_MaximumStorage_Success(User user)
{
// Arrange
user.Premium = true;
user.MaxStorageGb = 5;
user.Storage = 2L * 1024 * 1024 * 1024;
user.GatewaySubscriptionId = "sub_123";
var subscription = CreateMockSubscription("sub_123", 4);
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
// Act
var result = await _command.Run(user, 99);
// Assert
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.Items[0].Quantity == 99));
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u => u.MaxStorageGb == 100));
}
}