diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index 7141f8429d..afbef321a9 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -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 = diff --git a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs index b276174814..66cb018923 100644 --- a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs +++ b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs @@ -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 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().GetByEmailAsync(email).Returns((User?)null); - sutProvider.GetDependency().GetByIdAsync(orgId).Returns(organization); - sutProvider.GetDependency().GetByOrganizationEmailAsync(orgId, email) - .Returns((OrganizationUser?)null); - - // Feature flag enabled - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) - .Returns(true); - - // Mock the RegisterSSOAutoProvisionedUserAsync to return success - sutProvider.GetDependency() - .RegisterSSOAutoProvisionedUserAsync(Arg.Any(), Arg.Any()) - .Returns(IdentityResult.Success); - - var claims = new[] - { - new Claim(JwtClaimTypes.Email, email), - new Claim(JwtClaimTypes.Name, "New User") - } as IEnumerable; - 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().Received(1) - .RegisterSSOAutoProvisionedUserAsync( - Arg.Is(u => u.Email == email && u.Name == "New User"), - Arg.Is(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 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().GetByEmailAsync(email).Returns((User?)null); - sutProvider.GetDependency().GetByIdAsync(orgId).Returns(organization); - sutProvider.GetDependency().GetByOrganizationEmailAsync(orgId, email) - .Returns((OrganizationUser?)null); - - // Feature flag disabled - sutProvider.GetDependency() - .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) - .Returns(false); - - // Mock the RegisterUser to return success - sutProvider.GetDependency() - .RegisterUser(Arg.Any()) - .Returns(IdentityResult.Success); - - var claims = new[] - { - new Claim(JwtClaimTypes.Email, email), - new Claim(JwtClaimTypes.Name, "Legacy User") - } as IEnumerable; - 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().Received(1) - .RegisterUser(Arg.Is(u => u.Email == email && u.Name == "Legacy User")); - - // Verify the new method was NOT called - await sutProvider.GetDependency().DidNotReceive() - .RegisterSSOAutoProvisionedUserAsync(Arg.Any(), Arg.Any()); - - Assert.NotNull(result.user); - Assert.Equal(email, result.user.Email); - } - [Theory, BitAutoData] public void ExternalChallenge_WithMatchingOrgId_Succeeds( SutProvider sutProvider, diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index ae1d12e887..bce0332d67 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -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] public async Task PutVNext(Guid orgId, PolicyType type, [FromBody] SavePolicyRequest model) { diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs index 7d970aef8b..489241bd55 100644 --- a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -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 UpdateStorageAsync( + [BindNever] User user, + [FromBody] StorageUpdateRequest request) + { + var result = await updatePremiumStorageCommand.Run(user, request.AdditionalStorageGb); + return Handle(result); + } } diff --git a/src/Api/Billing/Models/Requests/Storage/StorageUpdateRequest.cs b/src/Api/Billing/Models/Requests/Storage/StorageUpdateRequest.cs new file mode 100644 index 0000000000..0b18fc1e6f --- /dev/null +++ b/src/Api/Billing/Models/Requests/Storage/StorageUpdateRequest.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Billing.Models.Requests.Storage; + +/// +/// Request model for updating storage allocation on a user's premium subscription. +/// Allows for both increasing and decreasing storage in an idempotent manner. +/// +public class StorageUpdateRequest : IValidatableObject +{ + /// + /// The additional storage in GB beyond the base storage. + /// Must be between 0 and the maximum allowed (minus base storage). + /// + [Required] + [Range(0, 99)] + public short AdditionalStorageGb { get; set; } + + public IEnumerable 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) }); + } + } +} diff --git a/src/Api/Tools/Controllers/OrganizationExportController.cs b/src/Api/Tools/Controllers/OrganizationExportController.cs index dd039bc4a5..578220075a 100644 --- a/src/Api/Tools/Controllers/OrganizationExportController.cs +++ b/src/Api/Tools/Controllers/OrganizationExportController.cs @@ -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) diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 6a506cc01f..d40cb1c410 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -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 _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 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) : diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/OrganizationAbility.cs similarity index 100% rename from src/Core/AdminConsole/Models/Data/Organizations/OrganizationAbility.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/OrganizationAbility.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/README.md b/src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/README.md new file mode 100644 index 0000000000..7b92ba3fef --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationAbility/README.md @@ -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. diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs index 1a0cdc531c..3052d209c1 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -279,11 +279,6 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand /// The encrypted default user collection name. 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 confirmedOrganizationUsers, string defaultUserCollectionName) { - if (!_featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)) - { - return; - } - // Skip if no collection name provided (backwards compatibility) if (string.IsNullOrWhiteSpace(defaultUserCollectionName)) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs index 6f763b18be..104a5751ff 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs @@ -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> factories, - IFeatureService featureService) + IEnumerable> 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; diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index 905f797bb4..3d63a35406 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -53,6 +53,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddTransient(); + services.AddScoped(); } private static void AddPremiumQueries(this IServiceCollection services) diff --git a/src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs b/src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs new file mode 100644 index 0000000000..610c112e08 --- /dev/null +++ b/src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs @@ -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; + +/// +/// Updates the storage allocation for a premium user's subscription. +/// Handles both increases and decreases in storage in an idempotent manner. +/// +public interface IUpdatePremiumStorageCommand +{ + /// + /// Updates the user's storage by the specified additional amount. + /// + /// The premium user whose storage should be updated. + /// The additional storage amount in GB beyond base storage. + /// A billing command result indicating success or failure. + Task> Run(User user, short additionalStorageGb); +} + +public class UpdatePremiumStorageCommand( + IStripeAdapter stripeAdapter, + IUserService userService, + IPricingClient pricingClient, + ILogger logger) + : BaseBillingCommand(logger), IUpdatePremiumStorageCommand +{ + public Task> Run(User user, short additionalStorageGb) => HandleAsync(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(); + + 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(); + }); +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 1002d2eb74..61c8d7931f 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -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"; diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs index 352bb447c8..7d30fdcbe4 100644 --- a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs +++ b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs @@ -660,8 +660,8 @@

Always confirm you are on a trusted Bitwarden domain before logging in:
- bitwarden.com | - Learn why we include this + bitwarden.com | + Learn why we include this

diff --git a/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.html.hbs b/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.html.hbs index be1a3854b5..29977724d4 100644 --- a/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.html.hbs +++ b/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-enterprise-teams.html.hbs @@ -784,8 +784,8 @@

Always confirm you are on a trusted Bitwarden domain before logging in:
- bitwarden.com | - Learn why we include this + bitwarden.com | + Learn why we include this

diff --git a/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.html.hbs b/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.html.hbs index b9984343d5..93d4b9cd9c 100644 --- a/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.html.hbs +++ b/src/Core/MailTemplates/Handlebars/MJML/AdminConsole/OrganizationConfirmation/organization-confirmation-family-free.html.hbs @@ -952,8 +952,8 @@

Always confirm you are on a trusted Bitwarden domain before logging in:
- bitwarden.com | - Learn why we include this + bitwarden.com | + Learn why we include this

diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs index 1998cf10ba..b2b957f849 100644 --- a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs @@ -873,8 +873,8 @@

Always confirm you are on a trusted Bitwarden domain before logging in:
- bitwarden.com | - Learn why we include this + bitwarden.com | + Learn why we include this

diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.html.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.html.hbs index 2ad670383b..4cdf153c30 100644 --- a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.html.hbs +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.html.hbs @@ -872,8 +872,8 @@

Always confirm you are on a trusted Bitwarden domain before logging in:
- bitwarden.com | - Learn why we include this + bitwarden.com | + Learn why we include this

diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.html.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.html.hbs index efcacf1866..5a8dfb7374 100644 --- a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.html.hbs +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.html.hbs @@ -873,8 +873,8 @@

Always confirm you are on a trusted Bitwarden domain before logging in:
- bitwarden.com | - Learn why we include this + bitwarden.com | + Learn why we include this

diff --git a/src/Core/MailTemplates/Mjml/components/footer.mjml b/src/Core/MailTemplates/Mjml/components/footer.mjml index 4037d6c9ba..ddaf3f493b 100644 --- a/src/Core/MailTemplates/Mjml/components/footer.mjml +++ b/src/Core/MailTemplates/Mjml/components/footer.mjml @@ -45,8 +45,8 @@

Always confirm you are on a trusted Bitwarden domain before logging in:
- bitwarden.com | - Learn why we include this + bitwarden.com | + Learn why we include this

diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs index 0fef4a0cd0..343178e7a2 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs @@ -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(featureService => - { - featureService - .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) - .Returns(true); - }); _client = _factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); } diff --git a/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs b/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs index b087a0fd6d..66d1a4d3e1 100644 --- a/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/VNext/AccountBillingVNextControllerTests.cs @@ -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(); - _createPremiumCloudHostedSubscriptionCommand = Substitute.For(); - _getCreditQuery = Substitute.For(); - _getPaymentMethodQuery = Substitute.For(); + _updatePremiumStorageCommand = Substitute.For(); _getUserLicenseQuery = Substitute.For(); - _updatePaymentMethodCommand = Substitute.For(); _sut = new AccountBillingVNextController( - _createBitPayInvoiceForCreditCommand, - _createPremiumCloudHostedSubscriptionCommand, - _getCreditQuery, - _getPaymentMethodQuery, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), _getUserLicenseQuery, - _updatePaymentMethodCommand); + Substitute.For(), + _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(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(u => u.Id == user.Id), + Arg.Is(s => s == 10)) + .Returns(new BillingCommandResult(new None())); + + // Act + var result = await _sut.UpdateStorageAsync(user, request); + + // Assert + var okResult = Assert.IsAssignableFrom(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(u => u.Id == user.Id), + Arg.Is(s => s == 10)) + .Returns(new BadRequest(errorMessage)); + + // Act + var result = await _sut.UpdateStorageAsync(user, request); + + // Assert + var badRequestResult = Assert.IsAssignableFrom(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(u => u.Id == user.Id), + Arg.Is(s => s == 10)) + .Returns(new BadRequest(errorMessage)); + + // Act + var result = await _sut.UpdateStorageAsync(user, request); + + // Assert + var badRequestResult = Assert.IsAssignableFrom(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(u => u.Id == user.Id), + Arg.Is(s => s == 1)) + .Returns(new BadRequest(errorMessage)); + + // Act + var result = await _sut.UpdateStorageAsync(user, request); + + // Assert + var badRequestResult = Assert.IsAssignableFrom(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(u => u.Id == user.Id), + Arg.Is(s => s == 100)) + .Returns(new BadRequest(errorMessage)); + + // Act + var result = await _sut.UpdateStorageAsync(user, request); + + // Assert + var badRequestResult = Assert.IsAssignableFrom(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(u => u.Id == user.Id), + Arg.Is(s => s == 2)) + .Returns(new BadRequest(errorMessage)); + + // Act + var result = await _sut.UpdateStorageAsync(user, request); + + // Assert + var badRequestResult = Assert.IsAssignableFrom(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(u => u.Id == user.Id), + Arg.Is(s => s == 15)) + .Returns(new BillingCommandResult(new None())); + + // Act + var result = await _sut.UpdateStorageAsync(user, request); + + // Assert + var okResult = Assert.IsAssignableFrom(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(u => u.Id == user.Id), + Arg.Is(s => s == 3)) + .Returns(new BillingCommandResult(new None())); + + // Act + var result = await _sut.UpdateStorageAsync(user, request); + + // Assert + var okResult = Assert.IsAssignableFrom(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(u => u.Id == user.Id), + Arg.Is(s => s == 100)) + .Returns(new BillingCommandResult(new None())); + + // Act + var result = await _sut.UpdateStorageAsync(user, request); + + // Assert + var okResult = Assert.IsAssignableFrom(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(u => u.Id == user.Id), + Arg.Is(s => s == 5)) + .Returns(new BillingCommandResult(new None())); + + // Act + var result = await _sut.UpdateStorageAsync(user, request); + + // Assert + var okResult = Assert.IsAssignableFrom(result); + await _updatePremiumStorageCommand.Received(1).Run(user, 5); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs index cf0e2c71eb..cca0e1f8ba 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs @@ -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 sutProvider) @@ -474,8 +474,6 @@ public class ConfirmOrganizationUserCommandTests sutProvider.GetDependency().GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); sutProvider.GetDependency().GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - sutProvider.GetDependency().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 sutProvider) @@ -513,8 +511,6 @@ public class ConfirmOrganizationUserCommandTests sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); sutProvider.GetDependency().GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.CreateDefaultLocation).Returns(true); - await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, ""); await sutProvider.GetDependency() @@ -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 sutProvider) @@ -535,7 +531,6 @@ public class ConfirmOrganizationUserCommandTests sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); sutProvider.GetDependency().GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser }); sutProvider.GetDependency().GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.CreateDefaultLocation).Returns(true); sutProvider.GetDependency() .GetAsync(orgUser.UserId!.Value) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs index 5d6bac0ead..d7415fbdab 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs @@ -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 sutProvider) - { - // Arrange - sutProvider.GetDependency() - .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() - .DidNotReceive() - .CreateDefaultCollectionsBulkAsync(Arg.Any(), Arg.Any>(), Arg.Any()); - } - [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() - .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() - .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() - .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(); - 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 sutProvider) - { - // Arrange - sutProvider.GetDependency() - .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() - .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() - .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() - .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() - .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) - .Returns(true); - var policyRequest = new SavePolicyModel(policyUpdate, metadata); // Act diff --git a/test/Core.Test/Billing/Payment/Commands/UpdateBillingAddressCommandTests.cs b/test/Core.Test/Billing/Payment/Commands/UpdateBillingAddressCommandTests.cs index 5854d1c3b5..88728d4839 100644 --- a/test/Core.Test/Billing/Payment/Commands/UpdateBillingAddressCommandTests.cs +++ b/test/Core.Test/Billing/Payment/Commands/UpdateBillingAddressCommandTests.cs @@ -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 + { + Data = + [ + new Subscription + { + Id = organization.GatewaySubscriptionId, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false } + } + ] + }, + TaxIds = new StripeList + { + Data = [existingTaxId] + } + }; + + _stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is(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( + 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()); + }); + + await _stripeAdapter.Received(1).DeleteTaxIdAsync(customer.Id, existingTaxId.Id); + await _stripeAdapter.Received(1).CreateTaxIdAsync(customer.Id, Arg.Is( + options => options.Type == "us_ein" && options.Value == "987654321")); + } } diff --git a/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs new file mode 100644 index 0000000000..7e3ea562d6 --- /dev/null +++ b/test/Core.Test/Billing/Premium/Commands/UpdatePremiumStorageCommandTests.cs @@ -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(); + private readonly IUserService _userService = Substitute.For(); + private readonly IPricingClient _pricingClient = Substitute.For(); + 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 }); + + _command = new UpdatePremiumStorageCommand( + _stripeAdapter, + _userService, + _pricingClient, + Substitute.For>()); + } + + private Subscription CreateMockSubscription(string subscriptionId, int? storageQuantity = null) + { + var items = new List(); + + // 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 + { + 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(), Arg.Any()); + await _userService.DidNotReceive().SaveUserAsync(Arg.Any()); + } + + [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(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(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(opts => + opts.Items.Count == 1 && + opts.Items[0].Price == "price_storage" && + opts.Items[0].Quantity == 9)); + + await _userService.Received(1).SaveUserAsync(Arg.Is(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(opts => + opts.Items.Count == 1 && + opts.Items[0].Id == "si_storage" && + opts.Items[0].Quantity == 2)); + + await _userService.Received(1).SaveUserAsync(Arg.Is(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(opts => + opts.Items.Count == 1 && + opts.Items[0].Id == "si_storage" && + opts.Items[0].Deleted == true)); + + await _userService.Received(1).SaveUserAsync(Arg.Is(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(opts => + opts.Items[0].Quantity == 99)); + + await _userService.Received(1).SaveUserAsync(Arg.Is(u => u.MaxStorageGb == 100)); + } +}