1
0
mirror of https://github.com/bitwarden/server synced 2025-12-26 21:23:39 +00:00

Merge branch 'main' into jmccannon/ac/pm-27131-auto-confirm-req

This commit is contained in:
jrmccannon
2025-11-26 09:06:52 -06:00
61 changed files with 4751 additions and 813 deletions

View File

@@ -0,0 +1,196 @@
using System.Net;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Enums;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Xunit;
namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;
public class OrganizationsControllerTests : IClassFixture<ApiApplicationFactory>, IAsyncLifetime
{
private readonly HttpClient _client;
private readonly ApiApplicationFactory _factory;
private readonly LoginHelper _loginHelper;
private Organization _organization = null!;
private string _ownerEmail = null!;
private readonly string _billingEmail = "billing@example.com";
private readonly string _organizationName = "Organizations Controller Test Org";
public OrganizationsControllerTests(ApiApplicationFactory apiFactory)
{
_factory = apiFactory;
_client = _factory.CreateClient();
_loginHelper = new LoginHelper(_factory, _client);
}
public async Task InitializeAsync()
{
_ownerEmail = $"org-integration-test-{Guid.NewGuid()}@example.com";
await _factory.LoginWithNewAccount(_ownerEmail);
(_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory,
name: _organizationName,
billingEmail: _billingEmail,
plan: PlanType.EnterpriseAnnually,
ownerEmail: _ownerEmail,
passwordManagerSeats: 5,
paymentMethod: PaymentMethodType.Card);
}
public Task DisposeAsync()
{
_client.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task Put_AsOwner_WithoutProvider_CanUpdateOrganization()
{
// Arrange - Regular organization owner (no provider)
await _loginHelper.LoginAsync(_ownerEmail);
var updateRequest = new OrganizationUpdateRequestModel
{
Name = "Updated Organization Name",
BillingEmail = "newbillingemail@example.com"
};
// Act
var response = await _client.PutAsJsonAsync($"/organizations/{_organization.Id}", updateRequest);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
// Verify the organization name was updated
var organizationRepository = _factory.GetService<IOrganizationRepository>();
var updatedOrg = await organizationRepository.GetByIdAsync(_organization.Id);
Assert.NotNull(updatedOrg);
Assert.Equal("Updated Organization Name", updatedOrg.Name);
Assert.Equal("newbillingemail@example.com", updatedOrg.BillingEmail);
}
[Fact]
public async Task Put_AsProvider_CanUpdateOrganization()
{
// Create and login as a new account to be the provider user (not the owner)
var providerUserEmail = $"provider-{Guid.NewGuid()}@example.com";
var (token, _) = await _factory.LoginWithNewAccount(providerUserEmail);
// Set up provider linked to org and ProviderUser entry
var provider = await ProviderTestHelpers.CreateProviderAndLinkToOrganizationAsync(_factory, _organization.Id,
ProviderType.Msp);
await ProviderTestHelpers.CreateProviderUserAsync(_factory, provider.Id, providerUserEmail,
ProviderUserType.ProviderAdmin);
await _loginHelper.LoginAsync(providerUserEmail);
var updateRequest = new OrganizationUpdateRequestModel
{
Name = "Updated Organization Name",
BillingEmail = "newbillingemail@example.com"
};
// Act
var response = await _client.PutAsJsonAsync($"/organizations/{_organization.Id}", updateRequest);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
// Verify the organization name was updated
var organizationRepository = _factory.GetService<IOrganizationRepository>();
var updatedOrg = await organizationRepository.GetByIdAsync(_organization.Id);
Assert.NotNull(updatedOrg);
Assert.Equal("Updated Organization Name", updatedOrg.Name);
Assert.Equal("newbillingemail@example.com", updatedOrg.BillingEmail);
}
[Fact]
public async Task Put_NotMemberOrProvider_CannotUpdateOrganization()
{
// Create and login as a new account to be unrelated to the org
var userEmail = "stranger@example.com";
await _factory.LoginWithNewAccount(userEmail);
await _loginHelper.LoginAsync(userEmail);
var updateRequest = new OrganizationUpdateRequestModel
{
Name = "Updated Organization Name",
BillingEmail = "newbillingemail@example.com"
};
// Act
var response = await _client.PutAsJsonAsync($"/organizations/{_organization.Id}", updateRequest);
// Assert
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
// Verify the organization name was not updated
var organizationRepository = _factory.GetService<IOrganizationRepository>();
var updatedOrg = await organizationRepository.GetByIdAsync(_organization.Id);
Assert.NotNull(updatedOrg);
Assert.Equal(_organizationName, updatedOrg.Name);
Assert.Equal(_billingEmail, updatedOrg.BillingEmail);
}
[Fact]
public async Task Put_AsOwner_WithProvider_CanRenameOrganization()
{
// Arrange - Create provider and link to organization
// The active user is ONLY an org owner, NOT a provider user
await ProviderTestHelpers.CreateProviderAndLinkToOrganizationAsync(_factory, _organization.Id, ProviderType.Msp);
await _loginHelper.LoginAsync(_ownerEmail);
var updateRequest = new OrganizationUpdateRequestModel
{
Name = "Updated Organization Name",
BillingEmail = null
};
// Act
var response = await _client.PutAsJsonAsync($"/organizations/{_organization.Id}", updateRequest);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
// Verify the organization name was actually updated
var organizationRepository = _factory.GetService<IOrganizationRepository>();
var updatedOrg = await organizationRepository.GetByIdAsync(_organization.Id);
Assert.NotNull(updatedOrg);
Assert.Equal("Updated Organization Name", updatedOrg.Name);
Assert.Equal(_billingEmail, updatedOrg.BillingEmail);
}
[Fact]
public async Task Put_AsOwner_WithProvider_CannotChangeBillingEmail()
{
// Arrange - Create provider and link to organization
// The active user is ONLY an org owner, NOT a provider user
await ProviderTestHelpers.CreateProviderAndLinkToOrganizationAsync(_factory, _organization.Id, ProviderType.Msp);
await _loginHelper.LoginAsync(_ownerEmail);
var updateRequest = new OrganizationUpdateRequestModel
{
Name = "Updated Organization Name",
BillingEmail = "updatedbilling@example.com"
};
// Act
var response = await _client.PutAsJsonAsync($"/organizations/{_organization.Id}", updateRequest);
// Assert
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
// Verify the organization was not updated
var organizationRepository = _factory.GetService<IOrganizationRepository>();
var updatedOrg = await organizationRepository.GetByIdAsync(_organization.Id);
Assert.NotNull(updatedOrg);
Assert.Equal(_organizationName, updatedOrg.Name);
Assert.Equal(_billingEmail, updatedOrg.BillingEmail);
}
}

View File

@@ -0,0 +1,77 @@
using Bit.Api.IntegrationTest.Factories;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Repositories;
namespace Bit.Api.IntegrationTest.Helpers;
public static class ProviderTestHelpers
{
/// <summary>
/// Creates a provider and links it to an organization.
/// This does NOT create any provider users.
/// </summary>
/// <param name="factory">The API application factory</param>
/// <param name="organizationId">The organization ID to link to the provider</param>
/// <param name="providerType">The type of provider to create</param>
/// <param name="providerStatus">The provider status (defaults to Created)</param>
/// <returns>The created provider</returns>
public static async Task<Provider> CreateProviderAndLinkToOrganizationAsync(
ApiApplicationFactory factory,
Guid organizationId,
ProviderType providerType,
ProviderStatusType providerStatus = ProviderStatusType.Created)
{
var providerRepository = factory.GetService<IProviderRepository>();
var providerOrganizationRepository = factory.GetService<IProviderOrganizationRepository>();
// Create the provider
var provider = await providerRepository.CreateAsync(new Provider
{
Name = $"Test {providerType} Provider",
BusinessName = $"Test {providerType} Provider Business",
BillingEmail = $"provider-{providerType.ToString().ToLower()}@example.com",
Type = providerType,
Status = providerStatus,
Enabled = true
});
// Link the provider to the organization
await providerOrganizationRepository.CreateAsync(new ProviderOrganization
{
ProviderId = provider.Id,
OrganizationId = organizationId,
Key = "test-provider-key"
});
return provider;
}
/// <summary>
/// Creates a providerUser for a provider.
/// </summary>
public static async Task<ProviderUser> CreateProviderUserAsync(
ApiApplicationFactory factory,
Guid providerId,
string userEmail,
ProviderUserType providerUserType)
{
var userRepository = factory.GetService<IUserRepository>();
var user = await userRepository.GetByEmailAsync(userEmail);
if (user is null)
{
throw new Exception("No user found in test setup.");
}
var providerUserRepository = factory.GetService<IProviderUserRepository>();
return await providerUserRepository.CreateAsync(new ProviderUser
{
ProviderId = providerId,
Status = ProviderUserStatusType.Confirmed,
UserId = user.Id,
Key = Guid.NewGuid().ToString(),
Type = providerUserType
});
}
}

View File

@@ -1,5 +1,4 @@
using System.Security.Claims;
using AutoFixture.Xunit2;
using Bit.Api.AdminConsole.Controllers;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.Models.Request.Organizations;
@@ -8,9 +7,6 @@ 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.Business.Tokenables;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
@@ -20,7 +16,6 @@ using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.Services;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Services;
@@ -31,101 +26,23 @@ using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Test.Billing.Mocks;
using Bit.Core.Tokens;
using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
namespace Bit.Api.Test.AdminConsole.Controllers;
public class OrganizationsControllerTests : IDisposable
[ControllerCustomize(typeof(OrganizationsController))]
[SutProviderCustomize]
public class OrganizationsControllerTests
{
private readonly GlobalSettings _globalSettings;
private readonly ICurrentContext _currentContext;
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationService _organizationService;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IPolicyRepository _policyRepository;
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly ISsoConfigService _ssoConfigService;
private readonly IUserService _userService;
private readonly IGetOrganizationApiKeyQuery _getOrganizationApiKeyQuery;
private readonly IRotateOrganizationApiKeyCommand _rotateOrganizationApiKeyCommand;
private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository;
private readonly ICreateOrganizationApiKeyCommand _createOrganizationApiKeyCommand;
private readonly IFeatureService _featureService;
private readonly IProviderRepository _providerRepository;
private readonly IProviderBillingService _providerBillingService;
private readonly IDataProtectorTokenFactory<OrgDeleteTokenable> _orgDeleteTokenDataFactory;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly ICloudOrganizationSignUpCommand _cloudOrganizationSignUpCommand;
private readonly IOrganizationDeleteCommand _organizationDeleteCommand;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IPricingClient _pricingClient;
private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand;
private readonly OrganizationsController _sut;
public OrganizationsControllerTests()
{
_currentContext = Substitute.For<ICurrentContext>();
_globalSettings = Substitute.For<GlobalSettings>();
_organizationRepository = Substitute.For<IOrganizationRepository>();
_organizationService = Substitute.For<IOrganizationService>();
_organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
_policyRepository = Substitute.For<IPolicyRepository>();
_ssoConfigRepository = Substitute.For<ISsoConfigRepository>();
_ssoConfigService = Substitute.For<ISsoConfigService>();
_getOrganizationApiKeyQuery = Substitute.For<IGetOrganizationApiKeyQuery>();
_rotateOrganizationApiKeyCommand = Substitute.For<IRotateOrganizationApiKeyCommand>();
_organizationApiKeyRepository = Substitute.For<IOrganizationApiKeyRepository>();
_userService = Substitute.For<IUserService>();
_createOrganizationApiKeyCommand = Substitute.For<ICreateOrganizationApiKeyCommand>();
_featureService = Substitute.For<IFeatureService>();
_providerRepository = Substitute.For<IProviderRepository>();
_providerBillingService = Substitute.For<IProviderBillingService>();
_orgDeleteTokenDataFactory = Substitute.For<IDataProtectorTokenFactory<OrgDeleteTokenable>>();
_removeOrganizationUserCommand = Substitute.For<IRemoveOrganizationUserCommand>();
_cloudOrganizationSignUpCommand = Substitute.For<ICloudOrganizationSignUpCommand>();
_organizationDeleteCommand = Substitute.For<IOrganizationDeleteCommand>();
_policyRequirementQuery = Substitute.For<IPolicyRequirementQuery>();
_pricingClient = Substitute.For<IPricingClient>();
_organizationUpdateKeysCommand = Substitute.For<IOrganizationUpdateKeysCommand>();
_sut = new OrganizationsController(
_organizationRepository,
_organizationUserRepository,
_policyRepository,
_organizationService,
_userService,
_currentContext,
_ssoConfigRepository,
_ssoConfigService,
_getOrganizationApiKeyQuery,
_rotateOrganizationApiKeyCommand,
_createOrganizationApiKeyCommand,
_organizationApiKeyRepository,
_featureService,
_globalSettings,
_providerRepository,
_providerBillingService,
_orgDeleteTokenDataFactory,
_removeOrganizationUserCommand,
_cloudOrganizationSignUpCommand,
_organizationDeleteCommand,
_policyRequirementQuery,
_pricingClient,
_organizationUpdateKeysCommand);
}
public void Dispose()
{
_sut?.Dispose();
}
[Theory, AutoData]
[Theory, BitAutoData]
public async Task OrganizationsController_UserCannotLeaveOrganizationThatProvidesKeyConnector(
Guid orgId, User user)
SutProvider<OrganizationsController> sutProvider,
Guid orgId,
User user)
{
var ssoConfig = new SsoConfig
{
@@ -140,21 +57,24 @@ public class OrganizationsControllerTests : IDisposable
user.UsesKeyConnector = true;
_currentContext.OrganizationUser(orgId).Returns(true);
_ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig);
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List<Organization> { null });
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.Leave(orgId));
sutProvider.GetDependency<ICurrentContext>().OrganizationUser(orgId).Returns(true);
sutProvider.GetDependency<ISsoConfigRepository>().GetByOrganizationIdAsync(orgId).Returns(ssoConfig);
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
sutProvider.GetDependency<IUserService>().GetOrganizationsClaimingUserAsync(user.Id).Returns(new List<Organization> { null });
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Leave(orgId));
Assert.Contains("Your organization's Single Sign-On settings prevent you from leaving.",
exception.Message);
await _removeOrganizationUserCommand.DidNotReceiveWithAnyArgs().UserLeaveAsync(default, default);
await sutProvider.GetDependency<IRemoveOrganizationUserCommand>().DidNotReceiveWithAnyArgs().UserLeaveAsync(default, default);
}
[Theory, AutoData]
[Theory, BitAutoData]
public async Task OrganizationsController_UserCannotLeaveOrganizationThatManagesUser(
Guid orgId, User user)
SutProvider<OrganizationsController> sutProvider,
Guid orgId,
User user)
{
var ssoConfig = new SsoConfig
{
@@ -166,27 +86,34 @@ public class OrganizationsControllerTests : IDisposable
Enabled = true,
OrganizationId = orgId,
};
var foundOrg = new Organization();
foundOrg.Id = orgId;
var foundOrg = new Organization
{
Id = orgId
};
_currentContext.OrganizationUser(orgId).Returns(true);
_ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig);
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List<Organization> { { foundOrg } });
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.Leave(orgId));
sutProvider.GetDependency<ICurrentContext>().OrganizationUser(orgId).Returns(true);
sutProvider.GetDependency<ISsoConfigRepository>().GetByOrganizationIdAsync(orgId).Returns(ssoConfig);
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
sutProvider.GetDependency<IUserService>().GetOrganizationsClaimingUserAsync(user.Id).Returns(new List<Organization> { foundOrg });
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Leave(orgId));
Assert.Contains("Claimed user account cannot leave claiming organization. Contact your organization administrator for additional details.",
exception.Message);
await _removeOrganizationUserCommand.DidNotReceiveWithAnyArgs().RemoveUserAsync(default, default);
await sutProvider.GetDependency<IRemoveOrganizationUserCommand>().DidNotReceiveWithAnyArgs().RemoveUserAsync(default, default);
}
[Theory]
[InlineAutoData(true, false)]
[InlineAutoData(false, true)]
[InlineAutoData(false, false)]
[BitAutoData(true, false)]
[BitAutoData(false, true)]
[BitAutoData(false, false)]
public async Task OrganizationsController_UserCanLeaveOrganizationThatDoesntProvideKeyConnector(
bool keyConnectorEnabled, bool userUsesKeyConnector, Guid orgId, User user)
bool keyConnectorEnabled,
bool userUsesKeyConnector,
SutProvider<OrganizationsController> sutProvider,
Guid orgId,
User user)
{
var ssoConfig = new SsoConfig
{
@@ -203,18 +130,19 @@ public class OrganizationsControllerTests : IDisposable
user.UsesKeyConnector = userUsesKeyConnector;
_currentContext.OrganizationUser(orgId).Returns(true);
_ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig);
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_userService.GetOrganizationsClaimingUserAsync(user.Id).Returns(new List<Organization>());
sutProvider.GetDependency<ICurrentContext>().OrganizationUser(orgId).Returns(true);
sutProvider.GetDependency<ISsoConfigRepository>().GetByOrganizationIdAsync(orgId).Returns(ssoConfig);
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
sutProvider.GetDependency<IUserService>().GetOrganizationsClaimingUserAsync(user.Id).Returns(new List<Organization>());
await _sut.Leave(orgId);
await sutProvider.Sut.Leave(orgId);
await _removeOrganizationUserCommand.Received(1).UserLeaveAsync(orgId, user.Id);
await sutProvider.GetDependency<IRemoveOrganizationUserCommand>().Received(1).UserLeaveAsync(orgId, user.Id);
}
[Theory, AutoData]
[Theory, BitAutoData]
public async Task Delete_OrganizationIsConsolidatedBillingClient_ScalesProvidersSeats(
SutProvider<OrganizationsController> sutProvider,
Provider provider,
Organization organization,
User user,
@@ -228,87 +156,89 @@ public class OrganizationsControllerTests : IDisposable
provider.Type = ProviderType.Msp;
provider.Status = ProviderStatusType.Billable;
_currentContext.OrganizationOwner(organizationId).Returns(true);
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organizationId).Returns(true);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
sutProvider.GetDependency<IUserService>().VerifySecretAsync(user, requestModel.Secret).Returns(true);
sutProvider.GetDependency<IProviderRepository>().GetByOrganizationIdAsync(organization.Id).Returns(provider);
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
await sutProvider.Sut.Delete(organizationId.ToString(), requestModel);
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_userService.VerifySecretAsync(user, requestModel.Secret).Returns(true);
_providerRepository.GetByOrganizationIdAsync(organization.Id).Returns(provider);
await _sut.Delete(organizationId.ToString(), requestModel);
await _providerBillingService.Received(1)
await sutProvider.GetDependency<IProviderBillingService>().Received(1)
.ScaleSeats(provider, organization.PlanType, -organization.Seats.Value);
await _organizationDeleteCommand.Received(1).DeleteAsync(organization);
await sutProvider.GetDependency<IOrganizationDeleteCommand>().Received(1).DeleteAsync(organization);
}
[Theory, AutoData]
[Theory, BitAutoData]
public async Task GetAutoEnrollStatus_WithPolicyRequirementsEnabled_ReturnsOrganizationAutoEnrollStatus_WithResetPasswordEnabledTrue(
SutProvider<OrganizationsController> sutProvider,
User user,
Organization organization,
OrganizationUser organizationUser
)
OrganizationUser organizationUser)
{
var policyRequirement = new ResetPasswordPolicyRequirement() { AutoEnrollOrganizations = [organization.Id] };
var policyRequirement = new ResetPasswordPolicyRequirement { AutoEnrollOrganizations = [organization.Id] };
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_organizationRepository.GetByIdentifierAsync(organization.Id.ToString()).Returns(organization);
_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
_organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser);
_policyRequirementQuery.GetAsync<ResetPasswordPolicyRequirement>(user.Id).Returns(policyRequirement);
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(true);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser);
sutProvider.GetDependency<IPolicyRequirementQuery>().GetAsync<ResetPasswordPolicyRequirement>(user.Id).Returns(policyRequirement);
var result = await _sut.GetAutoEnrollStatus(organization.Id.ToString());
var result = await sutProvider.Sut.GetAutoEnrollStatus(organization.Id.ToString());
await _userService.Received(1).GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>());
await _organizationRepository.Received(1).GetByIdentifierAsync(organization.Id.ToString());
await _policyRequirementQuery.Received(1).GetAsync<ResetPasswordPolicyRequirement>(user.Id);
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(1).GetAsync<ResetPasswordPolicyRequirement>(user.Id);
Assert.True(result.ResetPasswordEnabled);
Assert.Equal(result.Id, organization.Id);
}
[Theory, AutoData]
[Theory, BitAutoData]
public async Task GetAutoEnrollStatus_WithPolicyRequirementsDisabled_ReturnsOrganizationAutoEnrollStatus_WithResetPasswordEnabledTrue(
User user,
Organization organization,
OrganizationUser organizationUser
)
SutProvider<OrganizationsController> sutProvider,
User user,
Organization organization,
OrganizationUser organizationUser)
{
var policy = new Policy
{
Type = PolicyType.ResetPassword,
Enabled = true,
Data = "{\"AutoEnrollEnabled\": true}",
OrganizationId = organization.Id
};
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);
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_organizationRepository.GetByIdentifierAsync(organization.Id.ToString()).Returns(organization);
_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(false);
_organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id).Returns(organizationUser);
_policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword).Returns(policy);
var result = await sutProvider.Sut.GetAutoEnrollStatus(organization.Id.ToString());
var result = await _sut.GetAutoEnrollStatus(organization.Id.ToString());
await _userService.Received(1).GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>());
await _organizationRepository.Received(1).GetByIdentifierAsync(organization.Id.ToString());
await _policyRequirementQuery.Received(0).GetAsync<ResetPasswordPolicyRequirement>(user.Id);
await _policyRepository.Received(1).GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword);
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);
Assert.True(result.ResetPasswordEnabled);
}
[Theory, AutoData]
[Theory, BitAutoData]
public async Task PutCollectionManagement_ValidRequest_Success(
SutProvider<OrganizationsController> sutProvider,
Organization organization,
OrganizationCollectionManagementUpdateRequestModel model)
{
// Arrange
_currentContext.OrganizationOwner(organization.Id).Returns(true);
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(organization.Id).Returns(true);
var plan = MockPlans.Get(PlanType.EnterpriseAnnually);
_pricingClient.GetPlan(Arg.Any<PlanType>()).Returns(plan);
sutProvider.GetDependency<IPricingClient>().GetPlan(Arg.Any<PlanType>()).Returns(plan);
_organizationService
sutProvider.GetDependency<IOrganizationService>()
.UpdateCollectionManagementSettingsAsync(
organization.Id,
Arg.Is<OrganizationCollectionManagementSettings>(s =>
@@ -319,10 +249,10 @@ public class OrganizationsControllerTests : IDisposable
.Returns(organization);
// Act
await _sut.PutCollectionManagement(organization.Id, model);
await sutProvider.Sut.PutCollectionManagement(organization.Id, model);
// Assert
await _organizationService
await sutProvider.GetDependency<IOrganizationService>()
.Received(1)
.UpdateCollectionManagementSettingsAsync(
organization.Id,

View File

@@ -0,0 +1,641 @@
using Bit.Billing.Jobs;
using Bit.Billing.Services;
using Bit.Core;
using Bit.Core.Billing.Constants;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Quartz;
using Stripe;
using Xunit;
namespace Bit.Billing.Test.Jobs;
public class ReconcileAdditionalStorageJobTests
{
private readonly IStripeFacade _stripeFacade;
private readonly ILogger<ReconcileAdditionalStorageJob> _logger;
private readonly IFeatureService _featureService;
private readonly ReconcileAdditionalStorageJob _sut;
public ReconcileAdditionalStorageJobTests()
{
_stripeFacade = Substitute.For<IStripeFacade>();
_logger = Substitute.For<ILogger<ReconcileAdditionalStorageJob>>();
_featureService = Substitute.For<IFeatureService>();
_sut = new ReconcileAdditionalStorageJob(_stripeFacade, _logger, _featureService);
}
#region Feature Flag Tests
[Fact]
public async Task Execute_FeatureFlagDisabled_SkipsProcessing()
{
// Arrange
var context = CreateJobExecutionContext();
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob)
.Returns(false);
// Act
await _sut.Execute(context);
// Assert
_stripeFacade.DidNotReceiveWithAnyArgs().ListSubscriptionsAutoPagingAsync();
}
[Fact]
public async Task Execute_FeatureFlagEnabled_ProcessesSubscriptions()
{
// Arrange
var context = CreateJobExecutionContext();
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob)
.Returns(true);
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode)
.Returns(false);
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Empty<Subscription>());
// Act
await _sut.Execute(context);
// Assert
_stripeFacade.Received(3).ListSubscriptionsAutoPagingAsync(
Arg.Is<SubscriptionListOptions>(o => o.Status == "active"));
}
#endregion
#region Dry Run Mode Tests
[Fact]
public async Task Execute_DryRunMode_DoesNotUpdateSubscriptions()
{
// Arrange
var context = CreateJobExecutionContext();
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(false); // Dry run ON
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10);
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create(subscription));
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!);
}
[Fact]
public async Task Execute_DryRunModeDisabled_UpdatesSubscriptions()
{
// Arrange
var context = CreateJobExecutionContext();
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); // Dry run OFF
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10);
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create(subscription));
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.Received(1).UpdateSubscription(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(o => o.Items.Count == 1));
}
#endregion
#region Price ID Processing Tests
[Fact]
public async Task Execute_ProcessesAllThreePriceIds()
{
// Arrange
var context = CreateJobExecutionContext();
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(false);
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Empty<Subscription>());
// Act
await _sut.Execute(context);
// Assert
_stripeFacade.Received(1).ListSubscriptionsAutoPagingAsync(
Arg.Is<SubscriptionListOptions>(o => o.Price == "storage-gb-monthly"));
_stripeFacade.Received(1).ListSubscriptionsAutoPagingAsync(
Arg.Is<SubscriptionListOptions>(o => o.Price == "storage-gb-annually"));
_stripeFacade.Received(1).ListSubscriptionsAutoPagingAsync(
Arg.Is<SubscriptionListOptions>(o => o.Price == "personal-storage-gb-annually"));
}
#endregion
#region Already Processed Tests
[Fact]
public async Task Execute_SubscriptionAlreadyProcessed_SkipsUpdate()
{
// Arrange
var context = CreateJobExecutionContext();
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
var metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o")
};
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, metadata: metadata);
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create(subscription));
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!);
}
[Fact]
public async Task Execute_SubscriptionWithInvalidProcessedDate_ProcessesSubscription()
{
// Arrange
var context = CreateJobExecutionContext();
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
var metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.StorageReconciled2025] = "invalid-date"
};
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, metadata: metadata);
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create(subscription));
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.Received(1).UpdateSubscription("sub_123", Arg.Any<SubscriptionUpdateOptions>());
}
[Fact]
public async Task Execute_SubscriptionWithoutMetadata_ProcessesSubscription()
{
// Arrange
var context = CreateJobExecutionContext();
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, metadata: null);
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create(subscription));
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.Received(1).UpdateSubscription("sub_123", Arg.Any<SubscriptionUpdateOptions>());
}
#endregion
#region Quantity Reduction Logic Tests
[Fact]
public async Task Execute_QuantityGreaterThan4_ReducesBy4()
{
// Arrange
var context = CreateJobExecutionContext();
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10);
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create(subscription));
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.Received(1).UpdateSubscription(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(o =>
o.Items.Count == 1 &&
o.Items[0].Quantity == 6 &&
o.Items[0].Deleted != true));
}
[Fact]
public async Task Execute_QuantityEquals4_DeletesItem()
{
// Arrange
var context = CreateJobExecutionContext();
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 4);
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create(subscription));
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.Received(1).UpdateSubscription(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(o =>
o.Items.Count == 1 &&
o.Items[0].Deleted == true));
}
[Fact]
public async Task Execute_QuantityLessThan4_DeletesItem()
{
// Arrange
var context = CreateJobExecutionContext();
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 2);
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create(subscription));
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.Received(1).UpdateSubscription(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(o =>
o.Items.Count == 1 &&
o.Items[0].Deleted == true));
}
#endregion
#region Update Options Tests
[Fact]
public async Task Execute_UpdateOptions_SetsProrationBehaviorToCreateProrations()
{
// Arrange
var context = CreateJobExecutionContext();
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10);
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create(subscription));
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.Received(1).UpdateSubscription(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(o => o.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations));
}
[Fact]
public async Task Execute_UpdateOptions_SetsReconciledMetadata()
{
// Arrange
var context = CreateJobExecutionContext();
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10);
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create(subscription));
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.Received(1).UpdateSubscription(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(o =>
o.Metadata.ContainsKey(StripeConstants.MetadataKeys.StorageReconciled2025) &&
!string.IsNullOrEmpty(o.Metadata[StripeConstants.MetadataKeys.StorageReconciled2025])));
}
#endregion
#region Subscription Filtering Tests
[Fact]
public async Task Execute_SubscriptionWithNoItems_SkipsUpdate()
{
// Arrange
var context = CreateJobExecutionContext();
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
var subscription = new Subscription
{
Id = "sub_123",
Items = null
};
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create(subscription));
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!);
}
[Fact]
public async Task Execute_SubscriptionWithDifferentPriceId_SkipsUpdate()
{
// Arrange
var context = CreateJobExecutionContext();
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
var subscription = CreateSubscription("sub_123", "different-price-id", quantity: 10);
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create(subscription));
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!);
}
[Fact]
public async Task Execute_NullSubscription_SkipsProcessing()
{
// Arrange
var context = CreateJobExecutionContext();
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create<Subscription>(null!));
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!);
}
#endregion
#region Multiple Subscriptions Tests
[Fact]
public async Task Execute_MultipleSubscriptions_ProcessesAll()
{
// Arrange
var context = CreateJobExecutionContext();
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
var subscription1 = CreateSubscription("sub_1", "storage-gb-monthly", quantity: 10);
var subscription2 = CreateSubscription("sub_2", "storage-gb-monthly", quantity: 5);
var subscription3 = CreateSubscription("sub_3", "storage-gb-monthly", quantity: 3);
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create(subscription1, subscription2, subscription3));
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(callInfo => callInfo.Arg<string>() switch
{
"sub_1" => subscription1,
"sub_2" => subscription2,
"sub_3" => subscription3,
_ => null
});
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.Received(1).UpdateSubscription("sub_1", Arg.Any<SubscriptionUpdateOptions>());
await _stripeFacade.Received(1).UpdateSubscription("sub_2", Arg.Any<SubscriptionUpdateOptions>());
await _stripeFacade.Received(1).UpdateSubscription("sub_3", Arg.Any<SubscriptionUpdateOptions>());
}
[Fact]
public async Task Execute_MixedSubscriptionsWithProcessed_OnlyProcessesUnprocessed()
{
// Arrange
var context = CreateJobExecutionContext();
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
var processedMetadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o")
};
var subscription1 = CreateSubscription("sub_1", "storage-gb-monthly", quantity: 10);
var subscription2 = CreateSubscription("sub_2", "storage-gb-monthly", quantity: 5, metadata: processedMetadata);
var subscription3 = CreateSubscription("sub_3", "storage-gb-monthly", quantity: 3);
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create(subscription1, subscription2, subscription3));
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Returns(callInfo => callInfo.Arg<string>() switch
{
"sub_1" => subscription1,
"sub_3" => subscription3,
_ => null
});
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.Received(1).UpdateSubscription("sub_1", Arg.Any<SubscriptionUpdateOptions>());
await _stripeFacade.DidNotReceive().UpdateSubscription("sub_2", Arg.Any<SubscriptionUpdateOptions>());
await _stripeFacade.Received(1).UpdateSubscription("sub_3", Arg.Any<SubscriptionUpdateOptions>());
}
#endregion
#region Error Handling Tests
[Fact]
public async Task Execute_UpdateFails_ContinuesProcessingOthers()
{
// Arrange
var context = CreateJobExecutionContext();
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
var subscription1 = CreateSubscription("sub_1", "storage-gb-monthly", quantity: 10);
var subscription2 = CreateSubscription("sub_2", "storage-gb-monthly", quantity: 5);
var subscription3 = CreateSubscription("sub_3", "storage-gb-monthly", quantity: 3);
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create(subscription1, subscription2, subscription3));
_stripeFacade.UpdateSubscription("sub_1", Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription1);
_stripeFacade.UpdateSubscription("sub_2", Arg.Any<SubscriptionUpdateOptions>())
.Throws(new Exception("Stripe API error"));
_stripeFacade.UpdateSubscription("sub_3", Arg.Any<SubscriptionUpdateOptions>())
.Returns(subscription3);
// Act
await _sut.Execute(context);
// Assert
await _stripeFacade.Received(1).UpdateSubscription("sub_1", Arg.Any<SubscriptionUpdateOptions>());
await _stripeFacade.Received(1).UpdateSubscription("sub_2", Arg.Any<SubscriptionUpdateOptions>());
await _stripeFacade.Received(1).UpdateSubscription("sub_3", Arg.Any<SubscriptionUpdateOptions>());
}
[Fact]
public async Task Execute_UpdateFails_LogsError()
{
// Arrange
var context = CreateJobExecutionContext();
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10);
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create(subscription));
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
.Throws(new Exception("Stripe API error"));
// Act
await _sut.Execute(context);
// Assert
_logger.Received().Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
}
#endregion
#region Cancellation Tests
[Fact]
public async Task Execute_CancellationRequested_LogsWarningAndExits()
{
// Arrange
var cts = new CancellationTokenSource();
cts.Cancel(); // Cancel immediately
var context = CreateJobExecutionContext(cts.Token);
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
var subscription1 = CreateSubscription("sub_1", "storage-gb-monthly", quantity: 10);
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
.Returns(AsyncEnumerable.Create(subscription1));
// Act
await _sut.Execute(context);
// Assert - Should not process any subscriptions due to immediate cancellation
await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null);
_logger.Received().Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
}
#endregion
#region Helper Methods
private static IJobExecutionContext CreateJobExecutionContext(CancellationToken cancellationToken = default)
{
var context = Substitute.For<IJobExecutionContext>();
context.CancellationToken.Returns(cancellationToken);
return context;
}
private static Subscription CreateSubscription(
string id,
string priceId,
long? quantity = null,
Dictionary<string, string>? metadata = null)
{
var price = new Price { Id = priceId };
var item = new SubscriptionItem
{
Id = $"si_{id}",
Price = price,
Quantity = quantity ?? 0
};
return new Subscription
{
Id = id,
Metadata = metadata,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem> { item }
}
};
}
#endregion
}
internal static class AsyncEnumerable
{
public static async IAsyncEnumerable<T> Create<T>(params T[] items)
{
foreach (var item in items)
{
yield return item;
}
await Task.CompletedTask;
}
public static async IAsyncEnumerable<T> Empty<T>()
{
await Task.CompletedTask;
yield break;
}
}

View File

@@ -1,4 +1,5 @@
using Bit.Billing.Services;
using System.Globalization;
using Bit.Billing.Services;
using Bit.Billing.Services.Implementations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
@@ -10,7 +11,7 @@ using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Pricing.Premium;
using Bit.Core.Entities;
using Bit.Core.Models.Mail.UpdatedInvoiceIncoming;
using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Repositories;
@@ -117,7 +118,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
@@ -126,10 +127,7 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }
}
Data = [new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } }]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = customerId },
@@ -199,7 +197,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
@@ -208,10 +206,7 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } }
}
Data = [new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } }]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer
@@ -233,7 +228,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } }
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
@@ -272,11 +267,12 @@ public class UpcomingInvoiceHandlerTests
o.Discounts[0].Coupon == CouponIDs.Milestone2SubscriptionDiscount &&
o.ProrationBehavior == "none"));
// Verify the updated invoice email was sent
// Verify the updated invoice email was sent with correct price
await _mailer.Received(1).SendEmail(
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
Arg.Is<Families2020RenewalMail>(email =>
email.ToEmails.Contains("user@example.com") &&
email.Subject == "Your Subscription Will Renew Soon"));
email.Subject == "Your Bitwarden Families renewal is updating" &&
email.View.MonthlyRenewalPrice == (plan.Seat.Price / 12).ToString("C", new CultureInfo("en-US"))));
}
[Fact]
@@ -291,7 +287,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
@@ -307,7 +303,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
var organization = new Organization
@@ -375,7 +371,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
@@ -395,7 +391,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
var organization = new Organization
@@ -469,7 +465,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
@@ -489,7 +485,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
var organization = new Organization
@@ -560,7 +556,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
@@ -576,7 +572,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "UK" },
TaxExempt = TaxExempt.None
};
@@ -622,9 +618,8 @@ public class UpcomingInvoiceHandlerTests
}
[Fact]
public async Task HandleAsync_WhenUpdateSubscriptionItemPriceIdFails_LogsErrorAndSendsEmail()
public async Task HandleAsync_WhenUpdateSubscriptionItemPriceIdFails_LogsErrorAndSendsTraditionalEmail()
{
// Arrange
// Arrange
var parsedEvent = new Event { Id = "evt_123" };
var customerId = "cus_123";
@@ -637,7 +632,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
@@ -646,10 +641,7 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } }
}
Data = [new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } }]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Customer = new Customer
@@ -671,7 +663,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } }
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
@@ -708,11 +700,16 @@ public class UpcomingInvoiceHandlerTests
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
// Verify that email was still sent despite the exception
await _mailer.Received(1).SendEmail(
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
email.ToEmails.Contains("user@example.com") &&
email.Subject == "Your Subscription Will Renew Soon"));
// Verify that traditional email was sent when update fails
await _mailService.Received(1).SendInvoiceUpcoming(
Arg.Is<IEnumerable<string>>(emails => emails.Contains("user@example.com")),
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
Arg.Is<bool>(b => b == true));
// Verify renewal email was NOT sent
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2020RenewalMail>());
}
[Fact]
@@ -727,7 +724,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
@@ -737,12 +734,12 @@ public class UpcomingInvoiceHandlerTests
Items = new StripeList<SubscriptionItem>(),
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
Customer = new Customer { Id = "cus_123" },
Metadata = new Dictionary<string, string>(),
Metadata = new Dictionary<string, string>()
};
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } }
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
@@ -784,7 +781,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Free Item" } }
Data = [new() { Description = "Free Item" }]
}
};
var subscription = new Subscription
@@ -800,7 +797,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } }
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
@@ -841,7 +838,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
@@ -856,7 +853,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } }
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
@@ -885,7 +882,7 @@ public class UpcomingInvoiceHandlerTests
Arg.Any<List<string>>(),
Arg.Any<bool>());
await _mailer.DidNotReceive().SendEmail(Arg.Any<UpdatedInvoiceUpcomingMail>());
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2020RenewalMail>());
}
[Fact]
@@ -900,7 +897,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
var subscription = new Subscription
@@ -915,7 +912,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = "cus_123",
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } }
Subscriptions = new StripeList<Subscription> { Data = [subscription] }
};
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
@@ -964,7 +961,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
@@ -977,8 +974,8 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
@@ -989,7 +986,7 @@ public class UpcomingInvoiceHandlerTests
Id = premiumAccessItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePremiumAccessPlanId }
}
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
@@ -998,7 +995,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
@@ -1045,9 +1042,10 @@ public class UpcomingInvoiceHandlerTests
org.Seats == familiesPlan.PasswordManager.BaseSeats));
await _mailer.Received(1).SendEmail(
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
Arg.Is<Families2020RenewalMail>(email =>
email.ToEmails.Contains("org@example.com") &&
email.Subject == "Your Subscription Will Renew Soon"));
email.Subject == "Your Bitwarden Families renewal is updating" &&
email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))));
}
[Fact]
@@ -1066,7 +1064,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
@@ -1079,14 +1077,14 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
}
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
@@ -1095,7 +1093,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
@@ -1156,7 +1154,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
@@ -1168,14 +1166,14 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
}
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
@@ -1184,7 +1182,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
@@ -1232,7 +1230,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
@@ -1244,14 +1242,10 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new()
{
Id = "si_pm_123",
Price = new Price { Id = familiesPlan.PasswordManager.StripePlanId }
}
}
Data =
[
new() { Id = "si_pm_123", Price = new Price { Id = familiesPlan.PasswordManager.StripePlanId } }
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
@@ -1260,7 +1254,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
@@ -1307,7 +1301,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
@@ -1319,14 +1313,10 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new()
{
Id = "si_different_item",
Price = new Price { Id = "different-price-id" }
}
}
Data =
[
new() { Id = "si_different_item", Price = new Price { Id = "different-price-id" } }
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
@@ -1335,7 +1325,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
@@ -1378,7 +1368,7 @@ public class UpcomingInvoiceHandlerTests
}
[Fact]
public async Task HandleAsync_WhenMilestone3Enabled_AndUpdateFails_LogsError()
public async Task HandleAsync_WhenMilestone3Enabled_AndUpdateFails_LogsErrorAndSendsTraditionalEmail()
{
// Arrange
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
@@ -1393,7 +1383,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
@@ -1406,14 +1396,14 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
}
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
@@ -1422,7 +1412,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
@@ -1463,11 +1453,16 @@ public class UpcomingInvoiceHandlerTests
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
// Should still attempt to send email despite the failure
await _mailer.Received(1).SendEmail(
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
email.ToEmails.Contains("org@example.com") &&
email.Subject == "Your Subscription Will Renew Soon"));
// Should send traditional email when update fails
await _mailService.Received(1).SendInvoiceUpcoming(
Arg.Is<IEnumerable<string>>(emails => emails.Contains("org@example.com")),
Arg.Is<decimal>(amount => amount == invoice.AmountDue / 100M),
Arg.Is<DateTime>(dueDate => dueDate == invoice.NextPaymentAttempt.Value),
Arg.Is<List<string>>(items => items.Count == invoice.Lines.Data.Count),
Arg.Is<bool>(b => b == true));
// Verify renewal email was NOT sent
await _mailer.DidNotReceive().SendEmail(Arg.Any<Families2020RenewalMail>());
}
[Fact]
@@ -1487,7 +1482,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
@@ -1500,20 +1495,21 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
},
new()
{
Id = seatAddOnItemId,
Price = new Price { Id = "personal-org-seat-annually" },
Quantity = 3
}
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
@@ -1522,7 +1518,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
@@ -1569,9 +1565,10 @@ public class UpcomingInvoiceHandlerTests
org.Seats == familiesPlan.PasswordManager.BaseSeats));
await _mailer.Received(1).SendEmail(
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
Arg.Is<Families2020RenewalMail>(email =>
email.ToEmails.Contains("org@example.com") &&
email.Subject == "Your Subscription Will Renew Soon"));
email.Subject == "Your Bitwarden Families renewal is updating" &&
email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))));
}
[Fact]
@@ -1591,7 +1588,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
@@ -1604,20 +1601,21 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
},
new()
{
Id = seatAddOnItemId,
Price = new Price { Id = "personal-org-seat-annually" },
Quantity = 1
}
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
@@ -1626,7 +1624,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
@@ -1673,9 +1671,10 @@ public class UpcomingInvoiceHandlerTests
org.Seats == familiesPlan.PasswordManager.BaseSeats));
await _mailer.Received(1).SendEmail(
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
Arg.Is<Families2020RenewalMail>(email =>
email.ToEmails.Contains("org@example.com") &&
email.Subject == "Your Subscription Will Renew Soon"));
email.Subject == "Your Bitwarden Families renewal is updating" &&
email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))));
}
[Fact]
@@ -1696,7 +1695,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
@@ -1709,25 +1708,27 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
},
new()
{
Id = premiumAccessItemId,
Price = new Price { Id = families2019Plan.PasswordManager.StripePremiumAccessPlanId }
},
new()
{
Id = seatAddOnItemId,
Price = new Price { Id = "personal-org-seat-annually" },
Quantity = 2
}
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
@@ -1736,7 +1737,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
@@ -1785,9 +1786,10 @@ public class UpcomingInvoiceHandlerTests
org.Seats == familiesPlan.PasswordManager.BaseSeats));
await _mailer.Received(1).SendEmail(
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
Arg.Is<Families2020RenewalMail>(email =>
email.ToEmails.Contains("org@example.com") &&
email.Subject == "Your Subscription Will Renew Soon"));
email.Subject == "Your Bitwarden Families renewal is updating" &&
email.View.MonthlyRenewalPrice == (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))));
}
[Fact]
@@ -1806,7 +1808,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
@@ -1819,14 +1821,14 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2025Plan.PasswordManager.StripePlanId }
}
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
@@ -1835,7 +1837,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};
@@ -1895,7 +1897,7 @@ public class UpcomingInvoiceHandlerTests
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
Lines = new StripeList<InvoiceLineItem>
{
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
Data = [new() { Description = "Test Item" }]
}
};
@@ -1907,14 +1909,14 @@ public class UpcomingInvoiceHandlerTests
CustomerId = customerId,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
Data =
[
new()
{
Id = passwordManagerItemId,
Price = new Price { Id = families2025Plan.PasswordManager.StripePlanId }
}
}
]
},
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Metadata = new Dictionary<string, string>()
@@ -1923,7 +1925,7 @@ public class UpcomingInvoiceHandlerTests
var customer = new Customer
{
Id = customerId,
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
Address = new Address { Country = "US" }
};

View File

@@ -14,8 +14,8 @@
<PackageReference Include="MartinCostello.Logging.XUnit" Version="0.5.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Rnwood.SmtpServer" Version="3.1.0-ci0868" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,414 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
using Bit.Core.Billing.Organizations.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Organizations;
[SutProviderCustomize]
public class OrganizationUpdateCommandTests
{
[Theory, BitAutoData]
public async Task UpdateAsync_WhenValidOrganization_UpdatesOrganization(
Guid organizationId,
string name,
string billingEmail,
Organization organization,
SutProvider<OrganizationUpdateCommand> sutProvider)
{
// Arrange
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
var organizationService = sutProvider.GetDependency<IOrganizationService>();
var organizationBillingService = sutProvider.GetDependency<IOrganizationBillingService>();
organization.Id = organizationId;
organization.GatewayCustomerId = null; // No Stripe customer, so no billing update
organizationRepository
.GetByIdAsync(organizationId)
.Returns(organization);
var request = new OrganizationUpdateRequest
{
OrganizationId = organizationId,
Name = name,
BillingEmail = billingEmail
};
// Act
var result = await sutProvider.Sut.UpdateAsync(request);
// Assert
Assert.NotNull(result);
Assert.Equal(organizationId, result.Id);
Assert.Equal(name, result.Name);
Assert.Equal(billingEmail.ToLowerInvariant().Trim(), result.BillingEmail);
await organizationRepository
.Received(1)
.GetByIdAsync(Arg.Is<Guid>(id => id == organizationId));
await organizationService
.Received(1)
.ReplaceAndUpdateCacheAsync(
result,
EventType.Organization_Updated);
await organizationBillingService
.DidNotReceiveWithAnyArgs()
.UpdateOrganizationNameAndEmail(Arg.Any<Organization>());
}
[Theory, BitAutoData]
public async Task UpdateAsync_WhenOrganizationNotFound_ThrowsNotFoundException(
Guid organizationId,
string name,
string billingEmail,
SutProvider<OrganizationUpdateCommand> sutProvider)
{
// Arrange
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
organizationRepository
.GetByIdAsync(organizationId)
.Returns((Organization)null);
var request = new OrganizationUpdateRequest
{
OrganizationId = organizationId,
Name = name,
BillingEmail = billingEmail
};
// Act/Assert
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.UpdateAsync(request));
}
[Theory]
[BitAutoData("")]
[BitAutoData((string)null)]
public async Task UpdateAsync_WhenGatewayCustomerIdIsNullOrEmpty_SkipsBillingUpdate(
string gatewayCustomerId,
Guid organizationId,
Organization organization,
SutProvider<OrganizationUpdateCommand> sutProvider)
{
// Arrange
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
var organizationService = sutProvider.GetDependency<IOrganizationService>();
var organizationBillingService = sutProvider.GetDependency<IOrganizationBillingService>();
organization.Id = organizationId;
organization.Name = "Old Name";
organization.GatewayCustomerId = gatewayCustomerId;
organizationRepository
.GetByIdAsync(organizationId)
.Returns(organization);
var request = new OrganizationUpdateRequest
{
OrganizationId = organizationId,
Name = "New Name",
BillingEmail = organization.BillingEmail
};
// Act
var result = await sutProvider.Sut.UpdateAsync(request);
// Assert
Assert.NotNull(result);
Assert.Equal(organizationId, result.Id);
Assert.Equal("New Name", result.Name);
await organizationService
.Received(1)
.ReplaceAndUpdateCacheAsync(
result,
EventType.Organization_Updated);
await organizationBillingService
.DidNotReceiveWithAnyArgs()
.UpdateOrganizationNameAndEmail(Arg.Any<Organization>());
}
[Theory, BitAutoData]
public async Task UpdateAsync_WhenKeysProvided_AndNotAlreadySet_SetsKeys(
Guid organizationId,
string publicKey,
string encryptedPrivateKey,
Organization organization,
SutProvider<OrganizationUpdateCommand> sutProvider)
{
// Arrange
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
var organizationService = sutProvider.GetDependency<IOrganizationService>();
organization.Id = organizationId;
organization.PublicKey = null;
organization.PrivateKey = null;
organizationRepository
.GetByIdAsync(organizationId)
.Returns(organization);
var request = new OrganizationUpdateRequest
{
OrganizationId = organizationId,
Name = organization.Name,
BillingEmail = organization.BillingEmail,
PublicKey = publicKey,
EncryptedPrivateKey = encryptedPrivateKey
};
// Act
var result = await sutProvider.Sut.UpdateAsync(request);
// Assert
Assert.NotNull(result);
Assert.Equal(organizationId, result.Id);
Assert.Equal(publicKey, result.PublicKey);
Assert.Equal(encryptedPrivateKey, result.PrivateKey);
await organizationService
.Received(1)
.ReplaceAndUpdateCacheAsync(
result,
EventType.Organization_Updated);
}
[Theory, BitAutoData]
public async Task UpdateAsync_WhenKeysProvided_AndAlreadySet_DoesNotOverwriteKeys(
Guid organizationId,
string newPublicKey,
string newEncryptedPrivateKey,
Organization organization,
SutProvider<OrganizationUpdateCommand> sutProvider)
{
// Arrange
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
var organizationService = sutProvider.GetDependency<IOrganizationService>();
organization.Id = organizationId;
var existingPublicKey = organization.PublicKey;
var existingPrivateKey = organization.PrivateKey;
organizationRepository
.GetByIdAsync(organizationId)
.Returns(organization);
var request = new OrganizationUpdateRequest
{
OrganizationId = organizationId,
Name = organization.Name,
BillingEmail = organization.BillingEmail,
PublicKey = newPublicKey,
EncryptedPrivateKey = newEncryptedPrivateKey
};
// Act
var result = await sutProvider.Sut.UpdateAsync(request);
// Assert
Assert.NotNull(result);
Assert.Equal(organizationId, result.Id);
Assert.Equal(existingPublicKey, result.PublicKey);
Assert.Equal(existingPrivateKey, result.PrivateKey);
await organizationService
.Received(1)
.ReplaceAndUpdateCacheAsync(
result,
EventType.Organization_Updated);
}
[Theory, BitAutoData]
public async Task UpdateAsync_UpdatingNameOnly_UpdatesNameAndNotBillingEmail(
Guid organizationId,
string newName,
Organization organization,
SutProvider<OrganizationUpdateCommand> sutProvider)
{
// Arrange
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
var organizationService = sutProvider.GetDependency<IOrganizationService>();
var organizationBillingService = sutProvider.GetDependency<IOrganizationBillingService>();
organization.Id = organizationId;
organization.Name = "Old Name";
var originalBillingEmail = organization.BillingEmail;
organizationRepository
.GetByIdAsync(organizationId)
.Returns(organization);
var request = new OrganizationUpdateRequest
{
OrganizationId = organizationId,
Name = newName,
BillingEmail = null
};
// Act
var result = await sutProvider.Sut.UpdateAsync(request);
// Assert
Assert.NotNull(result);
Assert.Equal(organizationId, result.Id);
Assert.Equal(newName, result.Name);
Assert.Equal(originalBillingEmail, result.BillingEmail);
await organizationService
.Received(1)
.ReplaceAndUpdateCacheAsync(
result,
EventType.Organization_Updated);
await organizationBillingService
.Received(1)
.UpdateOrganizationNameAndEmail(result);
}
[Theory, BitAutoData]
public async Task UpdateAsync_UpdatingBillingEmailOnly_UpdatesBillingEmailAndNotName(
Guid organizationId,
string newBillingEmail,
Organization organization,
SutProvider<OrganizationUpdateCommand> sutProvider)
{
// Arrange
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
var organizationService = sutProvider.GetDependency<IOrganizationService>();
var organizationBillingService = sutProvider.GetDependency<IOrganizationBillingService>();
organization.Id = organizationId;
organization.BillingEmail = "old@example.com";
var originalName = organization.Name;
organizationRepository
.GetByIdAsync(organizationId)
.Returns(organization);
var request = new OrganizationUpdateRequest
{
OrganizationId = organizationId,
Name = null,
BillingEmail = newBillingEmail
};
// Act
var result = await sutProvider.Sut.UpdateAsync(request);
// Assert
Assert.NotNull(result);
Assert.Equal(organizationId, result.Id);
Assert.Equal(originalName, result.Name);
Assert.Equal(newBillingEmail.ToLowerInvariant().Trim(), result.BillingEmail);
await organizationService
.Received(1)
.ReplaceAndUpdateCacheAsync(
result,
EventType.Organization_Updated);
await organizationBillingService
.Received(1)
.UpdateOrganizationNameAndEmail(result);
}
[Theory, BitAutoData]
public async Task UpdateAsync_WhenNoChanges_PreservesBothFields(
Guid organizationId,
Organization organization,
SutProvider<OrganizationUpdateCommand> sutProvider)
{
// Arrange
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
var organizationService = sutProvider.GetDependency<IOrganizationService>();
var organizationBillingService = sutProvider.GetDependency<IOrganizationBillingService>();
organization.Id = organizationId;
var originalName = organization.Name;
var originalBillingEmail = organization.BillingEmail;
organizationRepository
.GetByIdAsync(organizationId)
.Returns(organization);
var request = new OrganizationUpdateRequest
{
OrganizationId = organizationId,
Name = null,
BillingEmail = null
};
// Act
var result = await sutProvider.Sut.UpdateAsync(request);
// Assert
Assert.NotNull(result);
Assert.Equal(organizationId, result.Id);
Assert.Equal(originalName, result.Name);
Assert.Equal(originalBillingEmail, result.BillingEmail);
await organizationService
.Received(1)
.ReplaceAndUpdateCacheAsync(
result,
EventType.Organization_Updated);
await organizationBillingService
.DidNotReceiveWithAnyArgs()
.UpdateOrganizationNameAndEmail(Arg.Any<Organization>());
}
[Theory, BitAutoData]
public async Task UpdateAsync_SelfHosted_OnlyUpdatesKeysNotOrganizationDetails(
Guid organizationId,
string newName,
string newBillingEmail,
string publicKey,
string encryptedPrivateKey,
Organization organization,
SutProvider<OrganizationUpdateCommand> sutProvider)
{
// Arrange
var organizationBillingService = sutProvider.GetDependency<IOrganizationBillingService>();
var globalSettings = sutProvider.GetDependency<IGlobalSettings>();
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
globalSettings.SelfHosted.Returns(true);
organization.Id = organizationId;
organization.Name = "Original Name";
organization.BillingEmail = "original@example.com";
organization.PublicKey = null;
organization.PrivateKey = null;
organizationRepository.GetByIdAsync(organizationId).Returns(organization);
var request = new OrganizationUpdateRequest
{
OrganizationId = organizationId,
Name = newName, // Should be ignored
BillingEmail = newBillingEmail, // Should be ignored
PublicKey = publicKey,
EncryptedPrivateKey = encryptedPrivateKey
};
// Act
var result = await sutProvider.Sut.UpdateAsync(request);
// Assert
Assert.Equal("Original Name", result.Name); // Not changed
Assert.Equal("original@example.com", result.BillingEmail); // Not changed
Assert.Equal(publicKey, result.PublicKey); // Changed
Assert.Equal(encryptedPrivateKey, result.PrivateKey); // Changed
await organizationBillingService
.DidNotReceiveWithAnyArgs()
.UpdateOrganizationNameAndEmail(Arg.Any<Organization>());
}
}

View File

@@ -14,6 +14,7 @@ using Bit.Test.Common.Helpers;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.Test.Services;
@@ -25,7 +26,6 @@ public class EventIntegrationHandlerTests
private const string _templateWithOrganization = "Org: #OrganizationName#";
private const string _templateWithUser = "#UserName#, #UserEmail#, #UserType#";
private const string _templateWithActingUser = "#ActingUserName#, #ActingUserEmail#, #ActingUserType#";
private static readonly Guid _groupId = Guid.NewGuid();
private static readonly Guid _organizationId = Guid.NewGuid();
private static readonly Uri _uri = new Uri("https://localhost");
private static readonly Uri _uri2 = new Uri("https://example.com");
@@ -113,6 +113,232 @@ public class EventIntegrationHandlerTests
return [config];
}
[Theory, BitAutoData]
public async Task BuildContextAsync_ActingUserIdPresent_UsesCache(EventMessage eventMessage, OrganizationUserUserDetails actingUser)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateWithActingUser));
var cache = sutProvider.GetDependency<IFusionCache>();
eventMessage.OrganizationId ??= Guid.NewGuid();
eventMessage.ActingUserId ??= Guid.NewGuid();
cache.GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<OrganizationUserUserDetails?>, CancellationToken, Task<OrganizationUserUserDetails?>>>()
).Returns(actingUser);
var context = await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithActingUser);
await cache.Received(1).GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<OrganizationUserUserDetails?>, CancellationToken, Task<OrganizationUserUserDetails?>>>()
);
Assert.Equal(actingUser, context.ActingUser);
}
[Theory, BitAutoData]
public async Task BuildContextAsync_ActingUserIdNull_SkipsCache(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateWithActingUser));
var cache = sutProvider.GetDependency<IFusionCache>();
eventMessage.OrganizationId ??= Guid.NewGuid();
eventMessage.ActingUserId = null;
var context = await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithActingUser);
await cache.DidNotReceive().GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<OrganizationUserUserDetails?>, CancellationToken, Task<OrganizationUserUserDetails?>>>()
);
Assert.Null(context.ActingUser);
}
[Theory, BitAutoData]
public async Task BuildContextAsync_ActingUserOrganizationIdNull_SkipsCache(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateWithActingUser));
var cache = sutProvider.GetDependency<IFusionCache>();
eventMessage.OrganizationId = null;
eventMessage.ActingUserId ??= Guid.NewGuid();
var context = await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithActingUser);
await cache.DidNotReceive().GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<OrganizationUserUserDetails?>, CancellationToken, Task<OrganizationUserUserDetails?>>>()
);
Assert.Null(context.ActingUser);
}
[Theory, BitAutoData]
public async Task BuildContextAsync_GroupIdPresent_UsesCache(EventMessage eventMessage, Group group)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateWithGroup));
var cache = sutProvider.GetDependency<IFusionCache>();
eventMessage.GroupId ??= Guid.NewGuid();
cache.GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<Group?>, CancellationToken, Task<Group?>>>()
).Returns(group);
var context = await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithGroup);
await cache.Received(1).GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<Group?>, CancellationToken, Task<Group?>>>()
);
Assert.Equal(group, context.Group);
}
[Theory, BitAutoData]
public async Task BuildContextAsync_GroupIdNull_SkipsCache(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateWithGroup));
var cache = sutProvider.GetDependency<IFusionCache>();
eventMessage.GroupId = null;
var context = await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithGroup);
await cache.DidNotReceive().GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<Group?>, CancellationToken, Task<Group?>>>()
);
Assert.Null(context.Group);
}
[Theory, BitAutoData]
public async Task BuildContextAsync_OrganizationIdPresent_UsesCache(EventMessage eventMessage, Organization organization)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateWithOrganization));
var cache = sutProvider.GetDependency<IFusionCache>();
eventMessage.OrganizationId ??= Guid.NewGuid();
cache.GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<Organization?>, CancellationToken, Task<Organization?>>>()
).Returns(organization);
var context = await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithOrganization);
await cache.Received(1).GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<Organization?>, CancellationToken, Task<Organization?>>>()
);
Assert.Equal(organization, context.Organization);
}
[Theory, BitAutoData]
public async Task BuildContextAsync_OrganizationIdNull_SkipsCache(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateWithOrganization));
var cache = sutProvider.GetDependency<IFusionCache>();
eventMessage.OrganizationId = null;
var context = await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithOrganization);
await cache.DidNotReceive().GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<Organization?>, CancellationToken, Task<Organization?>>>()
);
Assert.Null(context.Organization);
}
[Theory, BitAutoData]
public async Task BuildContextAsync_UserIdPresent_UsesCache(EventMessage eventMessage, OrganizationUserUserDetails userDetails)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateWithUser));
var cache = sutProvider.GetDependency<IFusionCache>();
eventMessage.OrganizationId ??= Guid.NewGuid();
eventMessage.UserId ??= Guid.NewGuid();
cache.GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<OrganizationUserUserDetails?>, CancellationToken, Task<OrganizationUserUserDetails?>>>()
).Returns(userDetails);
var context = await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithUser);
await cache.Received(1).GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<OrganizationUserUserDetails?>, CancellationToken, Task<OrganizationUserUserDetails?>>>()
);
Assert.Equal(userDetails, context.User);
}
[Theory, BitAutoData]
public async Task BuildContextAsync_UserIdNull_SkipsCache(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateWithUser));
var cache = sutProvider.GetDependency<IFusionCache>();
eventMessage.OrganizationId = null;
eventMessage.UserId ??= Guid.NewGuid();
var context = await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithUser);
await cache.DidNotReceive().GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<OrganizationUserUserDetails?>, CancellationToken, Task<OrganizationUserUserDetails?>>>()
);
Assert.Null(context.User);
}
[Theory, BitAutoData]
public async Task BuildContextAsync_OrganizationUserIdNull_SkipsCache(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateWithUser));
var cache = sutProvider.GetDependency<IFusionCache>();
eventMessage.OrganizationId ??= Guid.NewGuid();
eventMessage.UserId = null;
var context = await sutProvider.Sut.BuildContextAsync(eventMessage, _templateWithUser);
await cache.DidNotReceive().GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<OrganizationUserUserDetails?>, CancellationToken, Task<OrganizationUserUserDetails?>>>()
);
Assert.Null(context.User);
}
[Theory, BitAutoData]
public async Task BuildContextAsync_NoSpecialTokens_DoesNotCallAnyCache(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateWithUser));
var cache = sutProvider.GetDependency<IFusionCache>();
eventMessage.ActingUserId ??= Guid.NewGuid();
eventMessage.GroupId ??= Guid.NewGuid();
eventMessage.OrganizationId ??= Guid.NewGuid();
eventMessage.UserId ??= Guid.NewGuid();
await sutProvider.Sut.BuildContextAsync(eventMessage, _templateBase);
await cache.DidNotReceive().GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<Group?>, CancellationToken, Task<Group?>>>()
);
await cache.DidNotReceive().GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<Organization?>, CancellationToken, Task<Organization?>>>()
);
await cache.DidNotReceive().GetOrSetAsync(
key: Arg.Any<string>(),
factory: Arg.Any<Func<FusionCacheFactoryExecutionContext<OrganizationUserUserDetails?>, CancellationToken, Task<OrganizationUserUserDetails?>>>()
);
}
[Theory, BitAutoData]
public async Task HandleEventAsync_BaseTemplateNoConfigurations_DoesNothing(EventMessage eventMessage)
@@ -176,99 +402,6 @@ public class EventIntegrationHandlerTests
await sutProvider.GetDependency<IOrganizationUserRepository>().DidNotReceiveWithAnyArgs().GetDetailsByOrganizationIdUserIdAsync(Arg.Any<Guid>(), Arg.Any<Guid>());
}
[Theory, BitAutoData]
public async Task HandleEventAsync_ActingUserTemplate_LoadsUserFromRepository(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateWithActingUser));
var user = Substitute.For<OrganizationUserUserDetails>();
user.Email = "test@example.com";
user.Name = "Test";
eventMessage.OrganizationId = _organizationId;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetDetailsByOrganizationIdUserIdAsync(Arg.Any<Guid>(), Arg.Any<Guid>()).Returns(user);
await sutProvider.Sut.HandleEventAsync(eventMessage);
var expectedMessage = EventIntegrationHandlerTests.ExpectedMessage($"{user.Name}, {user.Email}, {user.Type}");
Assert.Single(_eventIntegrationPublisher.ReceivedCalls());
await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is(
AssertHelper.AssertPropertyEqual(expectedMessage, new[] { "MessageId" })));
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>());
await sutProvider.GetDependency<IOrganizationRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>());
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).GetDetailsByOrganizationIdUserIdAsync(Arg.Any<Guid>(), eventMessage.ActingUserId ?? Guid.Empty);
}
[Theory, BitAutoData]
public async Task HandleEventAsync_GroupTemplate_LoadsGroupFromRepository(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateWithGroup));
var group = Substitute.For<Group>();
group.Name = "Test";
eventMessage.GroupId = _groupId;
eventMessage.OrganizationId = _organizationId;
sutProvider.GetDependency<IGroupRepository>().GetByIdAsync(Arg.Any<Guid>()).Returns(group);
await sutProvider.Sut.HandleEventAsync(eventMessage);
Assert.Single(_eventIntegrationPublisher.ReceivedCalls());
var expectedMessage = EventIntegrationHandlerTests.ExpectedMessage($"Group: {group.Name}");
Assert.Single(_eventIntegrationPublisher.ReceivedCalls());
await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is(
AssertHelper.AssertPropertyEqual(expectedMessage, new[] { "MessageId" })));
await sutProvider.GetDependency<IGroupRepository>().Received(1).GetByIdAsync(eventMessage.GroupId ?? Guid.Empty);
await sutProvider.GetDependency<IOrganizationRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>());
await sutProvider.GetDependency<IOrganizationUserRepository>().DidNotReceiveWithAnyArgs().GetDetailsByOrganizationIdUserIdAsync(Arg.Any<Guid>(), Arg.Any<Guid>());
}
[Theory, BitAutoData]
public async Task HandleEventAsync_OrganizationTemplate_LoadsOrganizationFromRepository(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateWithOrganization));
var organization = Substitute.For<Organization>();
organization.Name = "Test";
eventMessage.OrganizationId = _organizationId;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(Arg.Any<Guid>()).Returns(organization);
await sutProvider.Sut.HandleEventAsync(eventMessage);
Assert.Single(_eventIntegrationPublisher.ReceivedCalls());
var expectedMessage = EventIntegrationHandlerTests.ExpectedMessage($"Org: {organization.Name}");
Assert.Single(_eventIntegrationPublisher.ReceivedCalls());
await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is(
AssertHelper.AssertPropertyEqual(expectedMessage, new[] { "MessageId" })));
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>());
await sutProvider.GetDependency<IOrganizationRepository>().Received(1).GetByIdAsync(eventMessage.OrganizationId ?? Guid.Empty);
await sutProvider.GetDependency<IOrganizationUserRepository>().DidNotReceiveWithAnyArgs().GetDetailsByOrganizationIdUserIdAsync(Arg.Any<Guid>(), Arg.Any<Guid>());
}
[Theory, BitAutoData]
public async Task HandleEventAsync_UserTemplate_LoadsUserFromRepository(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateWithUser));
var user = Substitute.For<OrganizationUserUserDetails>();
user.Email = "test@example.com";
user.Name = "Test";
eventMessage.OrganizationId = _organizationId;
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetDetailsByOrganizationIdUserIdAsync(Arg.Any<Guid>(), Arg.Any<Guid>()).Returns(user);
await sutProvider.Sut.HandleEventAsync(eventMessage);
var expectedMessage = EventIntegrationHandlerTests.ExpectedMessage($"{user.Name}, {user.Email}, {user.Type}");
Assert.Single(_eventIntegrationPublisher.ReceivedCalls());
await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is(
AssertHelper.AssertPropertyEqual(expectedMessage, new[] { "MessageId" })));
await sutProvider.GetDependency<IGroupRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>());
await sutProvider.GetDependency<IOrganizationRepository>().DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any<Guid>());
await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1).GetDetailsByOrganizationIdUserIdAsync(Arg.Any<Guid>(), eventMessage.UserId ?? Guid.Empty);
}
[Theory, BitAutoData]
public async Task HandleEventAsync_FilterReturnsFalse_DoesNothing(EventMessage eventMessage)
{

View File

@@ -1,4 +1,5 @@
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.UserFeatures.Registration.Implementations;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
@@ -40,22 +41,55 @@ public class SendVerificationEmailForRegistrationCommandTests
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>())
.Returns(false);
sutProvider.GetDependency<IMailService>()
.SendRegistrationVerificationEmailAsync(email, Arg.Any<string>())
.Returns(Task.CompletedTask);
var mockedToken = "token";
sutProvider.GetDependency<IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable>>()
.Protect(Arg.Any<RegistrationEmailVerificationTokenable>())
.Returns(mockedToken);
// Act
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails);
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null);
// Assert
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendRegistrationVerificationEmailAsync(email, mockedToken);
.SendRegistrationVerificationEmailAsync(email, mockedToken, null);
Assert.Null(result);
}
[Theory]
[BitAutoData]
public async Task SendVerificationEmailForRegistrationCommand_WhenFromMarketingIsPremium_SendsEmailWithMarketingParameterAndReturnsNull(SutProvider<SendVerificationEmailForRegistrationCommand> sutProvider,
string email, string name, bool receiveMarketingEmails)
{
// Arrange
sutProvider.GetDependency<IUserRepository>()
.GetByEmailAsync(email)
.ReturnsNull();
sutProvider.GetDependency<GlobalSettings>()
.EnableEmailVerification = true;
sutProvider.GetDependency<GlobalSettings>()
.DisableUserRegistration = false;
sutProvider.GetDependency<IOrganizationDomainRepository>()
.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any<string>())
.Returns(false);
var mockedToken = "token";
sutProvider.GetDependency<IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable>>()
.Protect(Arg.Any<RegistrationEmailVerificationTokenable>())
.Returns(mockedToken);
var fromMarketing = MarketingInitiativeConstants.Premium;
// Act
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, fromMarketing);
// Assert
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendRegistrationVerificationEmailAsync(email, mockedToken, fromMarketing);
Assert.Null(result);
}
@@ -87,12 +121,12 @@ public class SendVerificationEmailForRegistrationCommandTests
.Returns(mockedToken);
// Act
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails);
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null);
// Assert
await sutProvider.GetDependency<IMailService>()
.DidNotReceive()
.SendRegistrationVerificationEmailAsync(email, mockedToken);
.SendRegistrationVerificationEmailAsync(email, mockedToken, null);
Assert.Null(result);
}
@@ -124,7 +158,7 @@ public class SendVerificationEmailForRegistrationCommandTests
.Returns(mockedToken);
// Act
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails);
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null);
// Assert
Assert.Equal(mockedToken, result);
@@ -140,7 +174,7 @@ public class SendVerificationEmailForRegistrationCommandTests
.DisableUserRegistration = true;
// Act & Assert
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails));
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails, null));
}
[Theory]
@@ -166,7 +200,7 @@ public class SendVerificationEmailForRegistrationCommandTests
.Returns(false);
// Act & Assert
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails));
await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails, null));
}
[Theory]
@@ -177,7 +211,7 @@ public class SendVerificationEmailForRegistrationCommandTests
sutProvider.GetDependency<GlobalSettings>()
.DisableUserRegistration = false;
await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.Run(null, name, receiveMarketingEmails));
await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.Run(null, name, receiveMarketingEmails, null));
}
[Theory]
@@ -187,7 +221,7 @@ public class SendVerificationEmailForRegistrationCommandTests
{
sutProvider.GetDependency<GlobalSettings>()
.DisableUserRegistration = false;
await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.Run("", name, receiveMarketingEmails));
await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.Run("", name, receiveMarketingEmails, null));
}
[Theory]
@@ -210,7 +244,7 @@ public class SendVerificationEmailForRegistrationCommandTests
.Returns(true);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails));
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.Run(email, name, receiveMarketingEmails, null));
Assert.Equal("This email address is claimed by an organization using Bitwarden.", exception.Message);
}
@@ -246,7 +280,7 @@ public class SendVerificationEmailForRegistrationCommandTests
.Returns(mockedToken);
// Act
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails);
var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null);
// Assert
Assert.Equal(mockedToken, result);
@@ -270,7 +304,7 @@ public class SendVerificationEmailForRegistrationCommandTests
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.Run(email, name, receiveMarketingEmails));
sutProvider.Sut.Run(email, name, receiveMarketingEmails, null));
Assert.Equal("Invalid email address format.", exception.Message);
}
}

View File

@@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models.Sales;
@@ -353,4 +354,97 @@ public class OrganizationBillingServiceTests
}
#endregion
[Theory, BitAutoData]
public async Task UpdateOrganizationNameAndEmail_UpdatesStripeCustomer(
Organization organization,
SutProvider<OrganizationBillingService> sutProvider)
{
organization.Name = "Short name";
CustomerUpdateOptions capturedOptions = null;
sutProvider.GetDependency<IStripeAdapter>()
.CustomerUpdateAsync(
Arg.Is<string>(id => id == organization.GatewayCustomerId),
Arg.Do<CustomerUpdateOptions>(options => capturedOptions = options))
.Returns(new Customer());
// Act
await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization);
// Assert
await sutProvider.GetDependency<IStripeAdapter>()
.Received(1)
.CustomerUpdateAsync(
organization.GatewayCustomerId,
Arg.Any<CustomerUpdateOptions>());
Assert.NotNull(capturedOptions);
Assert.Equal(organization.BillingEmail, capturedOptions.Email);
Assert.Equal(organization.DisplayName(), capturedOptions.Description);
Assert.NotNull(capturedOptions.InvoiceSettings);
Assert.NotNull(capturedOptions.InvoiceSettings.CustomFields);
Assert.Single(capturedOptions.InvoiceSettings.CustomFields);
var customField = capturedOptions.InvoiceSettings.CustomFields.First();
Assert.Equal(organization.SubscriberType(), customField.Name);
Assert.Equal(organization.DisplayName(), customField.Value);
}
[Theory, BitAutoData]
public async Task UpdateOrganizationNameAndEmail_WhenNameIsLong_TruncatesTo30Characters(
Organization organization,
SutProvider<OrganizationBillingService> sutProvider)
{
// Arrange
organization.Name = "This is a very long organization name that exceeds thirty characters";
CustomerUpdateOptions capturedOptions = null;
sutProvider.GetDependency<IStripeAdapter>()
.CustomerUpdateAsync(
Arg.Is<string>(id => id == organization.GatewayCustomerId),
Arg.Do<CustomerUpdateOptions>(options => capturedOptions = options))
.Returns(new Customer());
// Act
await sutProvider.Sut.UpdateOrganizationNameAndEmail(organization);
// Assert
await sutProvider.GetDependency<IStripeAdapter>()
.Received(1)
.CustomerUpdateAsync(
organization.GatewayCustomerId,
Arg.Any<CustomerUpdateOptions>());
Assert.NotNull(capturedOptions);
Assert.NotNull(capturedOptions.InvoiceSettings);
Assert.NotNull(capturedOptions.InvoiceSettings.CustomFields);
var customField = capturedOptions.InvoiceSettings.CustomFields.First();
Assert.Equal(30, customField.Value.Length);
var expectedCustomFieldDisplayName = "This is a very long organizati";
Assert.Equal(expectedCustomFieldDisplayName, customField.Value);
}
[Theory, BitAutoData]
public async Task UpdateOrganizationNameAndEmail_WhenGatewayCustomerIdIsNull_ThrowsBillingException(
Organization organization,
SutProvider<OrganizationBillingService> sutProvider)
{
// Arrange
organization.GatewayCustomerId = null;
organization.Name = "Test Organization";
organization.BillingEmail = "billing@example.com";
// Act & Assert
var exception = await Assert.ThrowsAsync<BillingException>(
() => sutProvider.Sut.UpdateOrganizationNameAndEmail(organization));
Assert.Contains("Cannot update an organization in Stripe without a GatewayCustomerId.", exception.Response);
await sutProvider.GetDependency<IStripeAdapter>()
.DidNotReceiveWithAnyArgs()
.CustomerUpdateAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
}
}

View File

@@ -0,0 +1,41 @@
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Core.Test.Utilities;
public class EventIntegrationsCacheConstantsTests
{
[Theory, BitAutoData]
public void BuildCacheKeyForGroup_ReturnsExpectedKey(Guid groupId)
{
var expected = $"Group:{groupId:N}";
var key = EventIntegrationsCacheConstants.BuildCacheKeyForGroup(groupId);
Assert.Equal(expected, key);
}
[Theory, BitAutoData]
public void BuildCacheKeyForOrganization_ReturnsExpectedKey(Guid orgId)
{
var expected = $"Organization:{orgId:N}";
var key = EventIntegrationsCacheConstants.BuildCacheKeyForOrganization(orgId);
Assert.Equal(expected, key);
}
[Theory, BitAutoData]
public void BuildCacheKeyForOrganizationUser_ReturnsExpectedKey(Guid orgId, Guid userId)
{
var expected = $"OrganizationUserUserDetails:{orgId:N}:{userId:N}";
var key = EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationUser(orgId, userId);
Assert.Equal(expected, key);
}
[Fact]
public void CacheName_ReturnsExpected()
{
Assert.Equal("EventIntegrations", EventIntegrationsCacheConstants.CacheName);
}
}

View File

@@ -241,7 +241,7 @@ public class AccountsControllerTests : IDisposable
var token = "fakeToken";
_sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails).Returns(token);
_sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails, null).Returns(token);
// Act
var result = await _sut.PostRegisterSendVerificationEmail(model);
@@ -264,7 +264,7 @@ public class AccountsControllerTests : IDisposable
ReceiveMarketingEmails = receiveMarketingEmails
};
_sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails).ReturnsNull();
_sendVerificationEmailForRegistrationCommand.Run(email, name, receiveMarketingEmails, null).ReturnsNull();
// Act
var result = await _sut.PostRegisterSendVerificationEmail(model);
@@ -274,6 +274,55 @@ public class AccountsControllerTests : IDisposable
Assert.Equal(204, noContentResult.StatusCode);
}
[Theory]
[BitAutoData]
public async Task PostRegisterSendEmailVerification_WhenFeatureFlagEnabled_PassesFromMarketingToCommandAsync(
string email, string name, bool receiveMarketingEmails)
{
// Arrange
var fromMarketing = MarketingInitiativeConstants.Premium;
var model = new RegisterSendVerificationEmailRequestModel
{
Email = email,
Name = name,
ReceiveMarketingEmails = receiveMarketingEmails,
FromMarketing = fromMarketing,
};
_featureService.IsEnabled(FeatureFlagKeys.MarketingInitiatedPremiumFlow).Returns(true);
// Act
await _sut.PostRegisterSendVerificationEmail(model);
// Assert
await _sendVerificationEmailForRegistrationCommand.Received(1)
.Run(email, name, receiveMarketingEmails, fromMarketing);
}
[Theory]
[BitAutoData]
public async Task PostRegisterSendEmailVerification_WhenFeatureFlagDisabled_PassesNullFromMarketingToCommandAsync(
string email, string name, bool receiveMarketingEmails)
{
// Arrange
var model = new RegisterSendVerificationEmailRequestModel
{
Email = email,
Name = name,
ReceiveMarketingEmails = receiveMarketingEmails,
FromMarketing = MarketingInitiativeConstants.Premium, // model includes FromMarketing: "premium"
};
_featureService.IsEnabled(FeatureFlagKeys.MarketingInitiatedPremiumFlow).Returns(false);
// Act
await _sut.PostRegisterSendVerificationEmail(model);
// Assert
await _sendVerificationEmailForRegistrationCommand.Received(1)
.Run(email, name, receiveMarketingEmails, null); // fromMarketing gets ignored and null gets passed
}
[Theory, BitAutoData]
public async Task PostRegisterFinish_WhenGivenOrgInvite_ShouldRegisterUser(
string email, string masterPasswordHash, string orgInviteToken, Guid organizationUserId, string userSymmetricKey,

View File

@@ -10,7 +10,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View File

@@ -35,7 +35,7 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase<Startup>
// This allows us to use the official registration flow
SubstituteService<IMailService>(service =>
{
service.SendRegistrationVerificationEmailAsync(Arg.Any<string>(), Arg.Any<string>())
service.SendRegistrationVerificationEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.ReturnsForAnyArgs(Task.CompletedTask)
.AndDoes(call =>
{