Merge branch 'main' into billing/PM-31140/consolidate-unpaid-subscription-handling

Resolved conflict in SubscriptionUpdatedHandlerTests.cs by using
Stripe.Plan explicit type qualification from main.
This commit is contained in:
Alex Morask 2026-02-03 13:02:16 -06:00
commit 04104b3d46
No known key found for this signature in database
GPG Key ID: 23E38285B743E3A8
148 changed files with 6318 additions and 1465 deletions

4
.github/CODEOWNERS vendored
View File

@ -97,9 +97,7 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev
.github/workflows/test-database.yml @bitwarden/team-platform-dev
.github/workflows/test.yml @bitwarden/team-platform-dev
**/*Platform* @bitwarden/team-platform-dev
**/.dockerignore @bitwarden/team-platform-dev
**/Dockerfile @bitwarden/team-platform-dev
**/entrypoint.sh @bitwarden/team-platform-dev
# The PushType enum is expected to be editted by anyone without need for Platform review
src/Core/Platform/Push/PushType.cs

View File

@ -263,7 +263,7 @@ jobs:
- name: Scan Docker image
id: container-scan
uses: anchore/scan-action@62b74fb7bb810d2c45b1865f47a77655621862a5 # v7.2.3
uses: anchore/scan-action@0d444ed77d83ee2ba7f5ced0d90d640a1281d762 # v7.3.0
with:
image: ${{ steps.image-tags.outputs.primary_tag }}
fail-build: false

View File

@ -13,6 +13,10 @@
<TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<BitIncludeAuthentication>false</BitIncludeAuthentication>
<BitIncludeFeatures>false</BitIncludeFeatures>
</PropertyGroup>
<PropertyGroup>

View File

@ -1,4 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Sdk Name="Bitwarden.Server.Sdk" />
<PropertyGroup>
<UserSecretsId>bitwarden-Scim</UserSecretsId>

View File

@ -2,7 +2,7 @@
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
@ -45,7 +45,7 @@ public class AccountController : Controller
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly ISsoUserRepository _ssoUserRepository;
private readonly IUserRepository _userRepository;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly IUserService _userService;
private readonly II18nService _i18nService;
private readonly UserManager<User> _userManager;
@ -67,7 +67,7 @@ public class AccountController : Controller
ISsoConfigRepository ssoConfigRepository,
ISsoUserRepository ssoUserRepository,
IUserRepository userRepository,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
IUserService userService,
II18nService i18nService,
UserManager<User> userManager,
@ -88,7 +88,7 @@ public class AccountController : Controller
_userRepository = userRepository;
_ssoConfigRepository = ssoConfigRepository;
_ssoUserRepository = ssoUserRepository;
_policyRepository = policyRepository;
_policyQuery = policyQuery;
_userService = userService;
_i18nService = i18nService;
_userManager = userManager;
@ -687,9 +687,8 @@ public class AccountController : Controller
await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization);
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
var twoFactorPolicy =
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.TwoFactorAuthentication);
if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
var twoFactorPolicy = await _policyQuery.RunAsync(organization.Id, PolicyType.TwoFactorAuthentication);
if (twoFactorPolicy.Enabled)
{
newUser.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
{

View File

@ -1,4 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Sdk Name="Bitwarden.Server.Sdk" />
<PropertyGroup>
<UserSecretsId>bitwarden-Sso</UserSecretsId>

View File

@ -8,7 +8,6 @@ using Bit.Core.Utilities;
using Bit.SharedWeb.Utilities;
using Bit.Sso.Utilities;
using Duende.IdentityServer.Services;
using Microsoft.IdentityModel.Logging;
using Stripe;
namespace Bit.Sso;
@ -91,20 +90,15 @@ public class Startup
public void Configure(
IApplicationBuilder app,
IWebHostEnvironment env,
IWebHostEnvironment environment,
IHostApplicationLifetime appLifetime,
GlobalSettings globalSettings,
ILogger<Startup> logger)
{
if (env.IsDevelopment() || globalSettings.SelfHosted)
{
IdentityModelEventSource.ShowPII = true;
}
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();
if (!env.IsDevelopment())
if (!environment.IsDevelopment())
{
var uri = new Uri(globalSettings.BaseServiceUri.Sso);
app.Use(async (ctx, next) =>
@ -120,7 +114,7 @@ public class Startup
app.UseForwardedHeaders(globalSettings);
}
if (env.IsDevelopment())
if (environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseCookiePolicy();

View File

@ -28,6 +28,7 @@ $projects = @{
Scim = "../bitwarden_license/src/Scim"
IntegrationTests = "../test/Infrastructure.IntegrationTest"
SeederApi = "../util/SeederApi"
SeederUtility = "../util/DbSeederUtility"
}
foreach ($key in $projects.keys) {

View File

@ -6,6 +6,6 @@
"msbuild-sdks": {
"Microsoft.Build.Traversal": "4.1.0",
"Microsoft.Build.Sql": "1.0.0",
"Bitwarden.Server.Sdk": "1.2.0"
"Bitwarden.Server.Sdk": "1.4.0"
}
}

View File

@ -1,4 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Sdk Name="Bitwarden.Server.Sdk" />
<PropertyGroup>
<UserSecretsId>bitwarden-Admin</UserSecretsId>

View File

@ -86,7 +86,7 @@ public class UsersController : Controller
return RedirectToAction("Index");
}
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id);
var ciphers = await _cipherRepository.GetManyByUserIdAsync(id, withOrganizations: false);
var isTwoFactorEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user);
var verifiedDomain = await _userService.IsClaimedByAnyOrganizationAsync(user.Id);

View File

@ -8,7 +8,7 @@ public class Program
{
Host
.CreateDefaultBuilder(args)
.ConfigureCustomAppConfiguration(args)
.UseBitwardenSdk()
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.ConfigureKestrel(o =>

View File

@ -57,7 +57,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
private readonly ICollectionRepository _collectionRepository;
private readonly IGroupRepository _groupRepository;
private readonly IUserService _userService;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly ICurrentContext _currentContext;
private readonly ICountNewSmSeatsRequiredQuery _countNewSmSeatsRequiredQuery;
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
@ -90,7 +90,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
ICollectionRepository collectionRepository,
IGroupRepository groupRepository,
IUserService userService,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
ICurrentContext currentContext,
ICountNewSmSeatsRequiredQuery countNewSmSeatsRequiredQuery,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
@ -123,7 +123,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
_collectionRepository = collectionRepository;
_groupRepository = groupRepository;
_userService = userService;
_policyRepository = policyRepository;
_policyQuery = policyQuery;
_currentContext = currentContext;
_countNewSmSeatsRequiredQuery = countNewSmSeatsRequiredQuery;
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
@ -350,10 +350,9 @@ public class OrganizationUsersController : BaseAdminConsoleController
return false;
}
var masterPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
var useMasterPasswordPolicy = masterPasswordPolicy != null &&
masterPasswordPolicy.Enabled &&
masterPasswordPolicy.GetDataModel<ResetPasswordDataModel>().AutoEnrollEnabled;
var masterPasswordPolicy = await _policyQuery.RunAsync(orgId, PolicyType.ResetPassword);
var useMasterPasswordPolicy = masterPasswordPolicy.Enabled &&
masterPasswordPolicy.GetDataModel<ResetPasswordDataModel>().AutoEnrollEnabled;
return useMasterPasswordPolicy;
}

View File

@ -48,7 +48,7 @@ public class OrganizationsController : Controller
{
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly IOrganizationService _organizationService;
private readonly IUserService _userService;
private readonly ICurrentContext _currentContext;
@ -74,7 +74,7 @@ public class OrganizationsController : Controller
public OrganizationsController(
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
IOrganizationService organizationService,
IUserService userService,
ICurrentContext currentContext,
@ -99,7 +99,7 @@ public class OrganizationsController : Controller
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
_policyRepository = policyRepository;
_policyQuery = policyQuery;
_organizationService = organizationService;
_userService = userService;
_currentContext = currentContext;
@ -183,15 +183,14 @@ public class OrganizationsController : Controller
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, resetPasswordPolicyRequirement.AutoEnrollEnabled(organization.Id));
}
var resetPasswordPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled || resetPasswordPolicy.Data == null)
var resetPasswordPolicy = await _policyQuery.RunAsync(organization.Id, PolicyType.ResetPassword);
if (!resetPasswordPolicy.Enabled || resetPasswordPolicy.Data == null)
{
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, false);
}
var data = JsonSerializer.Deserialize<ResetPasswordDataModel>(resetPasswordPolicy.Data, JsonHelpers.IgnoreCase);
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false);
}
[HttpPost("")]

View File

@ -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.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
@ -43,6 +42,7 @@ public class PoliciesController : Controller
private readonly IUserService _userService;
private readonly ISavePolicyCommand _savePolicyCommand;
private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand;
private readonly IPolicyQuery _policyQuery;
public PoliciesController(IPolicyRepository policyRepository,
IOrganizationUserRepository organizationUserRepository,
@ -54,7 +54,8 @@ public class PoliciesController : Controller
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
IOrganizationRepository organizationRepository,
ISavePolicyCommand savePolicyCommand,
IVNextSavePolicyCommand vNextSavePolicyCommand)
IVNextSavePolicyCommand vNextSavePolicyCommand,
IPolicyQuery policyQuery)
{
_policyRepository = policyRepository;
_organizationUserRepository = organizationUserRepository;
@ -68,27 +69,24 @@ public class PoliciesController : Controller
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
_savePolicyCommand = savePolicyCommand;
_vNextSavePolicyCommand = vNextSavePolicyCommand;
_policyQuery = policyQuery;
}
[HttpGet("{type}")]
public async Task<PolicyDetailResponseModel> Get(Guid orgId, int type)
public async Task<PolicyStatusResponseModel> Get(Guid orgId, PolicyType type)
{
if (!await _currentContext.ManagePolicies(orgId))
{
throw new NotFoundException();
}
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type);
if (policy == null)
{
return new PolicyDetailResponseModel(new Policy { Type = (PolicyType)type });
}
var policy = await _policyQuery.RunAsync(orgId, type);
if (policy.Type is PolicyType.SingleOrg)
{
return await policy.GetSingleOrgPolicyDetailResponseAsync(_organizationHasVerifiedDomainsQuery);
return await policy.GetSingleOrgPolicyStatusResponseAsync(_organizationHasVerifiedDomainsQuery);
}
return new PolicyDetailResponseModel(policy);
return new PolicyStatusResponseModel(policy);
}
[HttpGet("")]

View File

@ -1,19 +1,21 @@
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
namespace Bit.Api.AdminConsole.Models.Response.Helpers;
public static class PolicyDetailResponses
public static class PolicyStatusResponses
{
public static async Task<PolicyDetailResponseModel> GetSingleOrgPolicyDetailResponseAsync(this Policy policy, IOrganizationHasVerifiedDomainsQuery hasVerifiedDomainsQuery)
public static async Task<PolicyStatusResponseModel> GetSingleOrgPolicyStatusResponseAsync(
this PolicyStatus policy, IOrganizationHasVerifiedDomainsQuery hasVerifiedDomainsQuery)
{
if (policy.Type is not PolicyType.SingleOrg)
{
throw new ArgumentException($"'{nameof(policy)}' must be of type '{nameof(PolicyType.SingleOrg)}'.", nameof(policy));
}
return new PolicyDetailResponseModel(policy, await CanToggleState());
return new PolicyStatusResponseModel(policy, await CanToggleState());
async Task<bool> CanToggleState()
{
@ -25,5 +27,4 @@ public static class PolicyDetailResponses
return !policy.Enabled;
}
}
}

View File

@ -1,20 +0,0 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class PolicyDetailResponseModel : PolicyResponseModel
{
public PolicyDetailResponseModel(Policy policy, string obj = "policy") : base(policy, obj)
{
}
public PolicyDetailResponseModel(Policy policy, bool canToggleState) : base(policy)
{
CanToggleState = canToggleState;
}
/// <summary>
/// Indicates whether the Policy can be enabled/disabled
/// </summary>
public bool CanToggleState { get; set; } = true;
}

View File

@ -0,0 +1,33 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.Models.Api;
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class PolicyStatusResponseModel : ResponseModel
{
public PolicyStatusResponseModel(PolicyStatus policy, bool canToggleState = true) : base("policy")
{
OrganizationId = policy.OrganizationId;
Type = policy.Type;
if (!string.IsNullOrWhiteSpace(policy.Data))
{
Data = JsonSerializer.Deserialize<Dictionary<string, object>>(policy.Data) ?? new();
}
Enabled = policy.Enabled;
CanToggleState = canToggleState;
}
public Guid OrganizationId { get; init; }
public PolicyType Type { get; init; }
public Dictionary<string, object> Data { get; init; } = new();
public bool Enabled { get; init; }
/// <summary>
/// Indicates whether the Policy can be enabled/disabled
/// </summary>
public bool CanToggleState { get; init; }
}

View File

@ -1,4 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Sdk Name="Bitwarden.Server.Sdk" />
<PropertyGroup>
<UserSecretsId>bitwarden-Api</UserSecretsId>
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>

View File

@ -6,7 +6,7 @@ using Bit.Api.Models.Response;
using Bit.Api.Models.Response.Organizations;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
@ -38,7 +38,7 @@ public class OrganizationSponsorshipsController : Controller
private readonly ICloudSyncSponsorshipsCommand _syncSponsorshipsCommand;
private readonly ICurrentContext _currentContext;
private readonly IUserService _userService;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly IFeatureService _featureService;
public OrganizationSponsorshipsController(
@ -55,7 +55,7 @@ public class OrganizationSponsorshipsController : Controller
ICloudSyncSponsorshipsCommand syncSponsorshipsCommand,
IUserService userService,
ICurrentContext currentContext,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
IFeatureService featureService)
{
_organizationSponsorshipRepository = organizationSponsorshipRepository;
@ -71,7 +71,7 @@ public class OrganizationSponsorshipsController : Controller
_syncSponsorshipsCommand = syncSponsorshipsCommand;
_userService = userService;
_currentContext = currentContext;
_policyRepository = policyRepository;
_policyQuery = policyQuery;
_featureService = featureService;
}
@ -81,10 +81,10 @@ public class OrganizationSponsorshipsController : Controller
public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model)
{
var sponsoringOrg = await _organizationRepository.GetByIdAsync(sponsoringOrgId);
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId,
var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(sponsoringOrgId,
PolicyType.FreeFamiliesSponsorshipPolicy);
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
if (freeFamiliesSponsorshipPolicy.Enabled)
{
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
}
@ -108,10 +108,10 @@ public class OrganizationSponsorshipsController : Controller
[SelfHosted(NotSelfHostedOnly = true)]
public async Task ResendSponsorshipOffer(Guid sponsoringOrgId, [FromQuery] string sponsoredFriendlyName)
{
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsoringOrgId,
var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(sponsoringOrgId,
PolicyType.FreeFamiliesSponsorshipPolicy);
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
if (freeFamiliesSponsorshipPolicy.Enabled)
{
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
}
@ -138,9 +138,9 @@ public class OrganizationSponsorshipsController : Controller
var (isValid, sponsorship) = await _validateRedemptionTokenCommand.ValidateRedemptionTokenAsync(sponsorshipToken, (await CurrentUser).Email);
if (isValid && sponsorship.SponsoringOrganizationId.HasValue)
{
var policy = await _policyRepository.GetByOrganizationIdTypeAsync(sponsorship.SponsoringOrganizationId.Value,
var policy = await _policyQuery.RunAsync(sponsorship.SponsoringOrganizationId.Value,
PolicyType.FreeFamiliesSponsorshipPolicy);
isFreeFamilyPolicyEnabled = policy?.Enabled ?? false;
isFreeFamilyPolicyEnabled = policy.Enabled;
}
var response = PreValidateSponsorshipResponseModel.From(isValid, isFreeFamilyPolicyEnabled);
@ -165,10 +165,10 @@ public class OrganizationSponsorshipsController : Controller
throw new BadRequestException("Can only redeem sponsorship for an organization you own.");
}
var freeFamiliesSponsorshipPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(
var freeFamiliesSponsorshipPolicy = await _policyQuery.RunAsync(
model.SponsoredOrganizationId, PolicyType.FreeFamiliesSponsorshipPolicy);
if (freeFamiliesSponsorshipPolicy?.Enabled == true)
if (freeFamiliesSponsorshipPolicy.Enabled)
{
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
}

View File

@ -1,8 +1,9 @@
using Bit.Api.Billing.Attributes;
using Bit.Api.Billing.Models.Requests.Tax;
using Bit.Api.Billing.Models.Requests.PreviewInvoice;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Organizations.Commands;
using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
@ -10,10 +11,11 @@ using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Bit.Api.Billing.Controllers;
[Authorize("Application")]
[Route("billing/tax")]
public class TaxController(
[Route("billing/preview-invoice")]
public class PreviewInvoiceController(
IPreviewOrganizationTaxCommand previewOrganizationTaxCommand,
IPreviewPremiumTaxCommand previewPremiumTaxCommand) : BaseBillingController
IPreviewPremiumTaxCommand previewPremiumTaxCommand,
IPreviewPremiumUpgradeProrationCommand previewPremiumUpgradeProrationCommand) : BaseBillingController
{
[HttpPost("organizations/subscriptions/purchase")]
public async Task<IResult> PreviewOrganizationSubscriptionPurchaseTaxAsync(
@ -21,11 +23,7 @@ public class TaxController(
{
var (purchase, billingAddress) = request.ToDomain();
var result = await previewOrganizationTaxCommand.Run(purchase, billingAddress);
return Handle(result.Map(pair => new
{
pair.Tax,
pair.Total
}));
return Handle(result.Map(pair => new { pair.Tax, pair.Total }));
}
[HttpPost("organizations/{organizationId:guid}/subscription/plan-change")]
@ -36,11 +34,7 @@ public class TaxController(
{
var (planChange, billingAddress) = request.ToDomain();
var result = await previewOrganizationTaxCommand.Run(organization, planChange, billingAddress);
return Handle(result.Map(pair => new
{
pair.Tax,
pair.Total
}));
return Handle(result.Map(pair => new { pair.Tax, pair.Total }));
}
[HttpPut("organizations/{organizationId:guid}/subscription/update")]
@ -51,11 +45,7 @@ public class TaxController(
{
var update = request.ToDomain();
var result = await previewOrganizationTaxCommand.Run(organization, update);
return Handle(result.Map(pair => new
{
pair.Tax,
pair.Total
}));
return Handle(result.Map(pair => new { pair.Tax, pair.Total }));
}
[HttpPost("premium/subscriptions/purchase")]
@ -64,10 +54,29 @@ public class TaxController(
{
var (purchase, billingAddress) = request.ToDomain();
var result = await previewPremiumTaxCommand.Run(purchase, billingAddress);
return Handle(result.Map(pair => new
return Handle(result.Map(pair => new { pair.Tax, pair.Total }));
}
[HttpPost("premium/subscriptions/upgrade")]
[InjectUser]
public async Task<IResult> PreviewPremiumUpgradeProrationAsync(
[BindNever] User user,
[FromBody] PreviewPremiumUpgradeProrationRequest request)
{
var (planType, billingAddress) = request.ToDomain();
var result = await previewPremiumUpgradeProrationCommand.Run(
user,
planType,
billingAddress);
return Handle(result.Map(proration => new
{
pair.Tax,
pair.Total
proration.NewPlanProratedAmount,
proration.Credit,
proration.Tax,
proration.Total,
proration.NewPlanProratedMonths
}));
}
}

View File

@ -132,8 +132,8 @@ public class AccountBillingVNextController(
[BindNever] User user,
[FromBody] UpgradePremiumToOrganizationRequest request)
{
var (organizationName, key, planType) = request.ToDomain();
var result = await upgradePremiumToOrganizationCommand.Run(user, organizationName, key, planType);
var (organizationName, key, planType, billingAddress) = request.ToDomain();
var result = await upgradePremiumToOrganizationCommand.Run(user, organizationName, key, planType, billingAddress);
return Handle(result);
}
}

View File

@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Core.Billing.Enums;
namespace Bit.Api.Billing.Models.Requests.Premium;
@ -14,24 +15,30 @@ public class UpgradePremiumToOrganizationRequest
[Required]
[JsonConverter(typeof(JsonStringEnumConverter))]
public ProductTierType Tier { get; set; }
public required ProductTierType TargetProductTierType { get; set; }
[Required]
[JsonConverter(typeof(JsonStringEnumConverter))]
public PlanCadenceType Cadence { get; set; }
public required MinimalBillingAddressRequest BillingAddress { get; set; }
private PlanType PlanType =>
Tier switch
private PlanType PlanType
{
get
{
ProductTierType.Families => PlanType.FamiliesAnnually,
ProductTierType.Teams => Cadence == PlanCadenceType.Monthly
? PlanType.TeamsMonthly
: PlanType.TeamsAnnually,
ProductTierType.Enterprise => Cadence == PlanCadenceType.Monthly
? PlanType.EnterpriseMonthly
: PlanType.EnterpriseAnnually,
_ => throw new InvalidOperationException("Cannot upgrade to an Organization subscription that isn't Families, Teams or Enterprise.")
};
if (TargetProductTierType is not (ProductTierType.Families or ProductTierType.Teams or ProductTierType.Enterprise))
{
throw new InvalidOperationException($"Cannot upgrade Premium subscription to {TargetProductTierType} plan.");
}
public (string OrganizationName, string Key, PlanType PlanType) ToDomain() => (OrganizationName, Key, PlanType);
return TargetProductTierType switch
{
ProductTierType.Families => PlanType.FamiliesAnnually,
ProductTierType.Teams => PlanType.TeamsAnnually,
ProductTierType.Enterprise => PlanType.EnterpriseAnnually,
_ => throw new InvalidOperationException($"Unexpected ProductTierType: {TargetProductTierType}")
};
}
}
public (string OrganizationName, string Key, PlanType PlanType, Core.Billing.Payment.Models.BillingAddress BillingAddress) ToDomain() =>
(OrganizationName, Key, PlanType, BillingAddress.ToDomain());
}

View File

@ -4,7 +4,7 @@ using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Payment.Models;
namespace Bit.Api.Billing.Models.Requests.Tax;
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
public record PreviewOrganizationSubscriptionPlanChangeTaxRequest
{

View File

@ -4,7 +4,7 @@ using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Payment.Models;
namespace Bit.Api.Billing.Models.Requests.Tax;
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
public record PreviewOrganizationSubscriptionPurchaseTaxRequest
{

View File

@ -1,7 +1,7 @@
using Bit.Api.Billing.Models.Requests.Organizations;
using Bit.Core.Billing.Organizations.Models;
namespace Bit.Api.Billing.Models.Requests.Tax;
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
public class PreviewOrganizationSubscriptionUpdateTaxRequest
{

View File

@ -2,7 +2,7 @@
using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Core.Billing.Payment.Models;
namespace Bit.Api.Billing.Models.Requests.Tax;
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
public record PreviewPremiumSubscriptionPurchaseTaxRequest
{

View File

@ -0,0 +1,39 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Payment.Models;
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
public record PreviewPremiumUpgradeProrationRequest
{
[Required]
[JsonConverter(typeof(JsonStringEnumConverter))]
public required ProductTierType TargetProductTierType { get; set; }
[Required]
public required MinimalBillingAddressRequest BillingAddress { get; set; }
private PlanType PlanType
{
get
{
if (TargetProductTierType is not (ProductTierType.Families or ProductTierType.Teams or ProductTierType.Enterprise))
{
throw new InvalidOperationException($"Cannot upgrade Premium subscription to {TargetProductTierType} plan.");
}
return TargetProductTierType switch
{
ProductTierType.Families => PlanType.FamiliesAnnually,
ProductTierType.Teams => PlanType.TeamsAnnually,
ProductTierType.Enterprise => PlanType.EnterpriseAnnually,
_ => throw new InvalidOperationException($"Unexpected ProductTierType: {TargetProductTierType}")
};
}
}
public (PlanType, BillingAddress) ToDomain() =>
(PlanType, BillingAddress.ToDomain());
}

View File

@ -8,7 +8,7 @@ public class Program
{
Host
.CreateDefaultBuilder(args)
.ConfigureCustomAppConfiguration(args)
.UseBitwardenSdk()
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();

View File

@ -14,7 +14,6 @@ using Bit.Api.Tools.Models.Request;
using Bit.Api.Vault.Models.Request;
using Bit.Core.Auth.Entities;
using Bit.SharedWeb.Health;
using Microsoft.IdentityModel.Logging;
using Microsoft.OpenApi.Models;
using Bit.SharedWeb.Utilities;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
@ -238,8 +237,6 @@ public class Startup
GlobalSettings globalSettings,
ILogger<Startup> logger)
{
IdentityModelEventSource.ShowPII = true;
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();

View File

@ -74,11 +74,6 @@ public class ImportCiphersController : Controller
throw new BadRequestException("You cannot import this much data at once.");
}
if (model.Ciphers.Any(c => c.ArchivedDate.HasValue))
{
throw new BadRequestException("You cannot import archived items into an organization.");
}
var orgId = new Guid(organizationId);
var collections = model.Collections.Select(c => c.ToCollection(orgId)).ToList();

View File

@ -0,0 +1,26 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
public class PolicyStatus
{
public PolicyStatus(Guid organizationId, PolicyType policyType, Policy? policy = null)
{
OrganizationId = policy?.OrganizationId ?? organizationId;
Data = policy?.Data;
Type = policy?.Type ?? policyType;
Enabled = policy?.Enabled ?? false;
}
public Guid OrganizationId { get; set; }
public PolicyType Type { get; set; }
public bool Enabled { get; set; }
public string? Data { get; set; }
public T GetDataModel<T>() where T : IPolicyDataModel, new()
{
return CoreHelpers.LoadClassFromJsonData<T>(Data);
}
}

View File

@ -1,5 +1,5 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -11,7 +11,7 @@ using Microsoft.AspNetCore.Identity;
namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
IUserRepository userRepository,
IMailService mailService,
IEventService eventService,
@ -30,9 +30,8 @@ public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepo
}
// Enterprise policy must be enabled
var resetPasswordPolicy =
await policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled)
var resetPasswordPolicy = await policyQuery.RunAsync(orgId, PolicyType.ResetPassword);
if (!resetPasswordPolicy.Enabled)
{
throw new BadRequestException("Organization does not have the password reset policy enabled.");
}

View File

@ -3,7 +3,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimed
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Utilities.v2;
using Bit.Core.AdminConsole.Utilities.v2.Validation;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
@ -20,7 +19,7 @@ public class AutomaticallyConfirmOrganizationUsersValidator(
IPolicyRequirementQuery policyRequirementQuery,
IAutomaticUserConfirmationPolicyEnforcementValidator automaticUserConfirmationPolicyEnforcementValidator,
IUserService userService,
IPolicyRepository policyRepository) : IAutomaticallyConfirmOrganizationUsersValidator
IPolicyQuery policyQuery) : IAutomaticallyConfirmOrganizationUsersValidator
{
public async Task<ValidationResult<AutomaticallyConfirmOrganizationUserValidationRequest>> ValidateAsync(
AutomaticallyConfirmOrganizationUserValidationRequest request)
@ -74,7 +73,7 @@ public class AutomaticallyConfirmOrganizationUsersValidator(
}
private async Task<bool> OrganizationHasAutomaticallyConfirmUsersPolicyEnabledAsync(AutomaticallyConfirmOrganizationUserValidationRequest request) =>
await policyRepository.GetByOrganizationIdTypeAsync(request.OrganizationId, PolicyType.AutomaticUserConfirmation) is { Enabled: true }
(await policyQuery.RunAsync(request.OrganizationId, PolicyType.AutomaticUserConfirmation)).Enabled
&& request.Organization is { UseAutomaticUserConfirmation: true };
private async Task<bool> OrganizationUserConformsToTwoFactorRequiredPolicyAsync(AutomaticallyConfirmOrganizationUserValidationRequest request)

View File

@ -4,7 +4,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
@ -19,7 +19,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUse
public class SendOrganizationInvitesCommand(
IUserRepository userRepository,
ISsoConfigRepository ssoConfigurationRepository,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory,
IDataProtectorTokenFactory<OrgUserInviteTokenable> dataProtectorTokenFactory,
IMailService mailService) : ISendOrganizationInvitesCommand
@ -58,7 +58,7 @@ public class SendOrganizationInvitesCommand(
// need to check the policy if the org has SSO enabled.
var orgSsoLoginRequiredPolicyEnabled = orgSsoEnabled &&
organization.UsePolicies &&
(await policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.RequireSso))?.Enabled == true;
(await policyQuery.RunAsync(organization.Id, PolicyType.RequireSso)).Enabled;
// Generate the list of org users and expiring tokens
// create helper function to create expiring tokens

View File

@ -0,0 +1,17 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies;
public interface IPolicyQuery
{
/// <summary>
/// Retrieves a summary view of an organization's usage of a policy specified by the <paramref name="policyType"/>.
/// </summary>
/// <remarks>
/// This query is the entrypoint for consumers interested in understanding how a particular <see cref="PolicyType"/>
/// has been applied to an organization; the resultant <see cref="PolicyStatus"/> is not indicative of explicit
/// policy configuration.
/// </remarks>
Task<PolicyStatus> RunAsync(Guid organizationId, PolicyType policyType);
}

View File

@ -0,0 +1,14 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.Repositories;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
public class PolicyQuery(IPolicyRepository policyRepository) : IPolicyQuery
{
public async Task<PolicyStatus> RunAsync(Guid organizationId, PolicyType policyType)
{
var dbPolicy = await policyRepository.GetByOrganizationIdTypeAsync(organizationId, policyType);
return new PolicyStatus(organizationId, policyType, dbPolicy);
}
}

View File

@ -18,6 +18,7 @@ public static class PolicyServiceCollectionExtensions
services.AddScoped<ISavePolicyCommand, SavePolicyCommand>();
services.AddScoped<IVNextSavePolicyCommand, VNextSavePolicyCommand>();
services.AddScoped<IPolicyRequirementQuery, PolicyRequirementQuery>();
services.AddScoped<IPolicyQuery, PolicyQuery>();
services.AddScoped<IPolicyEventHandlerFactory, PolicyEventHandlerHandlerFactory>();
services.AddPolicyValidators();

View File

@ -48,7 +48,7 @@ public class OrganizationService : IOrganizationService
private readonly IEventService _eventService;
private readonly IApplicationCacheService _applicationCacheService;
private readonly IStripePaymentService _paymentService;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly IPolicyService _policyService;
private readonly ISsoUserRepository _ssoUserRepository;
private readonly IGlobalSettings _globalSettings;
@ -75,7 +75,7 @@ public class OrganizationService : IOrganizationService
IEventService eventService,
IApplicationCacheService applicationCacheService,
IStripePaymentService paymentService,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
IPolicyService policyService,
ISsoUserRepository ssoUserRepository,
IGlobalSettings globalSettings,
@ -102,7 +102,7 @@ public class OrganizationService : IOrganizationService
_eventService = eventService;
_applicationCacheService = applicationCacheService;
_paymentService = paymentService;
_policyRepository = policyRepository;
_policyQuery = policyQuery;
_policyService = policyService;
_ssoUserRepository = ssoUserRepository;
_globalSettings = globalSettings;
@ -835,9 +835,8 @@ public class OrganizationService : IOrganizationService
}
// Make sure the organization has the policy enabled
var resetPasswordPolicy =
await _policyRepository.GetByOrganizationIdTypeAsync(organizationId, PolicyType.ResetPassword);
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled)
var resetPasswordPolicy = await _policyQuery.RunAsync(organizationId, PolicyType.ResetPassword);
if (!resetPasswordPolicy.Enabled)
{
throw new BadRequestException("Organization does not have the password reset policy enabled.");
}

View File

@ -147,16 +147,12 @@ public class WebAuthnTokenProvider : IUserTwoFactorTokenProvider<User>
return keys;
}
// Support up to 5 keys
for (var i = 1; i <= 5; i++)
// Load all WebAuthn credentials stored in metadata. The number of allowed credentials
// is controlled by credential registration.
foreach (var kvp in provider.MetaData.Where(k => k.Key.StartsWith("Key")))
{
var keyName = $"Key{i}";
if (provider.MetaData.TryGetValue(keyName, out var value))
{
var key = new TwoFactorProvider.WebAuthnData((dynamic)value);
keys.Add(new Tuple<string, TwoFactorProvider.WebAuthnData>(keyName, key));
}
var key = new TwoFactorProvider.WebAuthnData((dynamic)kvp.Value);
keys.Add(new Tuple<string, TwoFactorProvider.WebAuthnData>(kvp.Key, key));
}
return keys;

View File

@ -5,9 +5,9 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories;
@ -21,7 +21,7 @@ namespace Bit.Core.Auth.Services;
public class SsoConfigService : ISsoConfigService
{
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IEventService _eventService;
@ -29,14 +29,14 @@ public class SsoConfigService : ISsoConfigService
public SsoConfigService(
ISsoConfigRepository ssoConfigRepository,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IEventService eventService,
IVNextSavePolicyCommand vNextSavePolicyCommand)
{
_ssoConfigRepository = ssoConfigRepository;
_policyRepository = policyRepository;
_policyQuery = policyQuery;
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
_eventService = eventService;
@ -114,14 +114,14 @@ public class SsoConfigService : ISsoConfigService
throw new BadRequestException("Organization cannot use Key Connector.");
}
var singleOrgPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(config.OrganizationId, PolicyType.SingleOrg);
if (singleOrgPolicy is not { Enabled: true })
var singleOrgPolicy = await _policyQuery.RunAsync(config.OrganizationId, PolicyType.SingleOrg);
if (!singleOrgPolicy.Enabled)
{
throw new BadRequestException("Key Connector requires the Single Organization policy to be enabled.");
}
var ssoPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(config.OrganizationId, PolicyType.RequireSso);
if (ssoPolicy is not { Enabled: true })
var ssoPolicy = await _policyQuery.RunAsync(config.OrganizationId, PolicyType.RequireSso);
if (!ssoPolicy.Enabled)
{
throw new BadRequestException("Key Connector requires the Single Sign-On Authentication policy to be enabled.");
}

View File

@ -1,6 +1,6 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
@ -27,7 +27,7 @@ public class RegisterUserCommand : IRegisterUserCommand
private readonly IGlobalSettings _globalSettings;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly IOrganizationDomainRepository _organizationDomainRepository;
private readonly IFeatureService _featureService;
@ -50,7 +50,7 @@ public class RegisterUserCommand : IRegisterUserCommand
IGlobalSettings globalSettings,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
IOrganizationDomainRepository organizationDomainRepository,
IFeatureService featureService,
IDataProtectionProvider dataProtectionProvider,
@ -65,7 +65,7 @@ public class RegisterUserCommand : IRegisterUserCommand
_globalSettings = globalSettings;
_organizationUserRepository = organizationUserRepository;
_organizationRepository = organizationRepository;
_policyRepository = policyRepository;
_policyQuery = policyQuery;
_organizationDomainRepository = organizationDomainRepository;
_featureService = featureService;
@ -246,9 +246,9 @@ public class RegisterUserCommand : IRegisterUserCommand
var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserId.Value);
if (orgUser != null)
{
var twoFactorPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(orgUser.OrganizationId,
var twoFactorPolicy = await _policyQuery.RunAsync(orgUser.OrganizationId,
PolicyType.TwoFactorAuthentication);
if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
if (twoFactorPolicy.Enabled)
{
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
{

View File

@ -70,10 +70,6 @@ public static class StripeConstants
public const string InvoiceApproved = "invoice_approved";
public const string OrganizationId = "organizationId";
public const string PayPalTransactionId = "btPayPalTransactionId";
public const string PreviousAdditionalStorage = "previous_additional_storage";
public const string PreviousPeriodEndDate = "previous_period_end_date";
public const string PreviousPremiumPriceId = "previous_premium_price_id";
public const string PreviousPremiumUserId = "previous_premium_user_id";
public const string ProviderId = "providerId";
public const string Region = "region";
public const string RetiredBraintreeCustomerId = "btCustomerId_old";

View File

@ -61,6 +61,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<ICreatePremiumCloudHostedSubscriptionCommand, CreatePremiumCloudHostedSubscriptionCommand>();
services.AddScoped<ICreatePremiumSelfHostedSubscriptionCommand, CreatePremiumSelfHostedSubscriptionCommand>();
services.AddTransient<IPreviewPremiumTaxCommand, PreviewPremiumTaxCommand>();
services.AddScoped<IPreviewPremiumUpgradeProrationCommand, PreviewPremiumUpgradeProrationCommand>();
services.AddScoped<IUpdatePremiumStorageCommand, UpdatePremiumStorageCommand>();
services.AddScoped<IUpgradePremiumToOrganizationCommand, UpgradePremiumToOrganizationCommand>();
}

View File

@ -0,0 +1,166 @@
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Premium.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Microsoft.Extensions.Logging;
using Stripe;
namespace Bit.Core.Billing.Premium.Commands;
/// <summary>
/// Previews the proration details for upgrading a Premium user subscription to an Organization
/// plan by using the Stripe API to create an invoice preview, prorated, for the upgrade.
/// </summary>
public interface IPreviewPremiumUpgradeProrationCommand
{
/// <summary>
/// Calculates the tax, total cost, and proration credit for upgrading a Premium subscription to an Organization plan.
/// </summary>
/// <param name="user">The user with an active Premium subscription.</param>
/// <param name="targetPlanType">The target organization plan type.</param>
/// <param name="billingAddress">The billing address for tax calculation.</param>
/// <returns>The proration details for the upgrade including costs, credits, tax, and time remaining.</returns>
Task<BillingCommandResult<PremiumUpgradeProration>> Run(
User user,
PlanType targetPlanType,
BillingAddress billingAddress);
}
public class PreviewPremiumUpgradeProrationCommand(
ILogger<PreviewPremiumUpgradeProrationCommand> logger,
IPricingClient pricingClient,
IStripeAdapter stripeAdapter)
: BaseBillingCommand<PreviewPremiumUpgradeProrationCommand>(logger),
IPreviewPremiumUpgradeProrationCommand
{
public Task<BillingCommandResult<PremiumUpgradeProration>> Run(
User user,
PlanType targetPlanType,
BillingAddress billingAddress) => HandleAsync<PremiumUpgradeProration>(async () =>
{
if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" })
{
return new BadRequest("User does not have an active Premium subscription.");
}
var currentSubscription = await stripeAdapter.GetSubscriptionAsync(
user.GatewaySubscriptionId,
new SubscriptionGetOptions { Expand = ["customer"] });
var premiumPlans = await pricingClient.ListPremiumPlans();
var passwordManagerItem = currentSubscription.Items.Data.FirstOrDefault(i =>
premiumPlans.Any(p => p.Seat.StripePriceId == i.Price.Id));
if (passwordManagerItem == null)
{
return new BadRequest("Premium subscription password manager item not found.");
}
var usersPremiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id);
var targetPlan = await pricingClient.GetPlanOrThrow(targetPlanType);
var subscriptionItems = new List<InvoiceSubscriptionDetailsItemOptions>();
var storageItem = currentSubscription.Items.Data.FirstOrDefault(i =>
i.Price.Id == usersPremiumPlan.Storage.StripePriceId);
// Delete the storage item if it exists for this user's plan
if (storageItem != null)
{
subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions
{
Id = storageItem.Id,
Deleted = true
});
}
// Hardcode seats to 1 for upgrade flow
if (targetPlan.HasNonSeatBasedPasswordManagerPlan())
{
subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions
{
Id = passwordManagerItem.Id,
Price = targetPlan.PasswordManager.StripePlanId,
Quantity = 1
});
}
else
{
subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions
{
Id = passwordManagerItem.Id,
Price = targetPlan.PasswordManager.StripeSeatPlanId,
Quantity = 1
});
}
var options = new InvoiceCreatePreviewOptions
{
AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true },
Customer = user.GatewayCustomerId,
Subscription = user.GatewaySubscriptionId,
CustomerDetails = new InvoiceCustomerDetailsOptions
{
Address = new AddressOptions
{
Country = billingAddress.Country,
PostalCode = billingAddress.PostalCode
}
},
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
{
Items = subscriptionItems,
ProrationBehavior = StripeConstants.ProrationBehavior.AlwaysInvoice
}
};
var invoicePreview = await stripeAdapter.CreateInvoicePreviewAsync(options);
var proration = GetProration(invoicePreview, passwordManagerItem);
return proration;
});
private static PremiumUpgradeProration GetProration(Invoice invoicePreview, SubscriptionItem passwordManagerItem) => new()
{
NewPlanProratedAmount = GetNewPlanProratedAmountFromInvoice(invoicePreview),
Credit = GetProrationCreditFromInvoice(invoicePreview),
Tax = Convert.ToDecimal(invoicePreview.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount)) / 100,
Total = Convert.ToDecimal(invoicePreview.Total) / 100,
// Use invoice periodEnd here instead of UtcNow so that testing with Stripe time clocks works correctly. And if there is no test clock,
// (like in production), the previewInvoice's periodEnd is the same as UtcNow anyway because of the proration behavior (always_invoice)
NewPlanProratedMonths = CalculateNewPlanProratedMonths(invoicePreview.PeriodEnd, passwordManagerItem.CurrentPeriodEnd)
};
private static decimal GetProrationCreditFromInvoice(Invoice invoicePreview)
{
// Extract proration credit from negative line items (credits are negative in Stripe)
var prorationCredit = invoicePreview.Lines?.Data?
.Where(line => line.Amount < 0)
.Sum(line => Math.Abs(line.Amount)) ?? 0; // Return the credit as positive number
return Convert.ToDecimal(prorationCredit) / 100;
}
private static decimal GetNewPlanProratedAmountFromInvoice(Invoice invoicePreview)
{
// The target plan's prorated upgrade amount should be the only positive-valued line item
var proratedTotal = invoicePreview.Lines?.Data?
.Where(line => line.Amount > 0)
.Sum(line => line.Amount) ?? 0;
return Convert.ToDecimal(proratedTotal) / 100;
}
private static int CalculateNewPlanProratedMonths(DateTime invoicePeriodEnd, DateTime currentPeriodEnd)
{
var daysInProratedPeriod = (currentPeriodEnd - invoicePeriodEnd).TotalDays;
// Round to nearest month (30-day periods)
// 1-14 days = 1 month, 15-44 days = 1 month, 45-74 days = 2 months, etc.
// Minimum is always 1 month (never returns 0)
// Use MidpointRounding.AwayFromZero to round 0.5 up to 1
var months = (int)Math.Round(daysInProratedPeriod / 30, MidpointRounding.AwayFromZero);
return Math.Max(1, months);
}
}

View File

@ -2,7 +2,6 @@
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
@ -28,12 +27,14 @@ public interface IUpgradePremiumToOrganizationCommand
/// <param name="organizationName">The name for the new organization.</param>
/// <param name="key">The encrypted organization key for the owner.</param>
/// <param name="targetPlanType">The target organization plan type to upgrade to.</param>
/// <param name="billingAddress">The billing address for tax calculation.</param>
/// <returns>A billing command result indicating success or failure with appropriate error details.</returns>
Task<BillingCommandResult<None>> Run(
User user,
string organizationName,
string key,
PlanType targetPlanType);
PlanType targetPlanType,
Payment.Models.BillingAddress billingAddress);
}
public class UpgradePremiumToOrganizationCommand(
@ -51,7 +52,8 @@ public class UpgradePremiumToOrganizationCommand(
User user,
string organizationName,
string key,
PlanType targetPlanType) => HandleAsync<None>(async () =>
PlanType targetPlanType,
Payment.Models.BillingAddress billingAddress) => HandleAsync<None>(async () =>
{
// Validate that the user has an active Premium subscription
if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" })
@ -74,7 +76,7 @@ public class UpgradePremiumToOrganizationCommand(
if (passwordManagerItem == null)
{
return new BadRequest("Premium subscription item not found.");
return new BadRequest("Premium subscription password manager item not found.");
}
var usersPremiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id);
@ -85,20 +87,10 @@ public class UpgradePremiumToOrganizationCommand(
// Build the list of subscription item updates
var subscriptionItemOptions = new List<SubscriptionItemOptions>();
// Delete the user's specific password manager item
subscriptionItemOptions.Add(new SubscriptionItemOptions
{
Id = passwordManagerItem.Id,
Deleted = true
});
// Delete the storage item if it exists for this user's plan
var storageItem = currentSubscription.Items.Data.FirstOrDefault(i =>
i.Price.Id == usersPremiumPlan.Storage.StripePriceId);
// Capture the previous additional storage quantity for potential revert
var previousAdditionalStorage = storageItem?.Quantity ?? 0;
if (storageItem != null)
{
subscriptionItemOptions.Add(new SubscriptionItemOptions
@ -113,6 +105,7 @@ public class UpgradePremiumToOrganizationCommand(
{
subscriptionItemOptions.Add(new SubscriptionItemOptions
{
Id = passwordManagerItem.Id,
Price = targetPlan.PasswordManager.StripePlanId,
Quantity = 1
});
@ -121,6 +114,7 @@ public class UpgradePremiumToOrganizationCommand(
{
subscriptionItemOptions.Add(new SubscriptionItemOptions
{
Id = passwordManagerItem.Id,
Price = targetPlan.PasswordManager.StripeSeatPlanId,
Quantity = seats
});
@ -133,14 +127,12 @@ public class UpgradePremiumToOrganizationCommand(
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
{
Items = subscriptionItemOptions,
ProrationBehavior = StripeConstants.ProrationBehavior.None,
ProrationBehavior = StripeConstants.ProrationBehavior.AlwaysInvoice,
BillingCycleAnchor = SubscriptionBillingCycleAnchor.Unchanged,
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.OrganizationId] = organizationId.ToString(),
[StripeConstants.MetadataKeys.PreviousPremiumPriceId] = usersPremiumPlan.Seat.StripePriceId,
[StripeConstants.MetadataKeys.PreviousPeriodEndDate] = currentSubscription.GetCurrentPeriodEnd()?.ToString("O") ?? string.Empty,
[StripeConstants.MetadataKeys.PreviousAdditionalStorage] = previousAdditionalStorage.ToString(),
[StripeConstants.MetadataKeys.PreviousPremiumUserId] = user.Id.ToString(),
[StripeConstants.MetadataKeys.UserId] = string.Empty // Remove userId to unlink subscription from User
}
};
@ -152,7 +144,7 @@ public class UpgradePremiumToOrganizationCommand(
Name = organizationName,
BillingEmail = user.Email,
PlanType = targetPlan.Type,
Seats = (short)seats,
Seats = seats,
MaxCollections = targetPlan.PasswordManager.MaxCollections,
MaxStorageGb = targetPlan.PasswordManager.BaseStorageGb,
UsePolicies = targetPlan.HasPolicies,
@ -182,6 +174,16 @@ public class UpgradePremiumToOrganizationCommand(
GatewaySubscriptionId = currentSubscription.Id
};
// Update customer billing address for tax calculation
await stripeAdapter.UpdateCustomerAsync(user.GatewayCustomerId, new CustomerUpdateOptions
{
Address = new AddressOptions
{
Country = billingAddress.Country,
PostalCode = billingAddress.PostalCode
}
});
// Update the subscription in Stripe
await stripeAdapter.UpdateSubscriptionAsync(currentSubscription.Id, subscriptionUpdateOptions);

View File

@ -0,0 +1,36 @@
namespace Bit.Core.Billing.Premium.Models;
/// <summary>
/// Represents the proration details for upgrading a Premium user subscription to an Organization plan.
/// </summary>
public class PremiumUpgradeProration
{
/// <summary>
/// The prorated cost for the new organization plan, calculated from now until the end of the current billing period.
/// This represents what the user will pay for the upgraded plan for the remainder of the period.
/// </summary>
public decimal NewPlanProratedAmount { get; set; }
/// <summary>
/// The credit amount for the unused portion of the current Premium subscription.
/// This credit is applied against the cost of the new organization plan.
/// </summary>
public decimal Credit { get; set; }
/// <summary>
/// The tax amount calculated for the upgrade transaction.
/// </summary>
public decimal Tax { get; set; }
/// <summary>
/// The total amount due for the upgrade after applying the credit and adding tax.
/// </summary>
public decimal Total { get; set; }
/// <summary>
/// The number of months the user will be charged for the new organization plan in the prorated billing period.
/// Calculated by rounding the days remaining in the current billing cycle to the nearest month.
/// Minimum value is 1 month (never returns 0).
/// </summary>
public int NewPlanProratedMonths { get; set; }
}

View File

@ -141,7 +141,6 @@ public static class FeatureFlagKeys
public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users";
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache";
public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration";
public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud";
public const string DefaultUserCollectionRestore = "pm-30883-my-items-restored-users";
public const string PremiumAccessQuery = "pm-29495-refactor-premium-interface";
public const string RefactorMembersComponent = "pm-29503-refactor-members-inheritance";
@ -161,7 +160,6 @@ public static class FeatureFlagKeys
public const string Otp6Digits = "pm-18612-otp-6-digits";
public const string PM24579_PreventSsoOnExistingNonCompliantUsers = "pm-24579-prevent-sso-on-existing-non-compliant-users";
public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods";
public const string MJMLBasedEmailTemplates = "mjml-based-email-templates";
public const string PM2035PasskeyUnlock = "pm-2035-passkey-unlock";
public const string MjmlWelcomeEmailTemplates = "pm-21741-mjml-welcome-email";
public const string OrganizationConfirmationEmail = "pm-28402-update-confirmed-to-org-email-template";

View File

@ -1,28 +1,691 @@
{{#>FullHtmlLayout}}
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
Verify your email to access this Bitwarden Send.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
Your verification code is: <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{Token}}</b>
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
This code can only be used once and expires in 5 minutes. After that you'll need to verify your email again.
</td>
</tr>
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
<td class="content-block last" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0; -webkit-text-size-adjust: none;" valign="top">
<br style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;" />
<hr />
{{TheDate}} at {{TheTime}} {{TimeZone}}
</td>
</tr>
</table>
{{/FullHtmlLayout}}
<!doctype html>
<html lang="und" dir="auto" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title></title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a { padding:0; }
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
p { display:block;margin:13px 0; }
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-70 { width:70% !important; max-width: 70%; }
.mj-column-per-30 { width:30% !important; max-width: 30%; }
.mj-column-per-100 { width:100% !important; max-width: 100%; }
.mj-column-per-90 { width:90% !important; max-width: 90%; }
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-70 { width:70% !important; max-width: 70%; }
.moz-text-html .mj-column-per-30 { width:30% !important; max-width: 30%; }
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
.moz-text-html .mj-column-per-90 { width:90% !important; max-width: 90%; }
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
.mj-bw-hero-responsive-img {
display: none !important;
}
}
@media only screen and (max-width:480px) {
.mj-bw-learn-more-footer-responsive-img {
display: none !important;
}
}
@media only screen and (max-width:479px) {
table.mj-full-width-mobile { width: 100% !important; }
td.mj-full-width-mobile { width: auto !important; }
}
</style>
<style type="text/css">
.border-fix > table {
border-collapse: separate !important;
}
.border-fix > table > tbody > tr > td {
border-radius: 3px;
}
.send-bubble {
padding-left: 20px;
padding-right: 20px;
width: 90% !important;
}
</style>
</head>
<body style="word-spacing:normal;background-color:#e6e9ef;">
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
<!-- Blue Header Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="border-fix-outlook" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div class="border-fix" style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#175ddc;background-color:#175ddc;width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#175ddc" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;border-radius:4px 4px 0px 0px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:434px;" ><![endif]-->
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:150px;">
<img alt src="https://bitwarden.com/images/logo-horizontal-white.png" style="border:0;display:block;outline:none;text-decoration:none;height:30px;width:100%;font-size:16px;" width="150" height="30">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#ffffff;"><h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
Verify your email to access this Bitwarden Send
</h1></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:bottom;width:186px;" ><![endif]-->
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:bottom;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:bottom;" width="100%">
<tbody>
<tr>
<td align="center" class="mj-bw-hero-responsive-img" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:155px;">
<img alt src="https://assets.bitwarden.com/email/v1/spot-secure-send-round.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="155" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Main Content -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:620px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="vertical-align:top;padding:0px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">Your verification code is:</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:32px;line-height:1;text-align:left;color:#1B2029;"><b>{{Token}}</b></div>
</td>
</tr>
<tr>
<td style="font-size:0px;word-break:break-word;">
<div style="height:20px;line-height:20px;">&#8202;</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">This code expires in {{Expiry}} minutes. After that, you'll need
to verify your email again.</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px 0px 20px 0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="send-bubble-outlook" style="vertical-align:top;width:558px;" ><![endif]-->
<div class="mj-column-per-90 mj-outlook-group-fix send-bubble" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="vertical-align:top;padding:0px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="background-color:#DBE5F6;border-radius:16px;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"><p>
Bitwarden Send transmits sensitive, temporary information to
others easily and securely. Learn more about
<a href="https://bitwarden.com/help/send" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">Bitwarden Send</a>
or
<a href="https://bitwarden.com/signup" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">sign up</a>
to try it today.
</p></div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Learn More Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#F3F6F9" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#F3F6F9;background-color:#F3F6F9;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#F3F6F9;background-color:#F3F6F9;width:100%;border-radius:0px 0px 4px 4px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:420px;" ><![endif]-->
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;"><p style="font-size: 18px; line-height: 28px; font-weight: bold;">
Learn more about Bitwarden
</p>
Find user guides, product documentation, and videos on the
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:180px;" ><![endif]-->
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" class="mj-bw-learn-more-footer-responsive-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:94px;">
<img alt src="https://assets.bitwarden.com/email/v1/spot-community.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="94" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Footer -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:620px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0;word-break:break-word;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://x.com/bitwarden" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-x-twitter.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-reddit.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://community.bitwarden.com/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-discourse.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://github.com/bitwarden" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-github.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-youtube.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-linkedin.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.facebook.com/bitwarden/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-facebook.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px; margin-top: 5px">
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
Barbara, CA, USA
</p>
<p style="margin-top: 5px">
Always confirm you are on a trusted Bitwarden domain before logging
in:<br>
<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>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

View File

@ -3,7 +3,7 @@ Verify your email to access this Bitwarden Send.
Your verification code is: {{Token}}
This code can only be used once and expires in 5 minutes. After that you'll need to verify your email again.
This code can only be used once and expires in {{Expiry}} minutes. After that you'll need to verify your email again.
Date : {{TheDate}} at {{TheTime}} {{TimeZone}}
Bitwarden Send transmits sensitive, temporary information to others easily and securely. Learn more about Bitwarden Send or sign up to try it today.
{{/BasicTextLayout}}

View File

@ -1,691 +0,0 @@
<!doctype html>
<html lang="und" dir="auto" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title></title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a { padding:0; }
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
p { display:block;margin:13px 0; }
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-70 { width:70% !important; max-width: 70%; }
.mj-column-per-30 { width:30% !important; max-width: 30%; }
.mj-column-per-100 { width:100% !important; max-width: 100%; }
.mj-column-per-90 { width:90% !important; max-width: 90%; }
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-70 { width:70% !important; max-width: 70%; }
.moz-text-html .mj-column-per-30 { width:30% !important; max-width: 30%; }
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
.moz-text-html .mj-column-per-90 { width:90% !important; max-width: 90%; }
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
.mj-bw-hero-responsive-img {
display: none !important;
}
}
@media only screen and (max-width:480px) {
.mj-bw-learn-more-footer-responsive-img {
display: none !important;
}
}
@media only screen and (max-width:479px) {
table.mj-full-width-mobile { width: 100% !important; }
td.mj-full-width-mobile { width: auto !important; }
}
</style>
<style type="text/css">
.border-fix > table {
border-collapse: separate !important;
}
.border-fix > table > tbody > tr > td {
border-radius: 3px;
}
.send-bubble {
padding-left: 20px;
padding-right: 20px;
width: 90% !important;
}
</style>
</head>
<body style="word-spacing:normal;background-color:#e6e9ef;">
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
<!-- Blue Header Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="border-fix-outlook" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div class="border-fix" style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#175ddc;background-color:#175ddc;width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#175ddc" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;border-radius:4px 4px 0px 0px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:434px;" ><![endif]-->
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:150px;">
<img alt src="https://bitwarden.com/images/logo-horizontal-white.png" style="border:0;display:block;outline:none;text-decoration:none;height:30px;width:100%;font-size:16px;" width="150" height="30">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#ffffff;"><h1 style="font-weight: normal; font-size: 24px; line-height: 32px">
Verify your email to access this Bitwarden Send
</h1></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:bottom;width:186px;" ><![endif]-->
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:bottom;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:bottom;" width="100%">
<tbody>
<tr>
<td align="center" class="mj-bw-hero-responsive-img" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:155px;">
<img alt src="https://assets.bitwarden.com/email/v1/spot-secure-send-round.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="155" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Main Content -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:620px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="vertical-align:top;padding:0px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">Your verification code is:</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:32px;line-height:1;text-align:left;color:#1B2029;"><b>{{Token}}</b></div>
</td>
</tr>
<tr>
<td style="font-size:0px;word-break:break-word;">
<div style="height:20px;line-height:20px;">&#8202;</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;">This code expires in {{Expiry}} minutes. After that, you'll need
to verify your email again.</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px 0px 20px 0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="send-bubble-outlook" style="vertical-align:top;width:558px;" ><![endif]-->
<div class="mj-column-per-90 mj-outlook-group-fix send-bubble" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
<tbody>
<tr>
<td style="vertical-align:top;padding:0px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="background-color:#DBE5F6;border-radius:16px;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:1;text-align:left;color:#1B2029;"><p>
Bitwarden Send transmits sensitive, temporary information to
others easily and securely. Learn more about
<a href="https://bitwarden.com/help/send" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">Bitwarden Send</a>
or
<a href="https://bitwarden.com/signup" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">sign up</a>
to try it today.
</p></div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Learn More Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#F3F6F9" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#F3F6F9;background-color:#F3F6F9;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#F3F6F9;background-color:#F3F6F9;width:100%;border-radius:0px 0px 4px 4px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:420px;" ><![endif]-->
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;"><p style="font-size: 18px; line-height: 28px; font-weight: bold;">
Learn more about Bitwarden
</p>
Find user guides, product documentation, and videos on the
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:180px;" ><![endif]-->
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" class="mj-bw-learn-more-footer-responsive-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:94px;">
<img alt src="https://assets.bitwarden.com/email/v1/spot-community.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="94" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Footer -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:620px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0;word-break:break-word;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://x.com/bitwarden" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-x-twitter.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-reddit.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://community.bitwarden.com/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-discourse.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://github.com/bitwarden" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-github.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-youtube.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-linkedin.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:8px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:24px;">
<tbody>
<tr>
<td style="font-size:0;height:24px;vertical-align:middle;width:24px;">
<a href="https://www.facebook.com/bitwarden/" target="_blank">
<img alt height="24" src="https://assets.bitwarden.com/email/v1/social-icons-facebook.png" style="border-radius:3px;display:block;" width="24">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px; margin-top: 5px">
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
Barbara, CA, USA
</p>
<p style="margin-top: 5px">
Always confirm you are on a trusted Bitwarden domain before logging
in:<br>
<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>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

View File

@ -1,9 +0,0 @@
{{#>BasicTextLayout}}
Verify your email to access this Bitwarden Send.
Your verification code is: {{Token}}
This code can only be used once and expires in {{Expiry}} minutes. After that you'll need to verify your email again.
Bitwarden Send transmits sensitive, temporary information to others easily and securely. Learn more about Bitwarden Send or sign up to try it today.
{{/BasicTextLayout}}

View File

@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories;
@ -30,6 +31,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
private readonly IGroupRepository _groupRepository;
private readonly IStripePaymentService _paymentService;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly IOrganizationConnectionRepository _organizationConnectionRepository;
private readonly IServiceAccountRepository _serviceAccountRepository;
@ -45,6 +47,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
IGroupRepository groupRepository,
IStripePaymentService paymentService,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
ISsoConfigRepository ssoConfigRepository,
IOrganizationConnectionRepository organizationConnectionRepository,
IServiceAccountRepository serviceAccountRepository,
@ -59,6 +62,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
_groupRepository = groupRepository;
_paymentService = paymentService;
_policyRepository = policyRepository;
_policyQuery = policyQuery;
_ssoConfigRepository = ssoConfigRepository;
_organizationConnectionRepository = organizationConnectionRepository;
_serviceAccountRepository = serviceAccountRepository;
@ -184,9 +188,8 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
if (!newPlan.HasResetPassword && organization.UseResetPassword)
{
var resetPasswordPolicy =
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
if (resetPasswordPolicy != null && resetPasswordPolicy.Enabled)
var resetPasswordPolicy = await _policyQuery.RunAsync(organization.Id, PolicyType.ResetPassword);
if (resetPasswordPolicy.Enabled)
{
throw new BadRequestException("Your new plan does not allow the Password Reset feature. " +
"Disable your Password Reset policy.");

View File

@ -209,26 +209,6 @@ public class HandlebarsMailService : IMailService
}
public async Task SendSendEmailOtpEmailAsync(string email, string token, string subject)
{
var message = CreateDefaultMessage(subject, email);
var requestDateTime = DateTime.UtcNow;
var model = new DefaultEmailOtpViewModel
{
Token = token,
TheDate = requestDateTime.ToLongDateString(),
TheTime = requestDateTime.ToShortTimeString(),
TimeZone = _utcTimeZoneDisplay,
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName,
};
await AddMessageContentAsync(message, "Auth.SendAccessEmailOtpEmail", model);
message.MetaData.Add("SendGridBypassListManagement", true);
// TODO - PM-25380 change to string constant
message.Category = "SendEmailOtp";
await _mailDeliveryService.SendEmailAsync(message);
}
public async Task SendSendEmailOtpEmailv2Async(string email, string token, string subject)
{
var message = CreateDefaultMessage(subject, email);
var requestDateTime = DateTime.UtcNow;
@ -242,7 +222,7 @@ public class HandlebarsMailService : IMailService
WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash,
SiteName = _globalSettings.SiteName,
};
await AddMessageContentAsync(message, "Auth.SendAccessEmailOtpEmailv2", model);
await AddMessageContentAsync(message, "Auth.SendAccessEmailOtpEmail", model);
message.MetaData.Add("SendGridBypassListManagement", true);
// TODO - PM-25380 change to string constant
message.Category = "SendEmailOtp";

View File

@ -51,17 +51,15 @@ public interface IMailService
Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail);
Task SendChangeEmailEmailAsync(string newEmailAddress, string token);
Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, TwoFactorEmailPurpose purpose);
Task SendSendEmailOtpEmailAsync(string email, string token, string subject);
/// <summary>
/// <see cref="DefaultOtpTokenProviderOptions"/> has a default expiry of 5 minutes so we set the expiry to that value int he view model.
/// <see cref="DefaultOtpTokenProviderOptions"/> has a default expiry of 5 minutes so we set the expiry to that value in the view model.
/// Sends OTP code token to the specified email address.
/// will replace <see cref="SendSendEmailOtpEmailAsync"/> when MJML templates are fully accepted.
/// </summary>
/// <param name="email">Email address to send the OTP to</param>
/// <param name="token">Otp code token</param>
/// <param name="subject">subject line of the email</param>
/// <param name="subject">Subject line of the email</param>
/// <returns>Task</returns>
Task SendSendEmailOtpEmailv2Async(string email, string token, string subject);
Task SendSendEmailOtpEmailAsync(string email, string token, string subject);
Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType type, DateTime utcNow, string ip);
Task SendNoMasterPasswordHintEmailAsync(string email);
Task SendMasterPasswordHintEmailAsync(string email, string hint);

View File

@ -99,11 +99,6 @@ public class NoopMailService : IMailService
return Task.FromResult(0);
}
public Task SendSendEmailOtpEmailv2Async(string email, string token, string subject)
{
return Task.FromResult(0);
}
public Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip)
{
return Task.FromResult(0);

View File

@ -61,7 +61,7 @@ public class UserService : UserManager<User>, IUserService
private readonly IEventService _eventService;
private readonly IApplicationCacheService _applicationCacheService;
private readonly IStripePaymentService _paymentService;
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyQuery _policyQuery;
private readonly IPolicyService _policyService;
private readonly IFido2 _fido2;
private readonly ICurrentContext _currentContext;
@ -98,7 +98,7 @@ public class UserService : UserManager<User>, IUserService
IEventService eventService,
IApplicationCacheService applicationCacheService,
IStripePaymentService paymentService,
IPolicyRepository policyRepository,
IPolicyQuery policyQuery,
IPolicyService policyService,
IFido2 fido2,
ICurrentContext currentContext,
@ -139,7 +139,7 @@ public class UserService : UserManager<User>, IUserService
_eventService = eventService;
_applicationCacheService = applicationCacheService;
_paymentService = paymentService;
_policyRepository = policyRepository;
_policyQuery = policyQuery;
_policyService = policyService;
_fido2 = fido2;
_currentContext = currentContext;
@ -722,9 +722,8 @@ public class UserService : UserManager<User>, IUserService
}
// Enterprise policy must be enabled
var resetPasswordPolicy =
await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled)
var resetPasswordPolicy = await _policyQuery.RunAsync(orgId, PolicyType.ResetPassword);
if (!resetPasswordPolicy.Enabled)
{
throw new BadRequestException("Organization does not have the password reset policy enabled.");
}

View File

@ -76,6 +76,12 @@ public class ImportCiphersCommand : IImportCiphersCommand
{
cipher.Favorites = $"{{\"{cipher.UserId.ToString().ToUpperInvariant()}\":true}}";
}
if (cipher.UserId.HasValue && cipher.ArchivedDate.HasValue)
{
cipher.Archives = $"{{\"{cipher.UserId.Value.ToString().ToUpperInvariant()}\":\"" +
$"{cipher.ArchivedDate.Value:yyyy-MM-ddTHH:mm:ss.fffffffZ}\"}}";
}
}
var userfoldersIds = (await _folderRepository.GetManyByUserIdAsync(importingUserId)).Select(f => f.Id).ToList();
@ -135,10 +141,16 @@ public class ImportCiphersCommand : IImportCiphersCommand
}
}
// Init. ids for ciphers
foreach (var cipher in ciphers)
{
// Init. ids for ciphers
cipher.SetNewId();
if (cipher.ArchivedDate.HasValue)
{
cipher.Archives = $"{{\"{importingUserId.ToString().ToUpperInvariant()}\":\"" +
$"{cipher.ArchivedDate.Value:yyyy-MM-ddTHH:mm:ss.fffffffZ}\"}}";
}
}
var organizationCollectionsIds = (await _collectionRepository.GetManyByOrganizationIdAsync(org.Id)).Select(c => c.Id).ToList();

View File

@ -44,7 +44,7 @@ public record ResourcePassword(string Hash) : SendAuthenticationMethod;
/// <summary>
/// Create a send claim by requesting a one time password (OTP) confirmation code.
/// </summary>
/// <param name="Emails">
/// <param name="EmailHashes">
/// The list of email address **hashes** permitted access to the send.
/// </param>
public record EmailOtp(string[] Emails) : SendAuthenticationMethod;
public record EmailOtp(string[] EmailHashes) : SendAuthenticationMethod;

View File

@ -1,4 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Sdk Name="Bitwarden.Server.Sdk" />
<PropertyGroup>
<UserSecretsId>bitwarden-Events</UserSecretsId>

View File

@ -8,7 +8,7 @@ public class Program
{
Host
.CreateDefaultBuilder(args)
.ConfigureCustomAppConfiguration(args)
.UseBitwardenSdk()
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();

View File

@ -1,4 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Sdk Name="Bitwarden.Server.Sdk" />
<PropertyGroup>
<UserSecretsId>bitwarden-EventsProcessor</UserSecretsId>

View File

@ -1,7 +1,6 @@
using System.Globalization;
using Bit.Core.Utilities;
using Bit.SharedWeb.Utilities;
using Microsoft.IdentityModel.Logging;
namespace Bit.EventsProcessor;
@ -40,7 +39,6 @@ public class Startup
public void Configure(IApplicationBuilder app)
{
IdentityModelEventSource.ShowPII = true;
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();
app.UseRouting();

View File

@ -1,4 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Sdk Name="Bitwarden.Server.Sdk" />
<PropertyGroup>
<UserSecretsId>bitwarden-Icons</UserSecretsId>

View File

@ -8,6 +8,7 @@ public class Program
{
Host
.CreateDefaultBuilder(args)
.UseBitwardenSdk()
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();

View File

@ -1,4 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Sdk Name="Bitwarden.Server.Sdk" />
<PropertyGroup>
<UserSecretsId>bitwarden-Identity</UserSecretsId>

View File

@ -1,5 +1,6 @@
using System.Security.Claims;
using Bit.Core;
using System.Security.Cryptography;
using System.Text;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Services;
@ -11,7 +12,6 @@ using Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
public class SendEmailOtpRequestValidator(
IFeatureService featureService,
IOtpTokenProvider<DefaultOtpTokenProviderOptions> otpTokenProvider,
IMailService mailService) : ISendAuthenticationMethodValidator<EmailOtp>
{
@ -40,8 +40,10 @@ public class SendEmailOtpRequestValidator(
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailRequired);
}
// email must be in the list of emails in the EmailOtp array
if (!authMethod.Emails.Contains(email))
// email hash must be in the list of email hashes in the EmailOtp array
byte[] hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(email));
string hashEmailHex = Convert.ToHexString(hashBytes).ToUpperInvariant();
if (!authMethod.EmailHashes.Contains(hashEmailHex))
{
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailInvalid);
}
@ -62,20 +64,12 @@ public class SendEmailOtpRequestValidator(
{
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.OtpGenerationFailed);
}
if (featureService.IsEnabled(FeatureFlagKeys.MJMLBasedEmailTemplates))
{
await mailService.SendSendEmailOtpEmailv2Async(
email,
token,
string.Format(SendAccessConstants.OtpEmail.Subject, token));
}
else
{
await mailService.SendSendEmailOtpEmailAsync(
email,
token,
string.Format(SendAccessConstants.OtpEmail.Subject, token));
}
await mailService.SendSendEmailOtpEmailAsync(
email,
token,
string.Format(SendAccessConstants.OtpEmail.Subject, token));
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent);
}

View File

@ -15,7 +15,7 @@ public class Program
{
return Host
.CreateDefaultBuilder(args)
.ConfigureCustomAppConfiguration(args)
.UseBitwardenSdk()
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();

View File

@ -14,7 +14,6 @@ using Bit.SharedWeb.Swagger;
using Bit.SharedWeb.Utilities;
using Duende.IdentityServer.Services;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.IdentityModel.Logging;
using Microsoft.OpenApi.Models;
namespace Bit.Identity;
@ -170,16 +169,14 @@ public class Startup
public void Configure(
IApplicationBuilder app,
IWebHostEnvironment env,
IWebHostEnvironment environment,
GlobalSettings globalSettings,
ILogger<Startup> logger)
{
IdentityModelEventSource.ShowPII = true;
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();
if (!env.IsDevelopment())
if (!environment.IsDevelopment())
{
var uri = new Uri(globalSettings.BaseServiceUri.Identity);
app.Use(async (ctx, next) =>
@ -196,7 +193,7 @@ public class Startup
}
// Default Middleware
app.UseDefaultMiddleware(env, globalSettings);
app.UseDefaultMiddleware(environment, globalSettings);
if (!globalSettings.SelfHosted)
{
@ -204,7 +201,7 @@ public class Startup
app.UseMiddleware<CustomIpRateLimitMiddleware>();
}
if (env.IsDevelopment())
if (environment.IsDevelopment())
{
app.UseSwagger();
app.UseDeveloperExceptionPage();

View File

@ -248,7 +248,7 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
new { Ids = ids.ToGuidIdArrayTVP(), UserId = userId },
commandType: CommandType.StoredProcedure);
return results;
return DateTime.SpecifyKind(results, DateTimeKind.Utc);
}
}
@ -595,7 +595,7 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
new { Ids = ids.ToGuidIdArrayTVP(), UserId = userId },
commandType: CommandType.StoredProcedure);
return results;
return DateTime.SpecifyKind(results, DateTimeKind.Utc);
}
}
@ -608,7 +608,7 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
new { Ids = ids.ToGuidIdArrayTVP(), UserId = userId },
commandType: CommandType.StoredProcedure);
return results;
return DateTime.SpecifyKind(results, DateTimeKind.Utc);
}
}
@ -621,7 +621,7 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
new { Ids = ids.ToGuidIdArrayTVP(), OrganizationId = organizationId },
commandType: CommandType.StoredProcedure);
return results;
return DateTime.SpecifyKind(results, DateTimeKind.Utc);
}
}

View File

@ -1,4 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Sdk Name="Bitwarden.Server.Sdk" />
<PropertyGroup>
<UserSecretsId>bitwarden-Notifications</UserSecretsId>

View File

@ -8,7 +8,7 @@ public class Program
{
Host
.CreateDefaultBuilder(args)
.ConfigureCustomAppConfiguration(args)
.UseBitwardenSdk()
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();

View File

@ -5,7 +5,6 @@ using Bit.Core.Utilities;
using Bit.SharedWeb.Utilities;
using Duende.IdentityModel;
using Microsoft.AspNetCore.SignalR;
using Microsoft.IdentityModel.Logging;
namespace Bit.Notifications;
@ -84,8 +83,6 @@ public class Startup
IWebHostEnvironment env,
GlobalSettings globalSettings)
{
IdentityModelEventSource.ShowPII = true;
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();

View File

@ -472,11 +472,6 @@ public static class ServiceCollectionExtensions
addAuthorization.Invoke(config);
});
}
if (environment.IsDevelopment())
{
Microsoft.IdentityModel.Logging.IdentityModelEventSource.ShowPII = true;
}
}
public static void AddCustomDataProtectionServices(
@ -666,7 +661,6 @@ public static class ServiceCollectionExtensions
Constants.BrowserExtensions.OperaId
};
}
});
}

View File

@ -1,11 +1,14 @@
using System.Net;
using System.Text;
using System.Text.Json;
using AutoMapper;
using Bit.Api.AdminConsole.Models.Request;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Api.Models.Request;
using Bit.Core.Entities;
using Bit.Seeder.Recipes;
using Microsoft.AspNetCore.Identity;
using Xunit;
using Xunit.Abstractions;
@ -26,7 +29,9 @@ public class GroupsControllerPerformanceTests(ITestOutputHelper testOutputHelper
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
@ -34,8 +39,8 @@ public class GroupsControllerPerformanceTests(ITestOutputHelper testOutputHelper
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
var collectionIds = collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0);
var groupIds = groupsSeeder.AddToOrganization(orgId, 1, orgUserIds, 0);
var collectionIds = collectionsSeeder.Seed(orgId, collectionCount, orgUserIds, 0);
var groupIds = groupsSeeder.Seed(orgId, 1, orgUserIds, 0);
var groupId = groupIds.First();

View File

@ -1,13 +1,16 @@
using System.Net;
using System.Text;
using System.Text.Json;
using AutoMapper;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Api.Models.Request;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Seeder.Recipes;
using Microsoft.AspNetCore.Identity;
using Xunit;
using Xunit.Abstractions;
@ -28,7 +31,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
@ -37,8 +42,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: seats);
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
collectionsSeeder.AddToOrganization(orgId, 10, orgUserIds);
groupsSeeder.AddToOrganization(orgId, 5, orgUserIds);
collectionsSeeder.Seed(orgId, 10, orgUserIds);
groupsSeeder.Seed(orgId, 5, orgUserIds);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
@ -64,7 +69,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
@ -72,8 +79,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: seats);
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
collectionsSeeder.AddToOrganization(orgId, 10, orgUserIds);
groupsSeeder.AddToOrganization(orgId, 5, orgUserIds);
collectionsSeeder.Seed(orgId, 10, orgUserIds);
groupsSeeder.Seed(orgId, 5, orgUserIds);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
@ -98,14 +105,16 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var groupsSeeder = new GroupsRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1);
var orgUserId = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).FirstOrDefault();
groupsSeeder.AddToOrganization(orgId, 2, [orgUserId]);
groupsSeeder.Seed(orgId, 2, [orgUserId]);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
@ -130,7 +139,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1);
@ -163,7 +174,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(
@ -211,7 +224,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
@ -251,7 +266,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(
@ -295,7 +312,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(
@ -339,7 +358,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var domainSeeder = new OrganizationDomainRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
@ -350,7 +371,7 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
users: userCount,
usersStatus: OrganizationUserStatusType.Confirmed);
domainSeeder.AddVerifiedDomainToOrganization(orgId, domain);
domainSeeder.Seed(orgId, domain);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
@ -384,7 +405,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
@ -392,8 +415,8 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1);
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
var collectionIds = collectionsSeeder.AddToOrganization(orgId, 3, orgUserIds, 0);
var groupIds = groupsSeeder.AddToOrganization(orgId, 2, orgUserIds, 0);
var collectionIds = collectionsSeeder.Seed(orgId, 3, orgUserIds, 0);
var groupIds = groupsSeeder.Seed(orgId, 2, orgUserIds, 0);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
@ -434,7 +457,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
@ -471,7 +496,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var domainSeeder = new OrganizationDomainRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
@ -481,7 +508,7 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
users: 2,
usersStatus: OrganizationUserStatusType.Confirmed);
domainSeeder.AddVerifiedDomainToOrganization(orgId, domain);
domainSeeder.Seed(orgId, domain);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
@ -512,14 +539,16 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var collectionsSeeder = new CollectionsRecipe(db);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: 1);
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
var collectionIds = collectionsSeeder.AddToOrganization(orgId, 2, orgUserIds, 0);
var collectionIds = collectionsSeeder.Seed(orgId, 2, orgUserIds, 0);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
@ -560,7 +589,9 @@ public class OrganizationUsersControllerPerformanceTests(ITestOutputHelper testO
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var domain = OrganizationTestHelpers.GenerateRandomDomain();
var orgId = orgSeeder.Seed(

View File

@ -1,14 +1,17 @@
using System.Net;
using System.Text;
using System.Text.Json;
using AutoMapper;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Tokens;
using Bit.Seeder.Recipes;
using Microsoft.AspNetCore.Identity;
using Xunit;
using Xunit.Abstractions;
@ -29,7 +32,9 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
@ -37,8 +42,8 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0);
groupsSeeder.AddToOrganization(orgId, groupCount, orgUserIds, 0);
collectionsSeeder.Seed(orgId, collectionCount, orgUserIds, 0);
groupsSeeder.Seed(orgId, groupCount, orgUserIds, 0);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");
@ -77,7 +82,9 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu
var client = factory.CreateClient();
var db = factory.GetDatabaseContext();
var orgSeeder = new OrganizationWithUsersRecipe(db);
var mapper = factory.GetService<IMapper>();
var passwordHasher = factory.GetService<IPasswordHasher<User>>();
var orgSeeder = new OrganizationWithUsersRecipe(db, mapper, passwordHasher);
var collectionsSeeder = new CollectionsRecipe(db);
var groupsSeeder = new GroupsRecipe(db);
@ -85,8 +92,8 @@ public class OrganizationsControllerPerformanceTests(ITestOutputHelper testOutpu
var orgId = orgSeeder.Seed(name: "Org", domain: domain, users: userCount);
var orgUserIds = db.OrganizationUsers.Where(ou => ou.OrganizationId == orgId).Select(ou => ou.Id).ToList();
collectionsSeeder.AddToOrganization(orgId, collectionCount, orgUserIds, 0);
groupsSeeder.AddToOrganization(orgId, groupCount, orgUserIds, 0);
collectionsSeeder.Seed(orgId, collectionCount, orgUserIds, 0);
groupsSeeder.Seed(orgId, groupCount, orgUserIds, 0);
await PerformanceTestHelpers.AuthenticateClientAsync(factory, client, $"owner@{domain}");

View File

@ -14,7 +14,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Utilities.v2.Results;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Repositories;
@ -30,6 +29,7 @@ using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Test.AdminConsole.AutoFixture;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@ -137,23 +137,20 @@ public class OrganizationUsersControllerTests
[Theory]
[BitAutoData]
public async Task Accept_WhenOrganizationUsePoliciesIsEnabledAndResetPolicyIsEnabled_ShouldHandleResetPassword(Guid orgId, Guid orgUserId,
OrganizationUserAcceptRequestModel model, User user, SutProvider<OrganizationUsersController> sutProvider)
OrganizationUserAcceptRequestModel model, User user,
[Policy(PolicyType.ResetPassword, true)] PolicyStatus policy,
SutProvider<OrganizationUsersController> sutProvider)
{
// Arrange
var applicationCacheService = sutProvider.GetDependency<IApplicationCacheService>();
applicationCacheService.GetOrganizationAbilityAsync(orgId).Returns(new OrganizationAbility { UsePolicies = true });
var policy = new Policy
{
Enabled = true,
Data = CoreHelpers.ClassToJsonData(new ResetPasswordDataModel { AutoEnrollEnabled = true, }),
};
policy.Data = CoreHelpers.ClassToJsonData(new ResetPasswordDataModel { AutoEnrollEnabled = true, });
var userService = sutProvider.GetDependency<IUserService>();
userService.GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user);
var policyRepository = sutProvider.GetDependency<IPolicyRepository>();
policyRepository.GetByOrganizationIdTypeAsync(orgId,
var policyQuery = sutProvider.GetDependency<IPolicyQuery>();
policyQuery.RunAsync(orgId,
PolicyType.ResetPassword).Returns(policy);
// Act
@ -167,29 +164,27 @@ public class OrganizationUsersControllerTests
await userService.Received(1).GetUserByPrincipalAsync(default);
await applicationCacheService.Received(1).GetOrganizationAbilityAsync(orgId);
await policyRepository.Received(1).GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
await policyQuery.Received(1).RunAsync(orgId, PolicyType.ResetPassword);
}
[Theory]
[BitAutoData]
public async Task Accept_WhenOrganizationUsePoliciesIsDisabled_ShouldNotHandleResetPassword(Guid orgId, Guid orgUserId,
OrganizationUserAcceptRequestModel model, User user, SutProvider<OrganizationUsersController> sutProvider)
OrganizationUserAcceptRequestModel model, User user,
[Policy(PolicyType.ResetPassword, true)] PolicyStatus policy,
SutProvider<OrganizationUsersController> sutProvider)
{
// Arrange
var applicationCacheService = sutProvider.GetDependency<IApplicationCacheService>();
applicationCacheService.GetOrganizationAbilityAsync(orgId).Returns(new OrganizationAbility { UsePolicies = false });
var policy = new Policy
{
Enabled = true,
Data = CoreHelpers.ClassToJsonData(new ResetPasswordDataModel { AutoEnrollEnabled = true, }),
};
policy.Data = CoreHelpers.ClassToJsonData(new ResetPasswordDataModel { AutoEnrollEnabled = true, });
var userService = sutProvider.GetDependency<IUserService>();
userService.GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user);
var policyRepository = sutProvider.GetDependency<IPolicyRepository>();
policyRepository.GetByOrganizationIdTypeAsync(orgId,
var policyQuery = sutProvider.GetDependency<IPolicyQuery>();
policyQuery.RunAsync(orgId,
PolicyType.ResetPassword).Returns(policy);
// Act
@ -202,7 +197,7 @@ public class OrganizationUsersControllerTests
await sutProvider.GetDependency<IOrganizationService>().Received(0)
.UpdateUserResetPasswordEnrollmentAsync(orgId, user.Id, model.ResetPasswordKey, user.Id);
await policyRepository.Received(0).GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
await policyQuery.Received(0).RunAsync(orgId, PolicyType.ResetPassword);
await applicationCacheService.Received(1).GetOrganizationAbilityAsync(orgId);
}
@ -383,7 +378,7 @@ public class OrganizationUsersControllerTests
var policyRequirementQuery = sutProvider.GetDependency<IPolicyRequirementQuery>();
var policyRepository = sutProvider.GetDependency<IPolicyRepository>();
var policyQuery = sutProvider.GetDependency<IPolicyQuery>();
var policyRequirement = new ResetPasswordPolicyRequirement { AutoEnrollOrganizations = [orgId] };
@ -400,7 +395,7 @@ public class OrganizationUsersControllerTests
await userService.Received(1).GetUserByPrincipalAsync(default);
await applicationCacheService.Received(0).GetOrganizationAbilityAsync(orgId);
await policyRepository.Received(0).GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
await policyQuery.Received(0).RunAsync(orgId, PolicyType.ResetPassword);
await policyRequirementQuery.Received(1).GetAsync<ResetPasswordPolicyRequirement>(user.Id);
Assert.True(policyRequirement.AutoEnrollEnabled(orgId));
}
@ -425,7 +420,7 @@ public class OrganizationUsersControllerTests
var userService = sutProvider.GetDependency<IUserService>();
userService.GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user);
var policyRepository = sutProvider.GetDependency<IPolicyRepository>();
var policyQuery = sutProvider.GetDependency<IPolicyQuery>();
var policyRequirementQuery = sutProvider.GetDependency<IPolicyRequirementQuery>();
@ -445,7 +440,7 @@ public class OrganizationUsersControllerTests
await userService.Received(1).GetUserByPrincipalAsync(default);
await applicationCacheService.Received(0).GetOrganizationAbilityAsync(orgId);
await policyRepository.Received(0).GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
await policyQuery.Received(0).RunAsync(orgId, PolicyType.ResetPassword);
await policyRequirementQuery.Received(1).GetAsync<ResetPasswordPolicyRequirement>(user.Id);
Assert.Equal("Master Password reset is required, but not provided.", exception.Message);

View File

@ -7,6 +7,7 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Business;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
@ -25,6 +26,7 @@ using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Test.AdminConsole.AutoFixture;
using Bit.Core.Test.Billing.Mocks;
using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider;
using Bit.Test.Common.AutoFixture;
@ -200,28 +202,21 @@ public class OrganizationsControllerTests
SutProvider<OrganizationsController> sutProvider,
User user,
Organization organization,
OrganizationUser organizationUser)
OrganizationUser organizationUser,
[Policy(PolicyType.ResetPassword, data: "{\"AutoEnrollEnabled\": true}")] PolicyStatus policy)
{
var policy = new Policy
{
Type = PolicyType.ResetPassword,
Enabled = true,
Data = "{\"AutoEnrollEnabled\": true}",
OrganizationId = organization.Id
};
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdentifierAsync(organization.Id.ToString()).Returns(organization);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(false);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser);
sutProvider.GetDependency<IPolicyRepository>().GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword).Returns(policy);
sutProvider.GetDependency<IPolicyQuery>().RunAsync(organization.Id, PolicyType.ResetPassword).Returns(policy);
var result = await sutProvider.Sut.GetAutoEnrollStatus(organization.Id.ToString());
await sutProvider.GetDependency<IUserService>().Received(1).GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>());
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).GetByIdentifierAsync(organization.Id.ToString());
await sutProvider.GetDependency<IPolicyRequirementQuery>().Received(0).GetAsync<ResetPasswordPolicyRequirement>(user.Id);
await sutProvider.GetDependency<IPolicyRepository>().Received(1).GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
await sutProvider.GetDependency<IPolicyQuery>().Received(1).RunAsync(organization.Id, PolicyType.ResetPassword);
Assert.True(result.ResetPasswordEnabled);
}

View File

@ -1,14 +1,13 @@
using AutoFixture;
using Bit.Api.AdminConsole.Models.Response.Helpers;
using Bit.Core.AdminConsole.Entities;
using Bit.Api.AdminConsole.Models.Response.Helpers;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Models.Response.Helpers;
public class PolicyDetailResponsesTests
public class PolicyStatusResponsesTests
{
[Theory]
[InlineData(true, false)]
@ -17,19 +16,13 @@ public class PolicyDetailResponsesTests
bool policyEnabled,
bool expectedCanToggle)
{
var fixture = new Fixture();
var policy = fixture.Build<Policy>()
.Without(p => p.Data)
.With(p => p.Type, PolicyType.SingleOrg)
.With(p => p.Enabled, policyEnabled)
.Create();
var policy = new PolicyStatus(Guid.NewGuid(), PolicyType.SingleOrg) { Enabled = policyEnabled };
var querySub = Substitute.For<IOrganizationHasVerifiedDomainsQuery>();
querySub.HasVerifiedDomainsAsync(policy.OrganizationId)
.Returns(true);
var result = await policy.GetSingleOrgPolicyDetailResponseAsync(querySub);
var result = await policy.GetSingleOrgPolicyStatusResponseAsync(querySub);
Assert.Equal(expectedCanToggle, result.CanToggleState);
}
@ -37,18 +30,13 @@ public class PolicyDetailResponsesTests
[Fact]
public async Task GetSingleOrgPolicyDetailResponseAsync_WhenIsNotSingleOrgType_ThenShouldThrowArgumentException()
{
var fixture = new Fixture();
var policy = fixture.Build<Policy>()
.Without(p => p.Data)
.With(p => p.Type, PolicyType.TwoFactorAuthentication)
.Create();
var policy = new PolicyStatus(Guid.NewGuid(), PolicyType.TwoFactorAuthentication);
var querySub = Substitute.For<IOrganizationHasVerifiedDomainsQuery>();
querySub.HasVerifiedDomainsAsync(policy.OrganizationId)
.Returns(true);
var action = async () => await policy.GetSingleOrgPolicyDetailResponseAsync(querySub);
var action = async () => await policy.GetSingleOrgPolicyStatusResponseAsync(querySub);
await Assert.ThrowsAsync<ArgumentException>("policy", action);
}
@ -56,18 +44,13 @@ public class PolicyDetailResponsesTests
[Fact]
public async Task GetSingleOrgPolicyDetailResponseAsync_WhenIsSingleOrgTypeAndDoesNotHaveVerifiedDomains_ThenShouldBeAbleToToggle()
{
var fixture = new Fixture();
var policy = fixture.Build<Policy>()
.Without(p => p.Data)
.With(p => p.Type, PolicyType.SingleOrg)
.Create();
var policy = new PolicyStatus(Guid.NewGuid(), PolicyType.SingleOrg);
var querySub = Substitute.For<IOrganizationHasVerifiedDomainsQuery>();
querySub.HasVerifiedDomainsAsync(policy.OrganizationId)
.Returns(false);
var result = await policy.GetSingleOrgPolicyDetailResponseAsync(querySub);
var result = await policy.GetSingleOrgPolicyStatusResponseAsync(querySub);
Assert.True(result.CanToggleState);
}

View File

@ -1,6 +1,9 @@
using Bit.Api.Billing.Controllers;
using Bit.Api.Models.Request.Organizations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.Billing.Enums;
using Bit.Core.Context;
using Bit.Core.Entities;
@ -10,6 +13,7 @@ using Bit.Core.Models.Data;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Test.AdminConsole.AutoFixture;
using Bit.Core.Test.Billing.Mocks;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@ -82,7 +86,9 @@ public class OrganizationSponsorshipsControllerTests
[BitAutoData]
public async Task RedeemSponsorship_NotSponsoredOrgOwner_Success(string sponsorshipToken, User user,
OrganizationSponsorship sponsorship, Organization sponsoringOrganization,
OrganizationSponsorshipRedeemRequestModel model, SutProvider<OrganizationSponsorshipsController> sutProvider)
OrganizationSponsorshipRedeemRequestModel model,
[Policy(PolicyType.FreeFamiliesSponsorshipPolicy, false)] PolicyStatus policy,
SutProvider<OrganizationSponsorshipsController> sutProvider)
{
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(user.Id);
sutProvider.GetDependency<IUserService>().GetUserByIdAsync(user.Id)
@ -91,6 +97,9 @@ public class OrganizationSponsorshipsControllerTests
user.Email).Returns((true, sponsorship));
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(model.SponsoredOrganizationId).Returns(true);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(model.SponsoredOrganizationId).Returns(sponsoringOrganization);
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(Arg.Any<Guid>(), PolicyType.FreeFamiliesSponsorshipPolicy)
.Returns(policy);
await sutProvider.Sut.RedeemSponsorship(sponsorshipToken, model);
@ -101,14 +110,18 @@ public class OrganizationSponsorshipsControllerTests
[Theory]
[BitAutoData]
public async Task PreValidateSponsorshipToken_ValidatesToken_Success(string sponsorshipToken, User user,
OrganizationSponsorship sponsorship, SutProvider<OrganizationSponsorshipsController> sutProvider)
OrganizationSponsorship sponsorship,
[Policy(PolicyType.FreeFamiliesSponsorshipPolicy, false)] PolicyStatus policy,
SutProvider<OrganizationSponsorshipsController> sutProvider)
{
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(user.Id);
sutProvider.GetDependency<IUserService>().GetUserByIdAsync(user.Id)
.Returns(user);
sutProvider.GetDependency<IValidateRedemptionTokenCommand>()
.ValidateRedemptionTokenAsync(sponsorshipToken, user.Email).Returns((true, sponsorship));
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(Arg.Any<Guid>(), PolicyType.FreeFamiliesSponsorshipPolicy)
.Returns(policy);
await sutProvider.Sut.PreValidateSponsorshipToken(sponsorshipToken);
await sutProvider.GetDependency<IValidateRedemptionTokenCommand>().Received(1)

View File

@ -0,0 +1,56 @@
using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Api.Billing.Models.Requests.PreviewInvoice;
using Bit.Core.Billing.Enums;
using Xunit;
namespace Bit.Api.Test.Billing.Models.Requests;
public class PreviewPremiumUpgradeProrationRequestTests
{
[Theory]
[InlineData(ProductTierType.Families, PlanType.FamiliesAnnually)]
[InlineData(ProductTierType.Teams, PlanType.TeamsAnnually)]
[InlineData(ProductTierType.Enterprise, PlanType.EnterpriseAnnually)]
public void ToDomain_ValidTierTypes_ReturnsPlanType(ProductTierType tierType, PlanType expectedPlanType)
{
// Arrange
var sut = new PreviewPremiumUpgradeProrationRequest
{
TargetProductTierType = tierType,
BillingAddress = new MinimalBillingAddressRequest
{
Country = "US",
PostalCode = "12345"
}
};
// Act
var (planType, billingAddress) = sut.ToDomain();
// Assert
Assert.Equal(expectedPlanType, planType);
Assert.Equal("US", billingAddress.Country);
Assert.Equal("12345", billingAddress.PostalCode);
}
[Theory]
[InlineData(ProductTierType.Free)]
[InlineData(ProductTierType.TeamsStarter)]
public void ToDomain_InvalidTierTypes_ThrowsInvalidOperationException(ProductTierType tierType)
{
// Arrange
var sut = new PreviewPremiumUpgradeProrationRequest
{
TargetProductTierType = tierType,
BillingAddress = new MinimalBillingAddressRequest
{
Country = "US",
PostalCode = "12345"
}
};
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => sut.ToDomain());
Assert.Contains($"Cannot upgrade Premium subscription to {tierType} plan", exception.Message);
}
}

View File

@ -0,0 +1,62 @@
using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Api.Billing.Models.Requests.Premium;
using Bit.Core.Billing.Enums;
using Xunit;
namespace Bit.Api.Test.Billing.Models.Requests;
public class UpgradePremiumToOrganizationRequestTests
{
[Theory]
[InlineData(ProductTierType.Families, PlanType.FamiliesAnnually)]
[InlineData(ProductTierType.Teams, PlanType.TeamsAnnually)]
[InlineData(ProductTierType.Enterprise, PlanType.EnterpriseAnnually)]
public void ToDomain_ValidTierTypes_ReturnsPlanType(ProductTierType tierType, PlanType expectedPlanType)
{
// Arrange
var sut = new UpgradePremiumToOrganizationRequest
{
OrganizationName = "Test Organization",
Key = "encrypted-key",
TargetProductTierType = tierType,
BillingAddress = new MinimalBillingAddressRequest
{
Country = "US",
PostalCode = "12345"
}
};
// Act
var (organizationName, key, planType, billingAddress) = sut.ToDomain();
// Assert
Assert.Equal("Test Organization", organizationName);
Assert.Equal("encrypted-key", key);
Assert.Equal(expectedPlanType, planType);
Assert.Equal("US", billingAddress.Country);
Assert.Equal("12345", billingAddress.PostalCode);
}
[Theory]
[InlineData(ProductTierType.Free)]
[InlineData(ProductTierType.TeamsStarter)]
public void ToDomain_InvalidTierTypes_ThrowsInvalidOperationException(ProductTierType tierType)
{
// Arrange
var sut = new UpgradePremiumToOrganizationRequest
{
OrganizationName = "Test Organization",
Key = "encrypted-key",
TargetProductTierType = tierType,
BillingAddress = new MinimalBillingAddressRequest
{
Country = "US",
PostalCode = "12345"
}
};
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => sut.ToDomain());
Assert.Contains($"Cannot upgrade Premium subscription to {tierType} plan", exception.Message);
}
}

View File

@ -49,7 +49,7 @@ public class PoliciesControllerTests
sutProvider.GetDependency<IUserService>()
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
.Returns((Guid?)userId);
.Returns(userId);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByOrganizationAsync(orgId, userId)
@ -95,7 +95,7 @@ public class PoliciesControllerTests
// Arrange
sutProvider.GetDependency<IUserService>()
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
.Returns((Guid?)userId);
.Returns(userId);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByOrganizationAsync(orgId, userId)
@ -113,7 +113,7 @@ public class PoliciesControllerTests
// Arrange
sutProvider.GetDependency<IUserService>()
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
.Returns((Guid?)userId);
.Returns(userId);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByOrganizationAsync(orgId, userId)
@ -135,7 +135,7 @@ public class PoliciesControllerTests
// Arrange
sutProvider.GetDependency<IUserService>()
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
.Returns((Guid?)userId);
.Returns(userId);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByOrganizationAsync(orgId, userId)
@ -186,59 +186,35 @@ public class PoliciesControllerTests
[Theory]
[BitAutoData]
public async Task Get_WhenUserCanManagePolicies_WithExistingType_ReturnsExistingPolicy(
SutProvider<PoliciesController> sutProvider, Guid orgId, Policy policy, int type)
SutProvider<PoliciesController> sutProvider, Guid orgId, PolicyStatus policy, PolicyType type)
{
// Arrange
sutProvider.GetDependency<ICurrentContext>()
.ManagePolicies(orgId)
.Returns(true);
policy.Type = (PolicyType)type;
policy.Type = type;
policy.Enabled = true;
policy.Data = null;
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type)
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(orgId, type)
.Returns(policy);
// Act
var result = await sutProvider.Sut.Get(orgId, type);
// Assert
Assert.IsType<PolicyDetailResponseModel>(result);
Assert.Equal(policy.Id, result.Id);
Assert.IsType<PolicyStatusResponseModel>(result);
Assert.Equal(policy.Type, result.Type);
Assert.Equal(policy.Enabled, result.Enabled);
Assert.Equal(policy.OrganizationId, result.OrganizationId);
}
[Theory]
[BitAutoData]
public async Task Get_WhenUserCanManagePolicies_WithNonExistingType_ReturnsDefaultPolicy(
SutProvider<PoliciesController> sutProvider, Guid orgId, int type)
{
// Arrange
sutProvider.GetDependency<ICurrentContext>()
.ManagePolicies(orgId)
.Returns(true);
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(orgId, (PolicyType)type)
.Returns((Policy)null);
// Act
var result = await sutProvider.Sut.Get(orgId, type);
// Assert
Assert.IsType<PolicyDetailResponseModel>(result);
Assert.Equal(result.Type, (PolicyType)type);
Assert.False(result.Enabled);
}
[Theory]
[BitAutoData]
public async Task Get_WhenUserCannotManagePolicies_ThrowsNotFoundException(
SutProvider<PoliciesController> sutProvider, Guid orgId, int type)
SutProvider<PoliciesController> sutProvider, Guid orgId, PolicyType type)
{
// Arrange
sutProvider.GetDependency<ICurrentContext>()

View File

@ -806,63 +806,6 @@ public class ImportCiphersControllerTests
Arg.Any<Guid>());
}
[Theory, BitAutoData]
public async Task PostImportOrganization_ThrowsException_WhenAnyCipherIsArchived(
SutProvider<ImportCiphersController> sutProvider,
IFixture fixture,
User user
)
{
var orgId = Guid.NewGuid();
sutProvider.GetDependency<GlobalSettings>()
.SelfHosted = false;
sutProvider.GetDependency<GlobalSettings>()
.ImportCiphersLimitation = _organizationCiphersLimitations;
SetupUserService(sutProvider, user);
var ciphers = fixture.Build<CipherRequestModel>()
.With(_ => _.ArchivedDate, DateTime.UtcNow)
.CreateMany(2).ToArray();
var request = new ImportOrganizationCiphersRequestModel
{
Collections = new List<CollectionWithIdRequestModel>().ToArray(),
Ciphers = ciphers,
CollectionRelationships = new List<KeyValuePair<int, int>>().ToArray(),
};
sutProvider.GetDependency<ICurrentContext>()
.AccessImportExport(Arg.Any<Guid>())
.Returns(false);
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(Arg.Any<ClaimsPrincipal>(),
Arg.Any<IEnumerable<Collection>>(),
Arg.Is<IEnumerable<IAuthorizationRequirement>>(reqs =>
reqs.Contains(BulkCollectionOperations.ImportCiphers)))
.Returns(AuthorizationResult.Failed());
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(Arg.Any<ClaimsPrincipal>(),
Arg.Any<IEnumerable<Collection>>(),
Arg.Is<IEnumerable<IAuthorizationRequirement>>(reqs =>
reqs.Contains(BulkCollectionOperations.Create)))
.Returns(AuthorizationResult.Success());
sutProvider.GetDependency<ICollectionRepository>()
.GetManyByOrganizationIdAsync(orgId)
.Returns(new List<Collection>());
var exception = await Assert.ThrowsAsync<BadRequestException>(async () =>
{
await sutProvider.Sut.PostImportOrganization(orgId.ToString(), request);
});
Assert.Equal("You cannot import archived items into an organization.", exception.Message);
}
private static void SetupUserService(SutProvider<ImportCiphersController> sutProvider, User user)
{
// This is a workaround for the NSubstitute issue with ambiguous arguments

View File

@ -819,7 +819,7 @@ public class SubscriptionUpdatedHandlerTests
{
Data =
[
new SubscriptionItem { Plan = new Plan { Id = "secrets-manager-teams-seat-annually" } },
new SubscriptionItem { Plan = new Stripe.Plan { Id = "secrets-manager-teams-seat-annually" } },
]
}
})

View File

@ -0,0 +1,17 @@
using System.Security.Cryptography;
using System.Text;
namespace Bit.Test.Common.Helpers;
public class CryptographyHelper
{
/// <summary>
/// Returns a hex-encoded, SHA256 hash for the given string
/// </summary>
public static string HashAndEncode(string text)
{
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(text));
var hashEncoded = Convert.ToHexString(hashBytes).ToUpperInvariant();
return hashEncoded;
}
}

View File

@ -3,6 +3,7 @@ using AutoFixture;
using AutoFixture.Xunit2;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
namespace Bit.Core.Test.AdminConsole.AutoFixture;
@ -10,19 +11,30 @@ internal class PolicyCustomization : ICustomization
{
public PolicyType Type { get; set; }
public bool Enabled { get; set; }
public string? Data { get; set; }
public PolicyCustomization(PolicyType type, bool enabled)
public PolicyCustomization(PolicyType type, bool enabled, string? data)
{
Type = type;
Enabled = enabled;
Data = data;
}
public void Customize(IFixture fixture)
{
var orgId = Guid.NewGuid();
fixture.Customize<Policy>(composer => composer
.With(o => o.OrganizationId, Guid.NewGuid())
.With(o => o.OrganizationId, orgId)
.With(o => o.Type, Type)
.With(o => o.Enabled, Enabled));
.With(o => o.Enabled, Enabled)
.With(o => o.Data, Data));
fixture.Customize<PolicyStatus>(composer => composer
.With(o => o.OrganizationId, orgId)
.With(o => o.Type, Type)
.With(o => o.Enabled, Enabled)
.With(o => o.Data, Data));
}
}
@ -30,15 +42,17 @@ public class PolicyAttribute : CustomizeAttribute
{
private readonly PolicyType _type;
private readonly bool _enabled;
private readonly string? _data;
public PolicyAttribute(PolicyType type, bool enabled = true)
public PolicyAttribute(PolicyType type, bool enabled = true, string? data = null)
{
_type = type;
_enabled = enabled;
_data = data;
}
public override ICustomization GetCustomization(ParameterInfo parameter)
{
return new PolicyCustomization(_type, _enabled);
return new PolicyCustomization(_type, _enabled, _data);
}
}

View File

@ -1,14 +1,16 @@
using AutoFixture;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Test.AdminConsole.AutoFixture;
using Bit.Core.Test.AutoFixture.OrganizationUserFixtures;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@ -29,11 +31,12 @@ public class AdminRecoverAccountCommandTests
Organization organization,
OrganizationUser organizationUser,
User user,
[Policy(PolicyType.ResetPassword, true)] PolicyStatus policy,
SutProvider<AdminRecoverAccountCommand> sutProvider)
{
// Arrange
SetupValidOrganization(sutProvider, organization);
SetupValidPolicy(sutProvider, organization);
SetupValidPolicy(sutProvider, organization, policy);
SetupValidOrganizationUser(organizationUser, organization.Id);
SetupValidUser(sutProvider, user, organizationUser);
SetupSuccessfulPasswordUpdate(sutProvider, user, newMasterPassword);
@ -87,25 +90,18 @@ public class AdminRecoverAccountCommandTests
Assert.Equal("Organization does not allow password reset.", exception.Message);
}
public static IEnumerable<object[]> InvalidPolicies => new object[][]
{
[new Policy { Type = PolicyType.ResetPassword, Enabled = false }], [null]
};
[Theory]
[BitMemberAutoData(nameof(InvalidPolicies))]
[BitAutoData]
public async Task RecoverAccountAsync_InvalidPolicy_ThrowsBadRequest(
Policy resetPasswordPolicy,
string newMasterPassword,
string key,
Organization organization,
[Policy(PolicyType.ResetPassword, false)] PolicyStatus policy,
SutProvider<AdminRecoverAccountCommand> sutProvider)
{
// Arrange
SetupValidOrganization(sutProvider, organization);
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword)
.Returns(resetPasswordPolicy);
SetupValidPolicy(sutProvider, organization, policy);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
@ -171,11 +167,12 @@ public class AdminRecoverAccountCommandTests
Organization organization,
string newMasterPassword,
string key,
[Policy(PolicyType.ResetPassword, true)] PolicyStatus policy,
SutProvider<AdminRecoverAccountCommand> sutProvider)
{
// Arrange
SetupValidOrganization(sutProvider, organization);
SetupValidPolicy(sutProvider, organization);
SetupValidPolicy(sutProvider, organization, policy);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
@ -190,11 +187,12 @@ public class AdminRecoverAccountCommandTests
string key,
Organization organization,
OrganizationUser organizationUser,
[Policy(PolicyType.ResetPassword, true)] PolicyStatus policy,
SutProvider<AdminRecoverAccountCommand> sutProvider)
{
// Arrange
SetupValidOrganization(sutProvider, organization);
SetupValidPolicy(sutProvider, organization);
SetupValidPolicy(sutProvider, organization, policy);
SetupValidOrganizationUser(organizationUser, organization.Id);
sutProvider.GetDependency<IUserService>()
.GetUserByIdAsync(organizationUser.UserId!.Value)
@ -213,11 +211,12 @@ public class AdminRecoverAccountCommandTests
Organization organization,
OrganizationUser organizationUser,
User user,
[Policy(PolicyType.ResetPassword, true)] PolicyStatus policy,
SutProvider<AdminRecoverAccountCommand> sutProvider)
{
// Arrange
SetupValidOrganization(sutProvider, organization);
SetupValidPolicy(sutProvider, organization);
SetupValidPolicy(sutProvider, organization, policy);
SetupValidOrganizationUser(organizationUser, organization.Id);
user.UsesKeyConnector = true;
sutProvider.GetDependency<IUserService>()
@ -238,11 +237,10 @@ public class AdminRecoverAccountCommandTests
.Returns(organization);
}
private static void SetupValidPolicy(SutProvider<AdminRecoverAccountCommand> sutProvider, Organization organization)
private static void SetupValidPolicy(SutProvider<AdminRecoverAccountCommand> sutProvider, Organization organization, PolicyStatus policy)
{
var policy = new Policy { Type = PolicyType.ResetPassword, Enabled = true };
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword)
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(organization.Id, PolicyType.ResetPassword)
.Returns(policy);
}

View File

@ -7,7 +7,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimed
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
@ -120,7 +119,7 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests
[Organization(useAutomaticUserConfirmation: true, planType: PlanType.EnterpriseAnnually)] Organization organization,
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser,
User user,
[Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy)
[Policy(PolicyType.AutomaticUserConfirmation)] PolicyStatus autoConfirmPolicy)
{
// Arrange
organizationUser.UserId = user.Id;
@ -137,8 +136,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests
Key = "test-key"
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation)
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation)
.Returns(autoConfirmPolicy);
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
@ -280,7 +279,7 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests
[Organization(useAutomaticUserConfirmation: true)] Organization organization,
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser,
Guid userId,
[Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy)
[Policy(PolicyType.AutomaticUserConfirmation)] PolicyStatus autoConfirmPolicy)
{
// Arrange
organizationUser.UserId = userId;
@ -303,8 +302,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests
PolicyType = PolicyType.TwoFactorAuthentication
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation)
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation)
.Returns(autoConfirmPolicy);
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
@ -334,7 +333,7 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests
[Organization(useAutomaticUserConfirmation: true)] Organization organization,
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser,
User user,
[Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy)
[Policy(PolicyType.AutomaticUserConfirmation)] PolicyStatus autoConfirmPolicy)
{
// Arrange
organizationUser.UserId = user.Id;
@ -351,8 +350,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests
Key = "test-key"
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation)
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation)
.Returns(autoConfirmPolicy);
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
@ -389,7 +388,7 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests
[Organization(useAutomaticUserConfirmation: true)] Organization organization,
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser,
User user,
[Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy)
[Policy(PolicyType.AutomaticUserConfirmation)] PolicyStatus autoConfirmPolicy)
{
// Arrange
organizationUser.UserId = user.Id;
@ -406,8 +405,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests
Key = "test-key"
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation)
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation)
.Returns(autoConfirmPolicy);
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
@ -448,7 +447,7 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests
[Organization(useAutomaticUserConfirmation: true)] Organization organization,
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser,
User user,
[Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy)
[Policy(PolicyType.AutomaticUserConfirmation)] PolicyStatus autoConfirmPolicy)
{
// Arrange
organizationUser.UserId = user.Id;
@ -465,8 +464,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests
Key = "test-key"
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation)
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation)
.Returns(autoConfirmPolicy);
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
@ -501,7 +500,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests
SutProvider<AutomaticallyConfirmOrganizationUsersValidator> sutProvider,
Organization organization,
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser,
Guid userId)
Guid userId,
[Policy(PolicyType.AutomaticUserConfirmation, false)] PolicyStatus policy)
{
// Arrange
organizationUser.UserId = userId;
@ -518,9 +518,9 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests
Key = "test-key"
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation)
.Returns((Policy)null);
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation)
.Returns(policy);
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
.TwoFactorIsEnabledAsync(Arg.Any<IEnumerable<Guid>>())
@ -545,7 +545,7 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests
[Organization(useAutomaticUserConfirmation: false)] Organization organization,
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser,
Guid userId,
[Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy)
[Policy(PolicyType.AutomaticUserConfirmation)] PolicyStatus autoConfirmPolicy)
{
// Arrange
organizationUser.UserId = userId;
@ -562,8 +562,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests
Key = "test-key"
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation)
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation)
.Returns(autoConfirmPolicy);
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
@ -589,7 +589,7 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests
[Organization(useAutomaticUserConfirmation: true)] Organization organization,
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser organizationUser,
User user,
[Policy(PolicyType.AutomaticUserConfirmation)] Policy autoConfirmPolicy)
[Policy(PolicyType.AutomaticUserConfirmation)] PolicyStatus autoConfirmPolicy)
{
// Arrange
organizationUser.UserId = user.Id;
@ -606,8 +606,8 @@ public class AutomaticallyConfirmOrganizationUsersValidatorTests
Key = "test-key"
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.AutomaticUserConfirmation)
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(organization.Id, PolicyType.AutomaticUserConfirmation)
.Returns(autoConfirmPolicy);
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()

View File

@ -1,7 +1,9 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
@ -9,6 +11,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Models.Mail;
using Bit.Core.Services;
using Bit.Core.Test.AdminConsole.AutoFixture;
using Bit.Core.Test.AutoFixture.OrganizationFixtures;
using Bit.Core.Tokens;
using Bit.Test.Common.AutoFixture;
@ -31,6 +34,7 @@ public class SendOrganizationInvitesCommandTests
Organization organization,
SsoConfig ssoConfig,
OrganizationUser invite,
[Policy(PolicyType.RequireSso, false)] PolicyStatus policy,
SutProvider<SendOrganizationInvitesCommand> sutProvider)
{
// Setup FakeDataProtectorTokenFactory for creating new tokens - this must come first in order to avoid resetting mocks
@ -45,7 +49,9 @@ public class SendOrganizationInvitesCommandTests
sutProvider.GetDependency<ISsoConfigRepository>().GetByOrganizationIdAsync(organization.Id).Returns(ssoConfig);
// Return null policy to mimic new org that's never turned on the require sso policy
sutProvider.GetDependency<IPolicyRepository>().GetManyByOrganizationIdAsync(organization.Id).ReturnsNull();
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(organization.Id, PolicyType.RequireSso)
.Returns(policy);
// Mock tokenable factory to return a token that expires in 5 days
sutProvider.GetDependency<IOrgUserInviteTokenableFactory>()

View File

@ -2,9 +2,9 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
@ -13,6 +13,7 @@ using Bit.Core.Auth.Services;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Test.AdminConsole.AutoFixture;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
@ -163,7 +164,8 @@ public class SsoConfigServiceTests
[Theory, BitAutoData]
public async Task SaveAsync_KeyConnector_SingleOrgNotEnabled_Throws(SutProvider<SsoConfigService> sutProvider,
Organization organization)
Organization organization,
[Policy(PolicyType.SingleOrg, false)] PolicyStatus policy)
{
var utcNow = DateTime.UtcNow;
@ -180,6 +182,9 @@ public class SsoConfigServiceTests
RevisionDate = utcNow.AddDays(-10),
};
sutProvider.GetDependency<IPolicyQuery>().RunAsync(
Arg.Any<Guid>(), PolicyType.SingleOrg).Returns(policy);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SaveAsync(ssoConfig, organization));
@ -191,7 +196,9 @@ public class SsoConfigServiceTests
[Theory, BitAutoData]
public async Task SaveAsync_KeyConnector_SsoPolicyNotEnabled_Throws(SutProvider<SsoConfigService> sutProvider,
Organization organization)
Organization organization,
[Policy(PolicyType.SingleOrg, true)] PolicyStatus singleOrgPolicy,
[Policy(PolicyType.RequireSso, false)] PolicyStatus requireSsoPolicy)
{
var utcNow = DateTime.UtcNow;
@ -208,11 +215,10 @@ public class SsoConfigServiceTests
RevisionDate = utcNow.AddDays(-10),
};
sutProvider.GetDependency<IPolicyRepository>().GetByOrganizationIdTypeAsync(
Arg.Any<Guid>(), PolicyType.SingleOrg).Returns(new Policy
{
Enabled = true
});
sutProvider.GetDependency<IPolicyQuery>().RunAsync(
Arg.Any<Guid>(), PolicyType.SingleOrg).Returns(singleOrgPolicy);
sutProvider.GetDependency<IPolicyQuery>().RunAsync(
Arg.Any<Guid>(), PolicyType.RequireSso).Returns(requireSsoPolicy);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SaveAsync(ssoConfig, organization));
@ -225,7 +231,8 @@ public class SsoConfigServiceTests
[Theory, BitAutoData]
public async Task SaveAsync_KeyConnector_SsoConfigNotEnabled_Throws(SutProvider<SsoConfigService> sutProvider,
Organization organization)
Organization organization,
[Policy(PolicyType.SingleOrg, true)] PolicyStatus policy)
{
var utcNow = DateTime.UtcNow;
@ -242,11 +249,8 @@ public class SsoConfigServiceTests
RevisionDate = utcNow.AddDays(-10),
};
sutProvider.GetDependency<IPolicyRepository>().GetByOrganizationIdTypeAsync(
Arg.Any<Guid>(), Arg.Any<PolicyType>()).Returns(new Policy
{
Enabled = true
});
sutProvider.GetDependency<IPolicyQuery>().RunAsync(
Arg.Any<Guid>(), Arg.Any<PolicyType>()).Returns(policy);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SaveAsync(ssoConfig, organization));
@ -259,7 +263,8 @@ public class SsoConfigServiceTests
[Theory, BitAutoData]
public async Task SaveAsync_KeyConnector_KeyConnectorAbilityNotEnabled_Throws(SutProvider<SsoConfigService> sutProvider,
Organization organization)
Organization organization,
[Policy(PolicyType.SingleOrg, true)] PolicyStatus policy)
{
var utcNow = DateTime.UtcNow;
@ -277,11 +282,8 @@ public class SsoConfigServiceTests
RevisionDate = utcNow.AddDays(-10),
};
sutProvider.GetDependency<IPolicyRepository>().GetByOrganizationIdTypeAsync(
Arg.Any<Guid>(), Arg.Any<PolicyType>()).Returns(new Policy
{
Enabled = true,
});
sutProvider.GetDependency<IPolicyQuery>().RunAsync(
Arg.Any<Guid>(), Arg.Any<PolicyType>()).Returns(policy);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SaveAsync(ssoConfig, organization));
@ -294,7 +296,8 @@ public class SsoConfigServiceTests
[Theory, BitAutoData]
public async Task SaveAsync_KeyConnector_Success(SutProvider<SsoConfigService> sutProvider,
Organization organization)
Organization organization,
[Policy(PolicyType.SingleOrg, true)] PolicyStatus policy)
{
var utcNow = DateTime.UtcNow;
@ -312,11 +315,8 @@ public class SsoConfigServiceTests
RevisionDate = utcNow.AddDays(-10),
};
sutProvider.GetDependency<IPolicyRepository>().GetByOrganizationIdTypeAsync(
Arg.Any<Guid>(), Arg.Any<PolicyType>()).Returns(new Policy
{
Enabled = true,
});
sutProvider.GetDependency<IPolicyQuery>().RunAsync(
Arg.Any<Guid>(), Arg.Any<PolicyType>()).Returns(policy);
await sutProvider.Sut.SaveAsync(ssoConfig, organization);

View File

@ -1,7 +1,8 @@
using System.Text;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
@ -13,6 +14,7 @@ using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterpri
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Test.AdminConsole.AutoFixture;
using Bit.Core.Tokens;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
@ -241,7 +243,8 @@ public class RegisterUserCommandTests
[BitAutoData(true, "sampleInitiationPath")]
[BitAutoData(true, "Secrets Manager trial")]
public async Task RegisterUserViaOrganizationInviteToken_ComplexHappyPath_Succeeds(bool addUserReferenceData, string initiationPath,
SutProvider<RegisterUserCommand> sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId, Policy twoFactorPolicy)
SutProvider<RegisterUserCommand> sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId,
[Policy(PolicyType.TwoFactorAuthentication, true)] PolicyStatus policy)
{
// Arrange
sutProvider.GetDependency<IGlobalSettings>()
@ -267,10 +270,9 @@ public class RegisterUserCommandTests
.GetByIdAsync(orgUserId)
.Returns(orgUser);
twoFactorPolicy.Enabled = true;
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(orgUser.OrganizationId, PolicyType.TwoFactorAuthentication)
.Returns(twoFactorPolicy);
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(orgUser.OrganizationId, PolicyType.TwoFactorAuthentication)
.Returns(policy);
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user, masterPasswordHash)
@ -286,9 +288,9 @@ public class RegisterUserCommandTests
.Received(1)
.GetByIdAsync(orgUserId);
await sutProvider.GetDependency<IPolicyRepository>()
await sutProvider.GetDependency<IPolicyQuery>()
.Received(1)
.GetByOrganizationIdTypeAsync(orgUser.OrganizationId, PolicyType.TwoFactorAuthentication);
.RunAsync(orgUser.OrganizationId, PolicyType.TwoFactorAuthentication);
sutProvider.GetDependency<IUserService>()
.Received(1)
@ -431,7 +433,8 @@ public class RegisterUserCommandTests
[Theory]
[BitAutoData]
public async Task RegisterUserViaOrganizationInviteToken_BlockedDomainFromDifferentOrg_ThrowsBadRequestException(
SutProvider<RegisterUserCommand> sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId)
SutProvider<RegisterUserCommand> sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId,
[Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy)
{
// Arrange
user.Email = "user@blocked-domain.com";
@ -463,6 +466,10 @@ public class RegisterUserCommandTests
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com", orgUser.OrganizationId)
.Returns(true);
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(Arg.Any<Guid>(), PolicyType.TwoFactorAuthentication)
.Returns(policy);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUserId));
@ -472,7 +479,8 @@ public class RegisterUserCommandTests
[Theory]
[BitAutoData]
public async Task RegisterUserViaOrganizationInviteToken_BlockedDomainFromSameOrg_Succeeds(
SutProvider<RegisterUserCommand> sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId)
SutProvider<RegisterUserCommand> sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId,
[Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy)
{
// Arrange
user.Email = "user@company-domain.com";
@ -509,6 +517,10 @@ public class RegisterUserCommandTests
.CreateUserAsync(user, masterPasswordHash)
.Returns(IdentityResult.Success);
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(Arg.Any<Guid>(), PolicyType.TwoFactorAuthentication)
.Returns(policy);
// Act
var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUserId);
@ -1245,6 +1257,7 @@ public class RegisterUserCommandTests
OrganizationUser orgUser,
string orgInviteToken,
string masterPasswordHash,
[Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
@ -1259,9 +1272,9 @@ public class RegisterUserCommandTests
.GetByIdAsync(orgUser.Id)
.Returns(orgUser);
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), PolicyType.TwoFactorAuthentication)
.Returns((Policy)null);
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(Arg.Any<Guid>(), PolicyType.TwoFactorAuthentication)
.Returns(policy);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(orgUser.OrganizationId)
@ -1331,6 +1344,7 @@ public class RegisterUserCommandTests
OrganizationUser orgUser,
string masterPasswordHash,
string orgInviteToken,
[Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
@ -1346,9 +1360,9 @@ public class RegisterUserCommandTests
.GetByIdAsync(orgUser.Id)
.Returns(orgUser);
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), PolicyType.TwoFactorAuthentication)
.Returns((Policy)null);
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(Arg.Any<Guid>(), PolicyType.TwoFactorAuthentication)
.Returns(policy);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(orgUser.OrganizationId)

View File

@ -0,0 +1,777 @@
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Test.Billing.Mocks.Plans;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Stripe;
using Xunit;
using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan;
namespace Bit.Core.Test.Billing.Premium.Commands;
public class PreviewPremiumUpgradeProrationCommandTests
{
private readonly ILogger<PreviewPremiumUpgradeProrationCommand> _logger = Substitute.For<ILogger<PreviewPremiumUpgradeProrationCommand>>();
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
private readonly PreviewPremiumUpgradeProrationCommand _command;
public PreviewPremiumUpgradeProrationCommandTests()
{
_command = new PreviewPremiumUpgradeProrationCommand(
_logger,
_pricingClient,
_stripeAdapter);
}
[Theory, BitAutoData]
public async Task Run_UserWithoutPremium_ReturnsBadRequest(User user, BillingAddress billingAddress)
{
// Arrange
user.Premium = false;
// Act
var result = await _command.Run(user, PlanType.TeamsAnnually, billingAddress);
// Assert
Assert.True(result.IsT1);
var badRequest = result.AsT1;
Assert.Equal("User does not have an active Premium subscription.", badRequest.Response);
}
[Theory, BitAutoData]
public async Task Run_UserWithoutGatewaySubscriptionId_ReturnsBadRequest(User user, BillingAddress billingAddress)
{
// Arrange
user.Premium = true;
user.GatewaySubscriptionId = null;
// Act
var result = await _command.Run(user, PlanType.TeamsAnnually, billingAddress);
// Assert
Assert.True(result.IsT1);
var badRequest = result.AsT1;
Assert.Equal("User does not have an active Premium subscription.", badRequest.Response);
}
[Theory, BitAutoData]
public async Task Run_ValidUpgrade_ReturnsProrationAmounts(User user, BillingAddress billingAddress)
{
// Arrange - Setup valid Premium user
user.Premium = true;
user.GatewaySubscriptionId = "sub_123";
user.GatewayCustomerId = "cus_123";
// Setup Premium plans
var premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "premium-annually",
Price = 10m,
Provided = 1
},
Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "storage-gb-annually",
Price = 4m,
Provided = 1
}
};
var premiumPlans = new List<PremiumPlan> { premiumPlan };
// Setup current Stripe subscription
var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var currentPeriodEnd = now.AddMonths(6);
var currentSubscription = new Subscription
{
Id = "sub_123",
Customer = new Customer
{
Id = "cus_123",
Discount = null
},
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new()
{
Id = "si_premium",
Price = new Price { Id = "premium-annually" },
CurrentPeriodEnd = currentPeriodEnd
}
}
}
};
// Setup target organization plan
var targetPlan = new TeamsPlan(isAnnual: true);
// Setup invoice preview response
var invoice = new Invoice
{
Total = 5000, // $50.00
TotalTaxes = new List<InvoiceTotalTax>
{
new() { Amount = 500 } // $5.00
},
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem>
{
new() { Amount = 5000 } // $50.00 for new plan
}
},
PeriodEnd = now
};
// Configure mocks
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan);
_stripeAdapter.GetSubscriptionAsync(
"sub_123",
Arg.Any<SubscriptionGetOptions>())
.Returns(currentSubscription);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(invoice);
// Act
var result = await _command.Run(user, PlanType.TeamsAnnually, billingAddress);
// Assert
Assert.True(result.IsT0);
var proration = result.AsT0;
Assert.Equal(50.00m, proration.NewPlanProratedAmount);
Assert.Equal(0m, proration.Credit);
Assert.Equal(5.00m, proration.Tax);
Assert.Equal(50.00m, proration.Total);
Assert.Equal(6, proration.NewPlanProratedMonths); // 6 months remaining
}
[Theory, BitAutoData]
public async Task Run_ValidUpgrade_ExtractsProrationCredit(User user, BillingAddress billingAddress)
{
// Arrange
user.Premium = true;
user.GatewaySubscriptionId = "sub_123";
user.GatewayCustomerId = "cus_123";
var premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "premium-annually",
Price = 10m,
Provided = 1
},
Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "storage-gb-annually",
Price = 4m,
Provided = 1
}
};
var premiumPlans = new List<PremiumPlan> { premiumPlan };
// Use fixed time to avoid DateTime.UtcNow differences
var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var currentPeriodEnd = now.AddDays(45); // 1.5 months ~ 2 months rounded
var currentSubscription = new Subscription
{
Id = "sub_123",
Customer = new Customer { Id = "cus_123", Discount = null },
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = currentPeriodEnd }
}
}
};
var targetPlan = new TeamsPlan(isAnnual: true);
// Invoice with negative line item (proration credit)
var invoice = new Invoice
{
Total = 4000, // $40.00
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 400 } }, // $4.00
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem>
{
new() { Amount = -1000 }, // -$10.00 credit from unused Premium
new() { Amount = 5000 } // $50.00 for new plan
}
},
PeriodEnd = now
};
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan);
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>())
.Returns(currentSubscription);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(invoice);
// Act
var result = await _command.Run(user, PlanType.TeamsAnnually, billingAddress);
// Assert
Assert.True(result.IsT0);
var proration = result.AsT0;
Assert.Equal(50.00m, proration.NewPlanProratedAmount);
Assert.Equal(10.00m, proration.Credit); // Proration credit
Assert.Equal(4.00m, proration.Tax);
Assert.Equal(40.00m, proration.Total);
Assert.Equal(2, proration.NewPlanProratedMonths); // 45 days rounds to 2 months
}
[Theory, BitAutoData]
public async Task Run_ValidUpgrade_AlwaysUsesOneSeat(User user, BillingAddress billingAddress)
{
// Arrange
user.Premium = true;
user.GatewaySubscriptionId = "sub_123";
user.GatewayCustomerId = "cus_123";
var premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "premium-annually",
Price = 10m,
Provided = 1
},
Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "storage-gb-annually",
Price = 4m,
Provided = 1
}
};
var premiumPlans = new List<PremiumPlan> { premiumPlan };
var currentSubscription = new Subscription
{
Id = "sub_123",
Customer = new Customer { Id = "cus_123", Discount = null },
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }
}
}
};
var targetPlan = new TeamsPlan(isAnnual: true);
var invoice = new Invoice
{
Total = 5000,
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 500 } },
Lines = new StripeList<InvoiceLineItem> { Data = new List<InvoiceLineItem> { new() { Amount = 5000 } } },
PeriodEnd = DateTime.UtcNow
};
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan);
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>())
.Returns(currentSubscription);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(invoice);
// Act
await _command.Run(user, PlanType.TeamsAnnually, billingAddress);
// Assert - Verify that the subscription item quantity is always 1 and has Id
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
Arg.Is<InvoiceCreatePreviewOptions>(options =>
options.SubscriptionDetails.Items.Any(item =>
item.Id == "si_premium" &&
item.Price == targetPlan.PasswordManager.StripeSeatPlanId &&
item.Quantity == 1)));
}
[Theory, BitAutoData]
public async Task Run_ValidUpgrade_DeletesPremiumSubscriptionItems(User user, BillingAddress billingAddress)
{
// Arrange
user.Premium = true;
user.GatewaySubscriptionId = "sub_123";
user.GatewayCustomerId = "cus_123";
var premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "premium-annually",
Price = 10m,
Provided = 1
},
Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "storage-gb-annually",
Price = 4m,
Provided = 1
}
};
var premiumPlans = new List<PremiumPlan> { premiumPlan };
var currentSubscription = new Subscription
{
Id = "sub_123",
Customer = new Customer { Id = "cus_123", Discount = null },
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = "si_password_manager", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) },
new() { Id = "si_storage", Price = new Price { Id = "storage-gb-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }
}
}
};
var targetPlan = new TeamsPlan(isAnnual: true);
var invoice = new Invoice
{
Total = 5000,
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 500 } },
Lines = new StripeList<InvoiceLineItem> { Data = new List<InvoiceLineItem> { new() { Amount = 5000 } } },
PeriodEnd = DateTime.UtcNow
};
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan);
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>())
.Returns(currentSubscription);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(invoice);
// Act
await _command.Run(user, PlanType.TeamsAnnually, billingAddress);
// Assert - Verify password manager item is modified and storage item is deleted
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
Arg.Is<InvoiceCreatePreviewOptions>(options =>
// Password manager item should be modified to new plan price, not deleted
options.SubscriptionDetails.Items.Any(item =>
item.Id == "si_password_manager" &&
item.Price == targetPlan.PasswordManager.StripeSeatPlanId &&
item.Deleted != true) &&
// Storage item should be deleted
options.SubscriptionDetails.Items.Any(item =>
item.Id == "si_storage" && item.Deleted == true)));
}
[Theory, BitAutoData]
public async Task Run_NonSeatBasedPlan_UsesStripePlanId(User user, BillingAddress billingAddress)
{
// Arrange
user.Premium = true;
user.GatewaySubscriptionId = "sub_123";
user.GatewayCustomerId = "cus_123";
var premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "premium-annually",
Price = 10m,
Provided = 1
},
Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "storage-gb-annually",
Price = 4m,
Provided = 1
}
};
var premiumPlans = new List<PremiumPlan> { premiumPlan };
var currentSubscription = new Subscription
{
Id = "sub_123",
Customer = new Customer { Id = "cus_123", Discount = null },
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }
}
}
};
var targetPlan = new FamiliesPlan(); // families is non seat based
var invoice = new Invoice
{
Total = 5000,
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 500 } },
Lines = new StripeList<InvoiceLineItem> { Data = new List<InvoiceLineItem> { new() { Amount = 5000 } } },
PeriodEnd = DateTime.UtcNow
};
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(targetPlan);
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>())
.Returns(currentSubscription);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(invoice);
// Act
await _command.Run(user, PlanType.FamiliesAnnually, billingAddress);
// Assert - Verify non-seat-based plan uses StripePlanId with quantity 1 and modifies existing item
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
Arg.Is<InvoiceCreatePreviewOptions>(options =>
options.SubscriptionDetails.Items.Any(item =>
item.Id == "si_premium" &&
item.Price == targetPlan.PasswordManager.StripePlanId &&
item.Quantity == 1)));
}
[Theory, BitAutoData]
public async Task Run_ValidUpgrade_CreatesCorrectInvoicePreviewOptions(User user, BillingAddress billingAddress)
{
// Arrange
user.Premium = true;
user.GatewaySubscriptionId = "sub_123";
user.GatewayCustomerId = "cus_123";
billingAddress.Country = "US";
billingAddress.PostalCode = "12345";
var premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "premium-annually",
Price = 10m,
Provided = 1
},
Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "storage-gb-annually",
Price = 4m,
Provided = 1
}
};
var premiumPlans = new List<PremiumPlan> { premiumPlan };
var currentSubscription = new Subscription
{
Id = "sub_123",
Customer = new Customer { Id = "cus_123", Discount = null },
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }
}
}
};
var targetPlan = new TeamsPlan(isAnnual: true);
var invoice = new Invoice
{
Total = 5000,
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 500 } },
Lines = new StripeList<InvoiceLineItem> { Data = new List<InvoiceLineItem> { new() { Amount = 5000 } } },
PeriodEnd = DateTime.UtcNow
};
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan);
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>())
.Returns(currentSubscription);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(invoice);
// Act
await _command.Run(user, PlanType.TeamsAnnually, billingAddress);
// Assert - Verify all invoice preview options are correct
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
Arg.Is<InvoiceCreatePreviewOptions>(options =>
options.AutomaticTax.Enabled == true &&
options.Customer == "cus_123" &&
options.Subscription == "sub_123" &&
options.CustomerDetails.Address.Country == "US" &&
options.CustomerDetails.Address.PostalCode == "12345" &&
options.SubscriptionDetails.ProrationBehavior == "always_invoice"));
}
[Theory, BitAutoData]
public async Task Run_SeatBasedPlan_UsesStripeSeatPlanId(User user, BillingAddress billingAddress)
{
// Arrange
user.Premium = true;
user.GatewaySubscriptionId = "sub_123";
user.GatewayCustomerId = "cus_123";
var premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "premium-annually",
Price = 10m,
Provided = 1
},
Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "storage-gb-annually",
Price = 4m,
Provided = 1
}
};
var premiumPlans = new List<PremiumPlan> { premiumPlan };
var currentSubscription = new Subscription
{
Id = "sub_123",
Customer = new Customer { Id = "cus_123", Discount = null },
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }
}
}
};
// Use Teams which is seat-based
var targetPlan = new TeamsPlan(isAnnual: true);
var invoice = new Invoice
{
Total = 5000,
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 500 } },
Lines = new StripeList<InvoiceLineItem> { Data = new List<InvoiceLineItem> { new() { Amount = 5000 } } },
PeriodEnd = DateTime.UtcNow
};
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan);
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>())
.Returns(currentSubscription);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(invoice);
// Act
await _command.Run(user, PlanType.TeamsAnnually, billingAddress);
// Assert - Verify seat-based plan uses StripeSeatPlanId with quantity 1 and modifies existing item
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
Arg.Is<InvoiceCreatePreviewOptions>(options =>
options.SubscriptionDetails.Items.Any(item =>
item.Id == "si_premium" &&
item.Price == targetPlan.PasswordManager.StripeSeatPlanId &&
item.Quantity == 1)));
}
[Theory]
[InlineData(0, 1)] // Less than 15 days, minimum 1 month
[InlineData(1, 1)] // 1 day = 1 month minimum
[InlineData(14, 1)] // 14 days = 1 month minimum
[InlineData(15, 1)] // 15 days rounds to 1 month
[InlineData(30, 1)] // 30 days = 1 month
[InlineData(44, 1)] // 44 days rounds to 1 month
[InlineData(45, 2)] // 45 days rounds to 2 months
[InlineData(60, 2)] // 60 days = 2 months
[InlineData(90, 3)] // 90 days = 3 months
[InlineData(180, 6)] // 180 days = 6 months
[InlineData(365, 12)] // 365 days rounds to 12 months
public async Task Run_ValidUpgrade_CalculatesNewPlanProratedMonthsCorrectly(int daysRemaining, int expectedMonths)
{
// Arrange
var user = new User
{
Premium = true,
GatewaySubscriptionId = "sub_123",
GatewayCustomerId = "cus_123"
};
var billingAddress = new Core.Billing.Payment.Models.BillingAddress
{
Country = "US",
PostalCode = "12345"
};
var premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "premium-annually",
Price = 10m,
Provided = 1
},
Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "storage-gb-annually",
Price = 4m,
Provided = 1
}
};
var premiumPlans = new List<PremiumPlan> { premiumPlan };
// Use fixed time to avoid DateTime.UtcNow differences
var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var currentPeriodEnd = now.AddDays(daysRemaining);
var currentSubscription = new Subscription
{
Id = "sub_123",
Customer = new Customer { Id = "cus_123", Discount = null },
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = currentPeriodEnd }
}
}
};
var targetPlan = new TeamsPlan(isAnnual: true);
var invoice = new Invoice
{
Total = 5000,
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 500 } },
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Amount = 5000 } }
},
PeriodEnd = now
};
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan);
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>())
.Returns(currentSubscription);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(invoice);
// Act
var result = await _command.Run(user, PlanType.TeamsAnnually, billingAddress);
// Assert
Assert.True(result.IsT0);
var proration = result.AsT0;
Assert.Equal(expectedMonths, proration.NewPlanProratedMonths);
}
[Theory, BitAutoData]
public async Task Run_ValidUpgrade_ReturnsNewPlanProratedAmountCorrectly(User user, BillingAddress billingAddress)
{
// Arrange
user.Premium = true;
user.GatewaySubscriptionId = "sub_123";
user.GatewayCustomerId = "cus_123";
var premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "premium-annually",
Price = 10m,
Provided = 1
},
Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "storage-gb-annually",
Price = 4m,
Provided = 1
}
};
var premiumPlans = new List<PremiumPlan> { premiumPlan };
var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var currentPeriodEnd = now.AddMonths(3);
var currentSubscription = new Subscription
{
Id = "sub_123",
Customer = new Customer { Id = "cus_123", Discount = null },
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = "si_premium", Price = new Price { Id = "premium-annually" }, CurrentPeriodEnd = currentPeriodEnd }
}
}
};
var targetPlan = new TeamsPlan(isAnnual: true);
// Invoice showing new plan cost, credit, and net
var invoice = new Invoice
{
Total = 4500, // $45.00 net after $5 credit
TotalTaxes = new List<InvoiceTotalTax> { new() { Amount = 450 } }, // $4.50
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem>
{
new() { Amount = -500 }, // -$5.00 credit
new() { Amount = 5000 } // $50.00 for new plan
}
},
PeriodEnd = now
};
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(targetPlan);
_stripeAdapter.GetSubscriptionAsync("sub_123", Arg.Any<SubscriptionGetOptions>())
.Returns(currentSubscription);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(invoice);
// Act
var result = await _command.Run(user, PlanType.TeamsAnnually, billingAddress);
// Assert
Assert.True(result.IsT0);
var proration = result.AsT0;
Assert.Equal(50.00m, proration.NewPlanProratedAmount);
Assert.Equal(5.00m, proration.Credit);
Assert.Equal(4.50m, proration.Tax);
Assert.Equal(45.00m, proration.Total);
}
}

View File

@ -37,7 +37,6 @@ public class UpgradePremiumToOrganizationCommandTests
NameLocalizationKey = "";
DescriptionLocalizationKey = "";
CanBeUsedByBusiness = true;
TrialPeriodDays = null;
HasSelfHost = false;
HasPolicies = false;
HasGroups = false;
@ -86,10 +85,8 @@ public class UpgradePremiumToOrganizationCommandTests
string? stripePlanId = null,
string? stripeSeatPlanId = null,
string? stripePremiumAccessPlanId = null,
string? stripeStoragePlanId = null)
{
return new TestPlan(planType, stripePlanId, stripeSeatPlanId, stripePremiumAccessPlanId, stripeStoragePlanId);
}
string? stripeStoragePlanId = null) =>
new TestPlan(planType, stripePlanId, stripeSeatPlanId, stripePremiumAccessPlanId, stripeStoragePlanId);
private static PremiumPlan CreateTestPremiumPlan(
string seatPriceId = "premium-annually",
@ -151,6 +148,9 @@ public class UpgradePremiumToOrganizationCommandTests
_applicationCacheService);
}
private static Core.Billing.Payment.Models.BillingAddress CreateTestBillingAddress() =>
new() { Country = "US", PostalCode = "12345" };
[Theory, BitAutoData]
public async Task Run_UserNotPremium_ReturnsBadRequest(User user)
{
@ -158,7 +158,7 @@ public class UpgradePremiumToOrganizationCommandTests
user.Premium = false;
// Act
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress());
// Assert
Assert.True(result.IsT1);
@ -174,7 +174,7 @@ public class UpgradePremiumToOrganizationCommandTests
user.GatewaySubscriptionId = null;
// Act
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress());
// Assert
Assert.True(result.IsT1);
@ -190,7 +190,7 @@ public class UpgradePremiumToOrganizationCommandTests
user.GatewaySubscriptionId = "";
// Act
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress());
// Assert
Assert.True(result.IsT1);
@ -245,7 +245,7 @@ public class UpgradePremiumToOrganizationCommandTests
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
// Act
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress());
// Assert
Assert.True(result.IsT0);
@ -253,9 +253,8 @@ public class UpgradePremiumToOrganizationCommandTests
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.Items.Count == 2 && // 1 deleted + 1 seat (no storage)
opts.Items.Any(i => i.Deleted == true) &&
opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1)));
opts.Items.Count == 1 && // Only 1 item: modify existing password manager item (no storage to delete)
opts.Items.Any(i => i.Id == "si_premium" && i.Price == "teams-seat-annually" && i.Quantity == 1 && i.Deleted != true)));
await _organizationRepository.Received(1).CreateAsync(Arg.Is<Organization>(o =>
o.Name == "My Organization" &&
@ -320,7 +319,7 @@ public class UpgradePremiumToOrganizationCommandTests
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
// Act
var result = await _command.Run(user, "My Families Org", "encrypted-key", PlanType.FamiliesAnnually);
var result = await _command.Run(user, "My Families Org", "encrypted-key", PlanType.FamiliesAnnually, CreateTestBillingAddress());
// Assert
Assert.True(result.IsT0);
@ -328,9 +327,8 @@ public class UpgradePremiumToOrganizationCommandTests
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.Items.Count == 2 && // 1 deleted + 1 plan
opts.Items.Any(i => i.Deleted == true) &&
opts.Items.Any(i => i.Price == "families-plan-annually" && i.Quantity == 1)));
opts.Items.Count == 1 && // Only 1 item: modify existing password manager item (no storage to delete)
opts.Items.Any(i => i.Id == "si_premium" && i.Price == "families-plan-annually" && i.Quantity == 1 && i.Deleted != true)));
await _organizationRepository.Received(1).CreateAsync(Arg.Is<Organization>(o =>
o.Name == "My Families Org"));
@ -383,7 +381,7 @@ public class UpgradePremiumToOrganizationCommandTests
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
// Act
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress());
// Assert
Assert.True(result.IsT0);
@ -392,11 +390,6 @@ public class UpgradePremiumToOrganizationCommandTests
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId) &&
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPremiumPriceId) &&
opts.Metadata[StripeConstants.MetadataKeys.PreviousPremiumPriceId] == "premium-annually" &&
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPeriodEndDate) &&
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) &&
opts.Metadata[StripeConstants.MetadataKeys.PreviousAdditionalStorage] == "0" &&
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.UserId) &&
opts.Metadata[StripeConstants.MetadataKeys.UserId] == string.Empty)); // Removes userId to unlink from User
}
@ -453,19 +446,18 @@ public class UpgradePremiumToOrganizationCommandTests
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
// Act
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress());
// Assert
Assert.True(result.IsT0);
// Verify that BOTH legacy items (password manager + storage) are deleted by ID
// Verify that legacy password manager item is modified and legacy storage is deleted
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.Items.Count == 3 && // 2 deleted (legacy PM + legacy storage) + 1 new seat
opts.Items.Count(i => i.Deleted == true && i.Id == "si_premium_legacy") == 1 && // Legacy PM deleted
opts.Items.Count(i => i.Deleted == true && i.Id == "si_storage_legacy") == 1 && // Legacy storage deleted
opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1)));
opts.Items.Count == 2 && // 1 modified (legacy PM to new price) + 1 deleted (legacy storage)
opts.Items.Count(i => i.Id == "si_premium_legacy" && i.Price == "teams-seat-annually" && i.Quantity == 1 && i.Deleted != true) == 1 && // Legacy PM modified
opts.Items.Count(i => i.Deleted == true && i.Id == "si_storage_legacy") == 1)); // Legacy storage deleted
}
[Theory, BitAutoData]
@ -520,20 +512,19 @@ public class UpgradePremiumToOrganizationCommandTests
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
// Act
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress());
// Assert
Assert.True(result.IsT0);
// Verify that ONLY the premium password manager item is deleted (not other products)
// Note: We delete the specific premium item by ID, so other products are untouched
// Verify that ONLY the premium password manager item is modified (not other products)
// Note: We modify the specific premium item by ID, so other products are untouched
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.Items.Count == 2 && // 1 deleted (premium password manager) + 1 new seat
opts.Items.Count(i => i.Deleted == true && i.Id == "si_premium") == 1 && // Premium item deleted by ID
opts.Items.Count(i => i.Id == "si_other_product") == 0 && // Other product NOT in update (untouched)
opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1)));
opts.Items.Count == 1 && // Only modify premium password manager item
opts.Items.Count(i => i.Id == "si_premium" && i.Price == "teams-seat-annually" && i.Quantity == 1 && i.Deleted != true) == 1 && // Premium item modified
opts.Items.Count(i => i.Id == "si_other_product") == 0)); // Other product NOT in update (untouched)
}
[Theory, BitAutoData]
@ -589,7 +580,7 @@ public class UpgradePremiumToOrganizationCommandTests
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
// Act
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress());
// Assert
Assert.True(result.IsT0);
@ -598,10 +589,8 @@ public class UpgradePremiumToOrganizationCommandTests
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) &&
opts.Metadata[StripeConstants.MetadataKeys.PreviousAdditionalStorage] == "5" &&
opts.Items.Count == 3 && // 2 deleted (premium + storage) + 1 new seat
opts.Items.Count(i => i.Deleted == true) == 2));
opts.Items.Count == 2 && // 1 modified (premium to new price) + 1 deleted (storage)
opts.Items.Count(i => i.Deleted == true) == 1));
}
[Theory, BitAutoData]
@ -636,11 +625,385 @@ public class UpgradePremiumToOrganizationCommandTests
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
// Act
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress());
// Assert
Assert.True(result.IsT1);
var badRequest = result.AsT1;
Assert.Equal("Premium subscription item not found.", badRequest.Response);
Assert.Equal("Premium subscription password manager item not found.", badRequest.Response);
}
[Theory, BitAutoData]
public async Task Run_UpdatesCustomerBillingAddress(User user)
{
// Arrange
user.Premium = true;
user.GatewaySubscriptionId = "sub_123";
user.GatewayCustomerId = "cus_123";
var mockSubscription = new Subscription
{
Id = "sub_123",
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new SubscriptionItem
{
Id = "si_premium",
Price = new Price { Id = "premium-annually" }
}
}
},
Metadata = new Dictionary<string, string>()
};
var mockPremiumPlans = CreateTestPremiumPlansList();
var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually");
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription);
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>()).Returns(mockSubscription);
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(Task.FromResult(new Customer()));
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).Returns(Task.CompletedTask);
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
var billingAddress = new Core.Billing.Payment.Models.BillingAddress { Country = "US", PostalCode = "12345" };
// Act
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, billingAddress);
// Assert
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).UpdateCustomerAsync(
"cus_123",
Arg.Is<CustomerUpdateOptions>(opts =>
opts.Address.Country == "US" &&
opts.Address.PostalCode == "12345"));
}
[Theory, BitAutoData]
public async Task Run_EnablesAutomaticTaxOnSubscription(User user)
{
// Arrange
user.Premium = true;
user.GatewaySubscriptionId = "sub_123";
user.GatewayCustomerId = "cus_123";
var mockSubscription = new Subscription
{
Id = "sub_123",
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new SubscriptionItem
{
Id = "si_premium",
Price = new Price { Id = "premium-annually" }
}
}
},
Metadata = new Dictionary<string, string>()
};
var mockPremiumPlans = CreateTestPremiumPlansList();
var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually");
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription);
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>()).Returns(mockSubscription);
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(Task.FromResult(new Customer()));
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).Returns(Task.CompletedTask);
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
// Act
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress());
// Assert
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.AutomaticTax != null &&
opts.AutomaticTax.Enabled == true));
}
[Theory, BitAutoData]
public async Task Run_UsesAlwaysInvoiceProrationBehavior(User user)
{
// Arrange
user.Premium = true;
user.GatewaySubscriptionId = "sub_123";
user.GatewayCustomerId = "cus_123";
var mockSubscription = new Subscription
{
Id = "sub_123",
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new SubscriptionItem
{
Id = "si_premium",
Price = new Price { Id = "premium-annually" }
}
}
},
Metadata = new Dictionary<string, string>()
};
var mockPremiumPlans = CreateTestPremiumPlansList();
var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually");
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription);
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>()).Returns(mockSubscription);
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(Task.FromResult(new Customer()));
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).Returns(Task.CompletedTask);
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
// Act
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress());
// Assert
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.ProrationBehavior == "always_invoice"));
}
[Theory, BitAutoData]
public async Task Run_ModifiesExistingSubscriptionItem_NotDeleteAndRecreate(User user)
{
// Arrange
user.Premium = true;
user.GatewaySubscriptionId = "sub_123";
user.GatewayCustomerId = "cus_123";
var mockSubscription = new Subscription
{
Id = "sub_123",
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new SubscriptionItem
{
Id = "si_premium",
Price = new Price { Id = "premium-annually" }
}
}
},
Metadata = new Dictionary<string, string>()
};
var mockPremiumPlans = CreateTestPremiumPlansList();
var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually");
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription);
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>()).Returns(mockSubscription);
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(Task.FromResult(new Customer()));
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).Returns(Task.CompletedTask);
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
// Act
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress());
// Assert
Assert.True(result.IsT0);
// Verify that the subscription item was modified, not deleted
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(opts =>
// Should have an item with the original ID being modified
opts.Items.Any(item =>
item.Id == "si_premium" &&
item.Price == "teams-seat-annually" &&
item.Quantity == 1 &&
item.Deleted != true)));
}
[Theory, BitAutoData]
public async Task Run_CreatesOrganizationWithCorrectSettings(User user)
{
// Arrange
user.Premium = true;
user.GatewaySubscriptionId = "sub_123";
user.GatewayCustomerId = "cus_123";
var mockSubscription = new Subscription
{
Id = "sub_123",
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new SubscriptionItem
{
Id = "si_premium",
Price = new Price { Id = "premium-annually" }
}
}
},
Metadata = new Dictionary<string, string>()
};
var mockPremiumPlans = CreateTestPremiumPlansList();
var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually");
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription);
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>()).Returns(mockSubscription);
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(Task.FromResult(new Customer()));
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).Returns(Task.CompletedTask);
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
// Act
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress());
// Assert
Assert.True(result.IsT0);
await _organizationRepository.Received(1).CreateAsync(
Arg.Is<Organization>(org =>
org.Name == "My Organization" &&
org.BillingEmail == user.Email &&
org.PlanType == PlanType.TeamsAnnually &&
org.Seats == 1 &&
org.Gateway == GatewayType.Stripe &&
org.GatewayCustomerId == "cus_123" &&
org.GatewaySubscriptionId == "sub_123" &&
org.Enabled == true));
}
[Theory, BitAutoData]
public async Task Run_CreatesOrganizationApiKeyWithCorrectType(User user)
{
// Arrange
user.Premium = true;
user.GatewaySubscriptionId = "sub_123";
user.GatewayCustomerId = "cus_123";
var mockSubscription = new Subscription
{
Id = "sub_123",
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new SubscriptionItem
{
Id = "si_premium",
Price = new Price { Id = "premium-annually" }
}
}
},
Metadata = new Dictionary<string, string>()
};
var mockPremiumPlans = CreateTestPremiumPlansList();
var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually");
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription);
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>()).Returns(mockSubscription);
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(Task.FromResult(new Customer()));
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).Returns(Task.CompletedTask);
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
// Act
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress());
// Assert
Assert.True(result.IsT0);
await _organizationApiKeyRepository.Received(1).CreateAsync(
Arg.Is<OrganizationApiKey>(apiKey =>
apiKey.Type == OrganizationApiKeyType.Default &&
!string.IsNullOrEmpty(apiKey.ApiKey)));
}
[Theory, BitAutoData]
public async Task Run_CreatesOrganizationUserAsOwnerWithAllPermissions(User user)
{
// Arrange
user.Premium = true;
user.GatewaySubscriptionId = "sub_123";
user.GatewayCustomerId = "cus_123";
var mockSubscription = new Subscription
{
Id = "sub_123",
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new SubscriptionItem
{
Id = "si_premium",
Price = new Price { Id = "premium-annually" }
}
}
},
Metadata = new Dictionary<string, string>()
};
var mockPremiumPlans = CreateTestPremiumPlansList();
var mockPlan = CreateTestPlan(PlanType.TeamsAnnually, stripeSeatPlanId: "teams-seat-annually");
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(mockSubscription);
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>()).Returns(mockSubscription);
_stripeAdapter.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>()).Returns(Task.FromResult(new Customer()));
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).Returns(Task.CompletedTask);
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
// Act
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually, CreateTestBillingAddress());
// Assert
Assert.True(result.IsT0);
await _organizationUserRepository.Received(1).CreateAsync(
Arg.Is<OrganizationUser>(orgUser =>
orgUser.UserId == user.Id &&
orgUser.Type == OrganizationUserType.Owner &&
orgUser.Status == OrganizationUserStatusType.Confirmed));
}
}

View File

@ -1,4 +1,7 @@
using Bit.Core.Billing.Enums;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Exceptions;
@ -9,6 +12,7 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
using Bit.Core.Test.AdminConsole.AutoFixture;
using Bit.Core.Test.AutoFixture.OrganizationFixtures;
using Bit.Core.Test.Billing.Mocks;
using Bit.Test.Common.AutoFixture;
@ -72,8 +76,12 @@ public class UpgradeOrganizationPlanCommandTests
[Theory]
[FreeOrganizationUpgradeCustomize, BitAutoData]
public async Task UpgradePlan_Passes(Organization organization, OrganizationUpgrade upgrade,
[Policy(PolicyType.ResetPassword, false)] PolicyStatus policy,
SutProvider<UpgradeOrganizationPlanCommand> sutProvider)
{
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>())
.Returns(policy);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType).Returns(MockPlans.Get(organization.PlanType));
upgrade.AdditionalSmSeats = 10;
@ -100,6 +108,7 @@ public class UpgradeOrganizationPlanCommandTests
PlanType planType,
Organization organization,
OrganizationUpgrade organizationUpgrade,
[Policy(PolicyType.ResetPassword, false)] PolicyStatus policy,
SutProvider<UpgradeOrganizationPlanCommand> sutProvider)
{
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
@ -116,6 +125,9 @@ public class UpgradeOrganizationPlanCommandTests
organizationUpgrade.Plan = planType;
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organizationUpgrade.Plan).Returns(MockPlans.Get(organizationUpgrade.Plan));
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>())
.Returns(policy);
sutProvider.GetDependency<IOrganizationRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
{
@ -141,15 +153,20 @@ public class UpgradeOrganizationPlanCommandTests
[BitAutoData(PlanType.TeamsAnnually)]
[BitAutoData(PlanType.TeamsStarter)]
public async Task UpgradePlan_SM_Passes(PlanType planType, Organization organization, OrganizationUpgrade upgrade,
[Policy(PolicyType.ResetPassword, false)] PolicyStatus policy,
SutProvider<UpgradeOrganizationPlanCommand> sutProvider)
{
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType).Returns(MockPlans.Get(organization.PlanType));
upgrade.Plan = planType;
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(upgrade.Plan).Returns(MockPlans.Get(upgrade.Plan));
var plan = MockPlans.Get(upgrade.Plan);
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>())
.Returns(policy);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType).Returns(MockPlans.Get(organization.PlanType));
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
upgrade.AdditionalSeats = 15;
@ -180,6 +197,7 @@ public class UpgradeOrganizationPlanCommandTests
[BitAutoData(PlanType.TeamsAnnually)]
[BitAutoData(PlanType.TeamsStarter)]
public async Task UpgradePlan_SM_NotEnoughSmSeats_Throws(PlanType planType, Organization organization, OrganizationUpgrade upgrade,
[Policy(PolicyType.ResetPassword, false)] PolicyStatus policy,
SutProvider<UpgradeOrganizationPlanCommand> sutProvider)
{
upgrade.Plan = planType;
@ -191,6 +209,10 @@ public class UpgradeOrganizationPlanCommandTests
organization.SmSeats = 2;
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType).Returns(MockPlans.Get(organization.PlanType));
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>())
.Returns(policy);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IOrganizationRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
@ -214,7 +236,9 @@ public class UpgradeOrganizationPlanCommandTests
[BitAutoData(PlanType.TeamsAnnually, 51)]
[BitAutoData(PlanType.TeamsStarter, 51)]
public async Task UpgradePlan_SM_NotEnoughServiceAccounts_Throws(PlanType planType, int currentServiceAccounts,
Organization organization, OrganizationUpgrade upgrade, SutProvider<UpgradeOrganizationPlanCommand> sutProvider)
Organization organization, OrganizationUpgrade upgrade,
[Policy(PolicyType.ResetPassword, false)] PolicyStatus policy,
SutProvider<UpgradeOrganizationPlanCommand> sutProvider)
{
upgrade.Plan = planType;
upgrade.AdditionalSeats = 15;
@ -226,6 +250,10 @@ public class UpgradeOrganizationPlanCommandTests
organization.SmServiceAccounts = currentServiceAccounts;
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType).Returns(MockPlans.Get(organization.PlanType));
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>())
.Returns(policy);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IOrganizationRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
@ -251,6 +279,7 @@ public class UpgradeOrganizationPlanCommandTests
OrganizationUpgrade upgrade,
string newPublicKey,
string newPrivateKey,
[Policy(PolicyType.ResetPassword, false)] PolicyStatus policy,
SutProvider<UpgradeOrganizationPlanCommand> sutProvider)
{
organization.PublicKey = null;
@ -262,6 +291,9 @@ public class UpgradeOrganizationPlanCommandTests
publicKey: newPublicKey);
upgrade.AdditionalSeats = 10;
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>())
.Returns(policy);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
@ -291,6 +323,7 @@ public class UpgradeOrganizationPlanCommandTests
public async Task UpgradePlan_WhenOrganizationAlreadyHasPublicAndPrivateKeys_DoesNotOverwriteWithNull(
Organization organization,
OrganizationUpgrade upgrade,
[Policy(PolicyType.ResetPassword, false)] PolicyStatus policy,
SutProvider<UpgradeOrganizationPlanCommand> sutProvider)
{
// Arrange
@ -304,6 +337,9 @@ public class UpgradeOrganizationPlanCommandTests
upgrade.Keys = null;
upgrade.AdditionalSeats = 10;
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>())
.Returns(policy);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
@ -333,6 +369,7 @@ public class UpgradeOrganizationPlanCommandTests
public async Task UpgradePlan_WhenOrganizationAlreadyHasPublicAndPrivateKeys_DoesNotBackfillWithNewKeys(
Organization organization,
OrganizationUpgrade upgrade,
[Policy(PolicyType.ResetPassword, false)] PolicyStatus policy,
SutProvider<UpgradeOrganizationPlanCommand> sutProvider)
{
// Arrange
@ -343,6 +380,9 @@ public class UpgradeOrganizationPlanCommandTests
organization.PublicKey = existingPublicKey;
organization.PrivateKey = existingPrivateKey;
sutProvider.GetDependency<IPolicyQuery>()
.RunAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>())
.Returns(policy);
upgrade.Plan = PlanType.TeamsAnnually;
upgrade.Keys = new PublicKeyEncryptionKeyPairData(

View File

@ -0,0 +1,55 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
using Bit.Core.AdminConsole.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Xunit;
namespace Bit.Core.Test.OrganizationFeatures.Policies;
[SutProviderCustomize]
public class PolicyQueryTests
{
[Theory, BitAutoData]
public async Task RunAsync_WithExistingPolicy_ReturnsPolicy(SutProvider<PolicyQuery> sutProvider,
Policy policy)
{
// Arrange
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policy.OrganizationId, policy.Type)
.Returns(policy);
// Act
var policyData = await sutProvider.Sut.RunAsync(policy.OrganizationId, policy.Type);
// Assert
Assert.Equal(policy.Data, policyData.Data);
Assert.Equal(policy.Type, policyData.Type);
Assert.Equal(policy.Enabled, policyData.Enabled);
Assert.Equal(policy.OrganizationId, policyData.OrganizationId);
}
[Theory, BitAutoData]
public async Task RunAsync_WithNonExistentPolicy_ReturnsDefaultDisabledPolicy(
SutProvider<PolicyQuery> sutProvider,
Guid organizationId,
PolicyType policyType)
{
// Arrange
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(organizationId, policyType)
.ReturnsNull();
// Act
var policyData = await sutProvider.Sut.RunAsync(organizationId, policyType);
// Assert
Assert.Equal(organizationId, policyData.OrganizationId);
Assert.Equal(policyType, policyData.Type);
Assert.False(policyData.Enabled);
Assert.Null(policyData.Data);
}
}

View File

@ -254,21 +254,6 @@ public class HandlebarsMailServiceTests
}
}
[Fact]
public async Task SendSendEmailOtpEmailAsync_SendsEmail()
{
// Arrange
var email = "test@example.com";
var token = "aToken";
var subject = string.Format("Your Bitwarden Send verification code is {0}", token);
// Act
await _sut.SendSendEmailOtpEmailAsync(email, token, subject);
// Assert
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Any<MailMessage>());
}
[Fact]
public async Task SendIndividualUserWelcomeEmailAsync_SendsCorrectEmail()
{

View File

@ -326,4 +326,101 @@ public class ImportCiphersAsyncCommandTests
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
}
[Theory, BitAutoData]
public async Task ImportIntoIndividualVaultAsync_WithArchivedCiphers_PreservesArchiveStatus(
Guid importingUserId,
List<CipherDetails> ciphers,
SutProvider<ImportCiphersCommand> sutProvider)
{
var archivedDate = DateTime.UtcNow.AddDays(-1);
ciphers[0].UserId = importingUserId;
ciphers[0].ArchivedDate = archivedDate;
sutProvider.GetDependency<IPolicyService>()
.AnyPoliciesApplicableToUserAsync(importingUserId, PolicyType.OrganizationDataOwnership)
.Returns(false);
sutProvider.GetDependency<IFolderRepository>()
.GetManyByUserIdAsync(importingUserId)
.Returns(new List<Folder>());
var folders = new List<Folder>();
var folderRelationships = new List<KeyValuePair<int, int>>();
await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId);
await sutProvider.GetDependency<ICipherRepository>()
.Received(1)
.CreateAsync(importingUserId,
Arg.Is<List<CipherDetails>>(c =>
c[0].Archives != null &&
c[0].Archives.Contains(importingUserId.ToString().ToUpperInvariant()) &&
c[0].Archives.Contains(archivedDate.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"))),
Arg.Any<List<Folder>>());
}
/*
* Archive functionality is a per-user function. When importing archived ciphers into an organization vault,
* the Archives field should be set for the importing user only. This allows the importing user to see
* items as archived, while other organization members will not see them as archived.
*/
[Theory, BitAutoData]
public async Task ImportIntoOrganizationalVaultAsync_WithArchivedCiphers_SetsArchivesForImportingUserOnly(
Organization organization,
Guid importingUserId,
OrganizationUser importingOrganizationUser,
List<Collection> collections,
List<CipherDetails> ciphers,
SutProvider<ImportCiphersCommand> sutProvider)
{
var archivedDate = DateTime.UtcNow.AddDays(-1);
organization.MaxCollections = null;
importingOrganizationUser.OrganizationId = organization.Id;
foreach (var collection in collections)
{
collection.OrganizationId = organization.Id;
}
foreach (var cipher in ciphers)
{
cipher.OrganizationId = organization.Id;
}
ciphers[0].ArchivedDate = archivedDate;
ciphers[0].Archives = null;
KeyValuePair<int, int>[] collectionRelationships = {
new(0, 0),
new(1, 1),
new(2, 2)
};
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByOrganizationAsync(organization.Id, importingUserId)
.Returns(importingOrganizationUser);
sutProvider.GetDependency<ICollectionRepository>()
.GetManyByOrganizationIdAsync(organization.Id)
.Returns(new List<Collection>());
await sutProvider.Sut.ImportIntoOrganizationalVaultAsync(collections, ciphers, collectionRelationships, importingUserId);
await sutProvider.GetDependency<ICipherRepository>()
.Received(1)
.CreateAsync(
Arg.Is<List<CipherDetails>>(c =>
c[0].ArchivedDate == archivedDate &&
c[0].Archives != null &&
c[0].Archives.Contains(importingUserId.ToString().ToUpperInvariant()) &&
c[0].Archives.Contains(archivedDate.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"))),
Arg.Any<IEnumerable<Collection>>(),
Arg.Any<IEnumerable<CollectionCipher>>(),
Arg.Any<IEnumerable<CollectionUser>>());
}
}

View File

@ -56,7 +56,7 @@ public class SendAuthenticationQueryTests
// Assert
var emailOtp = Assert.IsType<EmailOtp>(result);
Assert.Equal(expectedEmailHashes, emailOtp.Emails);
Assert.Equal(expectedEmailHashes, emailOtp.EmailHashes);
}
[Fact]

Some files were not shown because too many files have changed in this diff Show More