mirror of
https://github.com/bitwarden/server
synced 2026-01-27 06:43:19 +00:00
Merge remote-tracking branch 'origin/main' into ac/pm-28555/add-semaphore-table
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,7 +7,6 @@ using Bit.Api.AdminConsole.Models.Request;
|
||||
using Bit.Api.AdminConsole.Models.Response.Helpers;
|
||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||
@@ -212,7 +211,6 @@ public class PoliciesController : Controller
|
||||
}
|
||||
|
||||
[HttpPut("{type}/vnext")]
|
||||
[RequireFeatureAttribute(FeatureFlagKeys.CreateDefaultLocation)]
|
||||
[Authorize<ManagePoliciesRequirement>]
|
||||
public async Task<PolicyResponseModel> PutVNext(Guid orgId, PolicyType type, [FromBody] SavePolicyRequest model)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using Bit.Api.Tools.Authorization;
|
||||
using Bit.Api.Tools.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
@@ -21,7 +20,6 @@ public class OrganizationExportController : Controller
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IOrganizationCiphersQuery _organizationCiphersQuery;
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public OrganizationExportController(
|
||||
IUserService userService,
|
||||
@@ -36,7 +34,6 @@ public class OrganizationExportController : Controller
|
||||
_authorizationService = authorizationService;
|
||||
_organizationCiphersQuery = organizationCiphersQuery;
|
||||
_collectionRepository = collectionRepository;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
[HttpGet("export")]
|
||||
@@ -46,33 +43,20 @@ public class OrganizationExportController : Controller
|
||||
VaultExportOperations.ExportWholeVault);
|
||||
var canExportManaged = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(organizationId),
|
||||
VaultExportOperations.ExportManagedCollections);
|
||||
var createDefaultLocationEnabled = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation);
|
||||
|
||||
if (canExportAll.Succeeded)
|
||||
{
|
||||
if (createDefaultLocationEnabled)
|
||||
{
|
||||
var allOrganizationCiphers =
|
||||
await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections(
|
||||
organizationId);
|
||||
var allOrganizationCiphers =
|
||||
await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections(
|
||||
organizationId);
|
||||
|
||||
var allCollections = await _collectionRepository
|
||||
.GetManySharedCollectionsByOrganizationIdAsync(
|
||||
organizationId);
|
||||
var allCollections = await _collectionRepository
|
||||
.GetManySharedCollectionsByOrganizationIdAsync(
|
||||
organizationId);
|
||||
|
||||
|
||||
return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections,
|
||||
_globalSettings));
|
||||
}
|
||||
else
|
||||
{
|
||||
var allOrganizationCiphers = await _organizationCiphersQuery.GetAllOrganizationCiphers(organizationId);
|
||||
|
||||
var allCollections = await _collectionRepository.GetManyByOrganizationIdAsync(organizationId);
|
||||
|
||||
return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections,
|
||||
_globalSettings));
|
||||
}
|
||||
return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections,
|
||||
_globalSettings));
|
||||
}
|
||||
|
||||
if (canExportManaged.Succeeded)
|
||||
|
||||
@@ -10,7 +10,6 @@ using Bit.Api.Utilities;
|
||||
using Bit.Api.Vault.Models.Request;
|
||||
using Bit.Api.Vault.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@@ -43,7 +42,6 @@ public class CiphersController : Controller
|
||||
private readonly ICipherService _cipherService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IAttachmentStorageService _attachmentStorageService;
|
||||
private readonly IProviderService _providerService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly ILogger<CiphersController> _logger;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
@@ -52,7 +50,6 @@ public class CiphersController : Controller
|
||||
private readonly ICollectionRepository _collectionRepository;
|
||||
private readonly IArchiveCiphersCommand _archiveCiphersCommand;
|
||||
private readonly IUnarchiveCiphersCommand _unarchiveCiphersCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public CiphersController(
|
||||
ICipherRepository cipherRepository,
|
||||
@@ -60,7 +57,6 @@ public class CiphersController : Controller
|
||||
ICipherService cipherService,
|
||||
IUserService userService,
|
||||
IAttachmentStorageService attachmentStorageService,
|
||||
IProviderService providerService,
|
||||
ICurrentContext currentContext,
|
||||
ILogger<CiphersController> logger,
|
||||
GlobalSettings globalSettings,
|
||||
@@ -68,15 +64,13 @@ public class CiphersController : Controller
|
||||
IApplicationCacheService applicationCacheService,
|
||||
ICollectionRepository collectionRepository,
|
||||
IArchiveCiphersCommand archiveCiphersCommand,
|
||||
IUnarchiveCiphersCommand unarchiveCiphersCommand,
|
||||
IFeatureService featureService)
|
||||
IUnarchiveCiphersCommand unarchiveCiphersCommand)
|
||||
{
|
||||
_cipherRepository = cipherRepository;
|
||||
_collectionCipherRepository = collectionCipherRepository;
|
||||
_cipherService = cipherService;
|
||||
_userService = userService;
|
||||
_attachmentStorageService = attachmentStorageService;
|
||||
_providerService = providerService;
|
||||
_currentContext = currentContext;
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings;
|
||||
@@ -85,7 +79,6 @@ public class CiphersController : Controller
|
||||
_collectionRepository = collectionRepository;
|
||||
_archiveCiphersCommand = archiveCiphersCommand;
|
||||
_unarchiveCiphersCommand = unarchiveCiphersCommand;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@@ -344,8 +337,7 @@ public class CiphersController : Controller
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
bool excludeDefaultUserCollections = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation) && !includeMemberItems;
|
||||
var allOrganizationCiphers = excludeDefaultUserCollections
|
||||
var allOrganizationCiphers = !includeMemberItems
|
||||
?
|
||||
await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections(organizationId)
|
||||
:
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# Organization Ability Flags
|
||||
|
||||
## Overview
|
||||
|
||||
Many Bitwarden features are tied to specific subscription plans. For example, SCIM and SSO are Enterprise features,
|
||||
while Event Logs are available to Teams and Enterprise plans. When developing features that require plan-based access
|
||||
control, we use **Organization Ability Flags** (or simply _abilities_) — explicit boolean properties on the Organization
|
||||
entity that indicate whether an organization can use a specific feature.
|
||||
|
||||
## The Rule
|
||||
|
||||
**Never check plan types to control feature access.** Always use a dedicated ability flag on the Organization entity.
|
||||
|
||||
### ❌ Don't Do This
|
||||
|
||||
```csharp
|
||||
// Checking plan type directly
|
||||
if (organization.PlanType == PlanType.Enterprise ||
|
||||
organization.PlanType == PlanType.Teams ||
|
||||
organization.PlanType == PlanType.Family)
|
||||
{
|
||||
// allow feature...
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Don't Do This
|
||||
|
||||
```csharp
|
||||
// Piggybacking off another feature's ability
|
||||
if (organization.PlanType == PlanType.Enterprise && organization.UseEvents)
|
||||
{
|
||||
// assume they can use some other feature...
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Do This Instead
|
||||
|
||||
```csharp
|
||||
// Check the explicit ability flag
|
||||
if (organization.UseEvents)
|
||||
{
|
||||
// allow UseEvents feature...
|
||||
}
|
||||
```
|
||||
|
||||
## Why This Pattern Matters
|
||||
|
||||
Using explicit ability flags instead of plan type checks provides several benefits:
|
||||
|
||||
1. **Simplicity** — A single boolean check is cleaner and less error-prone than maintaining lists of plan types.
|
||||
|
||||
2. **Centralized Control** — Feature access is managed in one place: the ability assignment during organization
|
||||
creation/upgrade. No need to hunt through the codebase for scattered plan type checks.
|
||||
|
||||
3. **Flexibility** — Abilities can be set independently of plan type, enabling:
|
||||
|
||||
- Early access programs for features not yet tied to a plan
|
||||
- Trial access to help customers evaluate a feature before upgrading
|
||||
- Custom arrangements for specific customers
|
||||
- A/B testing of features across different cohorts
|
||||
|
||||
4. **Safe Refactoring** — When plans change (e.g., adding a new plan tier, renaming plans, or moving features between
|
||||
tiers), we only update the ability assignment logic—not every place the feature is used.
|
||||
|
||||
5. **Graceful Downgrades** — When an organization downgrades, we update their abilities. All feature checks
|
||||
automatically respect the new access level.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Ability Assignment at Signup/Upgrade
|
||||
|
||||
When an organization is created or changes plans, the ability flags are set based on the plan's capabilities:
|
||||
|
||||
```csharp
|
||||
// During organization creation or plan change
|
||||
organization.UseGroups = plan.HasGroups;
|
||||
organization.UseSso = plan.HasSso;
|
||||
organization.UseScim = plan.HasScim;
|
||||
organization.UsePolicies = plan.HasPolicies;
|
||||
organization.UseEvents = plan.HasEvents;
|
||||
// ... etc
|
||||
```
|
||||
|
||||
### Modifying Abilities for Existing Organizations
|
||||
|
||||
To change abilities for existing organizations (e.g., rolling out a feature to a new plan tier), create a database
|
||||
migration that updates the relevant flag:
|
||||
|
||||
```sql
|
||||
-- Example: Enable UseEvents for all Teams organizations
|
||||
UPDATE [dbo].[Organization]
|
||||
SET UseEvents = 1
|
||||
WHERE PlanType IN (17, 18) -- TeamsMonthly = 17, TeamsAnnually = 18
|
||||
```
|
||||
|
||||
Then update the plan-to-ability assignment code so new organizations get the correct value.
|
||||
|
||||
## Adding a New Ability
|
||||
|
||||
When developing a new plan-gated feature:
|
||||
|
||||
1. **Add the ability to the Organization and OrganizationAbility entities** — Create a `Use[FeatureName]` boolean
|
||||
property.
|
||||
|
||||
2. **Add a database migration** — Add the new column to the Organization table.
|
||||
|
||||
3. **Update plan definitions** — Add a corresponding `Has[FeatureName]` property to the Plan model and configure which
|
||||
plans include it.
|
||||
|
||||
4. **Update organization creation/upgrade logic** — Ensure the ability is set based on the plan.
|
||||
|
||||
5. **Update the organization license claims** (if applicable) - to make the feature available on self-hosted instances.
|
||||
|
||||
6. **Implement checks throughout client and server** — Use the ability consistently everywhere the feature is accessed.
|
||||
- Clients: get the organization object from `OrganizationService`.
|
||||
- Server: if you already have the full `Organization` object in scope, you can use it directly. If not, use the
|
||||
`IApplicationCacheService` to retrieve the `OrganizationAbility`, which is a simplified, cached representation
|
||||
of the organization ability flags. Note that some older flags may be missing from `OrganizationAbility` but
|
||||
can be added if needed.
|
||||
|
||||
## Existing Abilities
|
||||
|
||||
For reference, here are some current organization ability flags (not a complete list):
|
||||
|
||||
| Ability | Description | Plans |
|
||||
|--------------------------|-------------------------------|-------------------|
|
||||
| `UseGroups` | Group-based collection access | Teams, Enterprise |
|
||||
| `UseDirectory` | Directory Connector sync | Teams, Enterprise |
|
||||
| `UseEvents` | Event logging | Teams, Enterprise |
|
||||
| `UseTotp` | Authenticator (TOTP) | Teams, Enterprise |
|
||||
| `UseSso` | Single Sign-On | Enterprise |
|
||||
| `UseScim` | SCIM provisioning | Teams, Enterprise |
|
||||
| `UsePolicies` | Enterprise policies | Enterprise |
|
||||
| `UseResetPassword` | Admin password reset | Enterprise |
|
||||
| `UseOrganizationDomains` | Domain verification/claiming | Enterprise |
|
||||
|
||||
## Questions?
|
||||
|
||||
If you're unsure whether your feature needs a new ability or which existing ability to use, reach out to your team lead
|
||||
or members of the Admin Console or Architecture teams. When in doubt, adding an explicit ability is almost always the
|
||||
right choice—it's easy to do and keeps our access control clean and maintainable.
|
||||
@@ -279,11 +279,6 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
/// <param name="defaultUserCollectionName">The encrypted default user collection name.</param>
|
||||
private async Task CreateDefaultCollectionAsync(OrganizationUser organizationUser, string defaultUserCollectionName)
|
||||
{
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if no collection name provided (backwards compatibility)
|
||||
if (string.IsNullOrWhiteSpace(defaultUserCollectionName))
|
||||
{
|
||||
@@ -311,11 +306,6 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
|
||||
private async Task CreateManyDefaultCollectionsAsync(Guid organizationId,
|
||||
IEnumerable<OrganizationUser> confirmedOrganizationUsers, string defaultUserCollectionName)
|
||||
{
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if no collection name provided (backwards compatibility)
|
||||
if (string.IsNullOrWhiteSpace(defaultUserCollectionName))
|
||||
{
|
||||
|
||||
@@ -6,15 +6,13 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
|
||||
|
||||
public class OrganizationDataOwnershipPolicyValidator(
|
||||
IPolicyRepository policyRepository,
|
||||
ICollectionRepository collectionRepository,
|
||||
IEnumerable<IPolicyRequirementFactory<IPolicyRequirement>> factories,
|
||||
IFeatureService featureService)
|
||||
IEnumerable<IPolicyRequirementFactory<IPolicyRequirement>> factories)
|
||||
: OrganizationPolicyValidator(policyRepository, factories), IPostSavePolicySideEffect, IOnPolicyPostUpdateEvent
|
||||
{
|
||||
public PolicyType Type => PolicyType.OrganizationDataOwnership;
|
||||
@@ -32,11 +30,6 @@ public class OrganizationDataOwnershipPolicyValidator(
|
||||
Policy postUpdatedPolicy,
|
||||
Policy? previousPolicyState)
|
||||
{
|
||||
if (!featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (policyRequest.Metadata is not OrganizationModelOwnershipPolicyModel metadata)
|
||||
{
|
||||
return;
|
||||
|
||||
@@ -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)
|
||||
|
||||
144
src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs
Normal file
144
src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs
Normal 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();
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -660,8 +660,8 @@
|
||||
<p style="margin-top: 5px">
|
||||
Always confirm you are on a trusted Bitwarden domain before logging
|
||||
in:<br>
|
||||
<a href="https://bitwarden.com/">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
|
||||
<a href="https://bitwarden.com/" style="text-decoration:none;color:#175ddc; font-weight:400">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/" style="text-decoration:none; color:#175ddc; font-weight:400">Learn why we include this</a>
|
||||
</p></div>
|
||||
|
||||
</td>
|
||||
|
||||
@@ -784,8 +784,8 @@
|
||||
<p style="margin-top: 5px">
|
||||
Always confirm you are on a trusted Bitwarden domain before logging
|
||||
in:<br>
|
||||
<a href="https://bitwarden.com/">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
|
||||
<a href="https://bitwarden.com/" style="text-decoration:none;color:#175ddc; font-weight:400">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/" style="text-decoration:none; color:#175ddc; font-weight:400">Learn why we include this</a>
|
||||
</p></div>
|
||||
|
||||
</td>
|
||||
|
||||
@@ -952,8 +952,8 @@
|
||||
<p style="margin-top: 5px">
|
||||
Always confirm you are on a trusted Bitwarden domain before logging
|
||||
in:<br>
|
||||
<a href="https://bitwarden.com/">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
|
||||
<a href="https://bitwarden.com/" style="text-decoration:none;color:#175ddc; font-weight:400">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/" style="text-decoration:none; color:#175ddc; font-weight:400">Learn why we include this</a>
|
||||
</p></div>
|
||||
|
||||
</td>
|
||||
|
||||
@@ -873,8 +873,8 @@
|
||||
<p style="margin-top: 5px">
|
||||
Always confirm you are on a trusted Bitwarden domain before logging
|
||||
in:<br>
|
||||
<a href="https://bitwarden.com/">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
|
||||
<a href="https://bitwarden.com/" style="text-decoration:none;color:#175ddc; font-weight:400">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/" style="text-decoration:none; color:#175ddc; font-weight:400">Learn why we include this</a>
|
||||
</p></div>
|
||||
|
||||
</td>
|
||||
|
||||
@@ -872,8 +872,8 @@
|
||||
<p style="margin-top: 5px">
|
||||
Always confirm you are on a trusted Bitwarden domain before logging
|
||||
in:<br>
|
||||
<a href="https://bitwarden.com/">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
|
||||
<a href="https://bitwarden.com/" style="text-decoration:none;color:#175ddc; font-weight:400">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/" style="text-decoration:none; color:#175ddc; font-weight:400">Learn why we include this</a>
|
||||
</p></div>
|
||||
|
||||
</td>
|
||||
|
||||
@@ -873,8 +873,8 @@
|
||||
<p style="margin-top: 5px">
|
||||
Always confirm you are on a trusted Bitwarden domain before logging
|
||||
in:<br>
|
||||
<a href="https://bitwarden.com/">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
|
||||
<a href="https://bitwarden.com/" style="text-decoration:none;color:#175ddc; font-weight:400">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/" style="text-decoration:none; color:#175ddc; font-weight:400">Learn why we include this</a>
|
||||
</p></div>
|
||||
|
||||
</td>
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
<p style="margin-top: 5px">
|
||||
Always confirm you are on a trusted Bitwarden domain before logging
|
||||
in:<br />
|
||||
<a href="https://bitwarden.com/">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
|
||||
<a href="https://bitwarden.com/" style="text-decoration:none;color:#175ddc; font-weight:400">bitwarden.com</a> |
|
||||
<a href="https://bitwarden.com/help/emails-from-bitwarden/" style="text-decoration:none; color:#175ddc; font-weight:400">Learn why we include this</a>
|
||||
</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
|
||||
@@ -5,7 +5,6 @@ using Bit.Api.IntegrationTest.Factories;
|
||||
using Bit.Api.IntegrationTest.Helpers;
|
||||
using Bit.Api.Models.Request;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
@@ -14,8 +13,6 @@ using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;
|
||||
@@ -28,12 +25,6 @@ public class OrganizationUserControllerTests : IClassFixture<ApiApplicationFacto
|
||||
public OrganizationUserControllerTests(ApiApplicationFactory apiFactory)
|
||||
{
|
||||
_factory = apiFactory;
|
||||
_factory.SubstituteService<IFeatureService>(featureService =>
|
||||
{
|
||||
featureService
|
||||
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
|
||||
.Returns(true);
|
||||
});
|
||||
_client = _factory.CreateClient();
|
||||
_loginHelper = new LoginHelper(_factory, _client);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,7 +461,7 @@ public class ConfirmOrganizationUserCommandTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ConfirmUserAsync_WithCreateDefaultLocationEnabled_WithOrganizationDataOwnershipPolicyApplicable_WithValidCollectionName_CreatesDefaultCollection(
|
||||
public async Task ConfirmUserAsync_WithOrganizationDataOwnershipPolicyApplicable_WithValidCollectionName_CreatesDefaultCollection(
|
||||
Organization organization, OrganizationUser confirmingUser,
|
||||
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
|
||||
string key, string collectionName, SutProvider<ConfirmOrganizationUserCommand> sutProvider)
|
||||
@@ -474,8 +474,6 @@ public class ConfirmOrganizationUserCommandTests
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
|
||||
sutProvider.GetDependency<IUserRepository>().GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.CreateDefaultLocation).Returns(true);
|
||||
|
||||
var policyDetails = new PolicyDetails
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
@@ -500,7 +498,7 @@ public class ConfirmOrganizationUserCommandTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ConfirmUserAsync_WithCreateDefaultLocationEnabled_WithOrganizationDataOwnershipPolicyApplicable_WithInvalidCollectionName_DoesNotCreateDefaultCollection(
|
||||
public async Task ConfirmUserAsync_WithOrganizationDataOwnershipPolicyApplicable_WithInvalidCollectionName_DoesNotCreateDefaultCollection(
|
||||
Organization org, OrganizationUser confirmingUser,
|
||||
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
|
||||
string key, SutProvider<ConfirmOrganizationUserCommand> sutProvider)
|
||||
@@ -513,8 +511,6 @@ public class ConfirmOrganizationUserCommandTests
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(org.Id).Returns(org);
|
||||
sutProvider.GetDependency<IUserRepository>().GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.CreateDefaultLocation).Returns(true);
|
||||
|
||||
await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, "");
|
||||
|
||||
await sutProvider.GetDependency<ICollectionRepository>()
|
||||
@@ -523,7 +519,7 @@ public class ConfirmOrganizationUserCommandTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ConfirmUserAsync_WithCreateDefaultLocationEnabled_WithOrganizationDataOwnershipPolicyNotApplicable_DoesNotCreateDefaultCollection(
|
||||
public async Task ConfirmUserAsync_WithOrganizationDataOwnershipPolicyNotApplicable_DoesNotCreateDefaultCollection(
|
||||
Organization org, OrganizationUser confirmingUser,
|
||||
[OrganizationUser(OrganizationUserStatusType.Accepted, OrganizationUserType.Owner)] OrganizationUser orgUser, User user,
|
||||
string key, string collectionName, SutProvider<ConfirmOrganizationUserCommand> sutProvider)
|
||||
@@ -535,7 +531,6 @@ public class ConfirmOrganizationUserCommandTests
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(org.Id).Returns(org);
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
|
||||
sutProvider.GetDependency<IUserRepository>().GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.CreateDefaultLocation).Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
||||
.GetAsync<OrganizationDataOwnershipPolicyRequirement>(orgUser.UserId!.Value)
|
||||
|
||||
@@ -6,7 +6,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Test.AdminConsole.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
@@ -20,29 +19,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
{
|
||||
private const string _defaultUserCollectionName = "Default";
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ExecuteSideEffectsAsync_FeatureFlagDisabled_DoesNothing(
|
||||
[PolicyUpdate(PolicyType.OrganizationDataOwnership, false)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.OrganizationDataOwnership, false)] Policy postUpdatedPolicy,
|
||||
[Policy(PolicyType.OrganizationDataOwnership, false)] Policy previousPolicyState,
|
||||
SutProvider<OrganizationDataOwnershipPolicyValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
|
||||
.Returns(false);
|
||||
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<ICollectionRepository>()
|
||||
.DidNotReceive()
|
||||
.CreateDefaultCollectionsBulkAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ExecuteSideEffectsAsync_PolicyAlreadyEnabled_DoesNothing(
|
||||
[PolicyUpdate(PolicyType.OrganizationDataOwnership, true)] PolicyUpdate policyUpdate,
|
||||
@@ -54,10 +30,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
previousPolicyState.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
|
||||
.Returns(true);
|
||||
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
|
||||
|
||||
// Act
|
||||
@@ -80,10 +52,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
previousPolicyState.OrganizationId = policyUpdate.OrganizationId;
|
||||
postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
|
||||
.Returns(true);
|
||||
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
|
||||
|
||||
// Act
|
||||
@@ -302,10 +270,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
previousPolicyState.OrganizationId = policyUpdate.OrganizationId;
|
||||
policyUpdate.Enabled = true;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
|
||||
.Returns(true);
|
||||
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, metadata);
|
||||
|
||||
// Act
|
||||
@@ -332,39 +296,10 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
IPolicyRepository policyRepository,
|
||||
ICollectionRepository collectionRepository)
|
||||
{
|
||||
|
||||
var featureService = Substitute.For<IFeatureService>();
|
||||
featureService
|
||||
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
|
||||
.Returns(true);
|
||||
|
||||
var sut = new OrganizationDataOwnershipPolicyValidator(policyRepository, collectionRepository, [factory], featureService);
|
||||
var sut = new OrganizationDataOwnershipPolicyValidator(policyRepository, collectionRepository, [factory]);
|
||||
return sut;
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ExecutePostUpsertSideEffectAsync_FeatureFlagDisabled_DoesNothing(
|
||||
[PolicyUpdate(PolicyType.OrganizationDataOwnership, false)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.OrganizationDataOwnership, false)] Policy postUpdatedPolicy,
|
||||
[Policy(PolicyType.OrganizationDataOwnership, false)] Policy previousPolicyState,
|
||||
SutProvider<OrganizationDataOwnershipPolicyValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
|
||||
.Returns(false);
|
||||
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<ICollectionRepository>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.CreateDefaultCollectionsBulkAsync(default, default, default);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ExecutePostUpsertSideEffectAsync_PolicyAlreadyEnabled_DoesNothing(
|
||||
[PolicyUpdate(PolicyType.OrganizationDataOwnership, true)] PolicyUpdate policyUpdate,
|
||||
@@ -376,10 +311,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
previousPolicyState.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
|
||||
.Returns(true);
|
||||
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
|
||||
|
||||
// Act
|
||||
@@ -402,10 +333,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
previousPolicyState.OrganizationId = policyUpdate.OrganizationId;
|
||||
postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
|
||||
.Returns(true);
|
||||
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
|
||||
|
||||
// Act
|
||||
@@ -500,10 +427,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
previousPolicyState.OrganizationId = policyUpdate.OrganizationId;
|
||||
policyUpdate.Enabled = true;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
|
||||
.Returns(true);
|
||||
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, metadata);
|
||||
|
||||
// Act
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user