1
0
mirror of https://github.com/bitwarden/server synced 2025-12-26 13:13:24 +00:00

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

This commit is contained in:
Patrick Pimentel
2025-11-17 16:37:47 -05:00
137 changed files with 10715 additions and 873 deletions

View File

@@ -20,6 +20,20 @@ public class IntegrationTemplateContextTests
Assert.Equal(expected, sut.EventMessage);
}
[Theory, BitAutoData]
public void DateIso8601_ReturnsIso8601FormattedDate(EventMessage eventMessage)
{
var testDate = new DateTime(2025, 10, 27, 13, 30, 0, DateTimeKind.Utc);
eventMessage.Date = testDate;
var sut = new IntegrationTemplateContext(eventMessage);
var result = sut.DateIso8601;
Assert.Equal("2025-10-27T13:30:00.0000000Z", result);
// Verify it's valid ISO 8601
Assert.True(DateTime.TryParse(result, out _));
}
[Theory, BitAutoData]
public void UserName_WhenUserIsSet_ReturnsName(EventMessage eventMessage, User user)
{

View File

@@ -97,6 +97,8 @@ public class ConfirmOrganizationUserCommandTests
[BitAutoData(PlanType.EnterpriseMonthly2019, OrganizationUserType.Owner)]
[BitAutoData(PlanType.FamiliesAnnually, OrganizationUserType.Admin)]
[BitAutoData(PlanType.FamiliesAnnually, OrganizationUserType.Owner)]
[BitAutoData(PlanType.FamiliesAnnually2025, OrganizationUserType.Admin)]
[BitAutoData(PlanType.FamiliesAnnually2025, OrganizationUserType.Owner)]
[BitAutoData(PlanType.FamiliesAnnually2019, OrganizationUserType.Admin)]
[BitAutoData(PlanType.FamiliesAnnually2019, OrganizationUserType.Owner)]
[BitAutoData(PlanType.TeamsAnnually, OrganizationUserType.Admin)]

View File

@@ -23,6 +23,7 @@ public class CloudICloudOrganizationSignUpCommandTests
{
[Theory]
[BitAutoData(PlanType.FamiliesAnnually)]
[BitAutoData(PlanType.FamiliesAnnually2025)]
public async Task SignUp_PM_Family_Passes(PlanType planType, OrganizationSignup signup, SutProvider<CloudOrganizationSignUpCommand> sutProvider)
{
signup.Plan = planType;
@@ -65,6 +66,7 @@ public class CloudICloudOrganizationSignUpCommandTests
[Theory]
[BitAutoData(PlanType.FamiliesAnnually)]
[BitAutoData(PlanType.FamiliesAnnually2025)]
public async Task SignUp_AssignsOwnerToDefaultCollection
(PlanType planType, OrganizationSignup signup, SutProvider<CloudOrganizationSignUpCommand> sutProvider)
{

View File

@@ -0,0 +1,628 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Test.AdminConsole.AutoFixture;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
[SutProviderCustomize]
public class AutomaticUserConfirmationPolicyEventHandlerTests
{
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_SingleOrgNotEnabled_ReturnsError(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns((Policy?)null);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_SingleOrgPolicyDisabled_ReturnsError(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg, false)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_UsersNotCompliantWithSingleOrg_ReturnsError(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
Guid nonCompliantUserId,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var orgUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Confirmed,
UserId = nonCompliantUserId,
Email = "user@example.com"
};
var otherOrgUser = new OrganizationUser
{
Id = Guid.NewGuid(),
OrganizationId = Guid.NewGuid(),
UserId = nonCompliantUserId,
Status = OrganizationUserStatusType.Confirmed
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([orgUser]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([otherOrgUser]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_UserWithInvitedStatusInOtherOrg_ValidationPasses(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
Guid userId,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var orgUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Confirmed,
UserId = userId,
Email = "test@email.com"
};
var otherOrgUser = new OrganizationUser
{
Id = Guid.NewGuid(),
OrganizationId = Guid.NewGuid(),
UserId = null, // invited users do not have a user id
Status = OrganizationUserStatusType.Invited,
Email = orgUser.Email
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([orgUser]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([otherOrgUser]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_ProviderUsersExist_ReturnsError(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var providerUser = new ProviderUser
{
Id = Guid.NewGuid(),
ProviderId = Guid.NewGuid(),
UserId = Guid.NewGuid(),
Status = ProviderUserStatusType.Confirmed
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([providerUser]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.Contains("Provider user type", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_AllValidationsPassed_ReturnsEmptyString(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var orgUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Confirmed,
UserId = Guid.NewGuid(),
Email = "user@example.com"
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([orgUser]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task ValidateAsync_PolicyAlreadyEnabled_ReturnsEmptyString(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, currentPolicy);
// Assert
Assert.True(string.IsNullOrEmpty(result));
await sutProvider.GetDependency<IPolicyRepository>()
.DidNotReceive()
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>());
}
[Theory, BitAutoData]
public async Task ValidateAsync_DisablingPolicy_ReturnsEmptyString(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation, false)] PolicyUpdate policyUpdate,
[Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, currentPolicy);
// Assert
Assert.True(string.IsNullOrEmpty(result));
await sutProvider.GetDependency<IPolicyRepository>()
.DidNotReceive()
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>());
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_IncludesOwnersAndAdmins_InComplianceCheck(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
Guid nonCompliantOwnerId,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var ownerUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.Owner,
Status = OrganizationUserStatusType.Confirmed,
UserId = nonCompliantOwnerId,
Email = "owner@example.com"
};
var otherOrgUser = new OrganizationUser
{
Id = Guid.NewGuid(),
OrganizationId = Guid.NewGuid(),
UserId = nonCompliantOwnerId,
Status = OrganizationUserStatusType.Confirmed
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([ownerUser]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([otherOrgUser]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_InvitedUsersExcluded_FromComplianceCheck(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var invitedUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Invited,
UserId = Guid.NewGuid(),
Email = "invited@example.com"
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([invitedUser]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_RevokedUsersExcluded_FromComplianceCheck(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var revokedUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Revoked,
UserId = Guid.NewGuid(),
Email = "revoked@example.com"
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([revokedUser]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_AcceptedUsersIncluded_InComplianceCheck(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
Guid nonCompliantUserId,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var acceptedUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Accepted,
UserId = nonCompliantUserId,
Email = "accepted@example.com"
};
var otherOrgUser = new OrganizationUser
{
Id = Guid.NewGuid(),
OrganizationId = Guid.NewGuid(),
UserId = nonCompliantUserId,
Status = OrganizationUserStatusType.Confirmed
};
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([acceptedUser]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([otherOrgUser]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_EmptyOrganization_ReturnsEmptyString(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task ValidateAsync_WithSavePolicyModel_CallsValidateWithPolicyUpdate(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
var savePolicyModel = new SavePolicyModel(policyUpdate);
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
.Returns(singleOrgPolicy);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([]);
// Act
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, null);
// Assert
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_EnablingPolicy_SetsUseAutomaticUserConfirmationToTrue(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
Organization organization,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
organization.Id = policyUpdate.OrganizationId;
organization.UseAutomaticUserConfirmation = false;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
// Act
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Organization>(o =>
o.Id == organization.Id &&
o.UseAutomaticUserConfirmation == true &&
o.RevisionDate > DateTime.MinValue));
}
[Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_DisablingPolicy_SetsUseAutomaticUserConfirmationToFalse(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation, false)] PolicyUpdate policyUpdate,
Organization organization,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
organization.Id = policyUpdate.OrganizationId;
organization.UseAutomaticUserConfirmation = true;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
// Act
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Organization>(o =>
o.Id == organization.Id &&
o.UseAutomaticUserConfirmation == false &&
o.RevisionDate > DateTime.MinValue));
}
[Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_OrganizationNotFound_DoesNotThrowException(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns((Organization?)null);
// Act
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.DidNotReceive()
.UpsertAsync(Arg.Any<Organization>());
}
[Theory, BitAutoData]
public async Task ExecutePreUpsertSideEffectAsync_CallsOnSaveSideEffectsAsync(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
[Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy,
Organization organization,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
organization.Id = policyUpdate.OrganizationId;
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
var savePolicyModel = new SavePolicyModel(policyUpdate);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
// Act
await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, currentPolicy);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Organization>(o =>
o.Id == organization.Id &&
o.UseAutomaticUserConfirmation == policyUpdate.Enabled));
}
[Theory, BitAutoData]
public async Task OnSaveSideEffectsAsync_UpdatesRevisionDate(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
Organization organization,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
organization.Id = policyUpdate.OrganizationId;
var originalRevisionDate = DateTime.UtcNow.AddDays(-1);
organization.RevisionDate = originalRevisionDate;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
// Act
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
// Assert
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Organization>(o =>
o.Id == organization.Id &&
o.RevisionDate > originalRevisionDate));
}
}

View File

@@ -38,6 +38,20 @@ public class EventIntegrationEventWriteServiceTests
organizationId: Arg.Is<string>(orgId => eventMessage.OrganizationId.ToString().Equals(orgId)));
}
[Fact]
public async Task CreateManyAsync_EmptyList_DoesNothing()
{
await Subject.CreateManyAsync([]);
await _eventIntegrationPublisher.DidNotReceiveWithAnyArgs().PublishEventAsync(Arg.Any<string>(), Arg.Any<string>());
}
[Fact]
public async Task DisposeAsync_DisposesEventIntegrationPublisher()
{
await Subject.DisposeAsync();
await _eventIntegrationPublisher.Received(1).DisposeAsync();
}
private static bool AssertJsonStringsMatch(EventMessage expected, string body)
{
var actual = JsonSerializer.Deserialize<EventMessage>(body);

View File

@@ -120,6 +120,16 @@ public class EventIntegrationHandlerTests
Assert.Empty(_eventIntegrationPublisher.ReceivedCalls());
}
[Theory, BitAutoData]
public async Task HandleEventAsync_NoOrganizationId_DoesNothing(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateBase));
eventMessage.OrganizationId = null;
await sutProvider.Sut.HandleEventAsync(eventMessage);
Assert.Empty(_eventIntegrationPublisher.ReceivedCalls());
}
[Theory, BitAutoData]
public async Task HandleEventAsync_BaseTemplateOneConfiguration_PublishesIntegrationMessage(EventMessage eventMessage)
{

View File

@@ -1,65 +0,0 @@
using Bit.Core.Models.Data;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Services;
[SutProviderCustomize]
public class EventRouteServiceTests
{
private readonly IEventWriteService _broadcastEventWriteService = Substitute.For<IEventWriteService>();
private readonly IEventWriteService _storageEventWriteService = Substitute.For<IEventWriteService>();
private readonly IFeatureService _featureService = Substitute.For<IFeatureService>();
private readonly EventRouteService Subject;
public EventRouteServiceTests()
{
Subject = new EventRouteService(_broadcastEventWriteService, _storageEventWriteService, _featureService);
}
[Theory, BitAutoData]
public async Task CreateAsync_FlagDisabled_EventSentToStorageService(EventMessage eventMessage)
{
_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(false);
await Subject.CreateAsync(eventMessage);
await _broadcastEventWriteService.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any<EventMessage>());
await _storageEventWriteService.Received(1).CreateAsync(eventMessage);
}
[Theory, BitAutoData]
public async Task CreateAsync_FlagEnabled_EventSentToBroadcastService(EventMessage eventMessage)
{
_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(true);
await Subject.CreateAsync(eventMessage);
await _broadcastEventWriteService.Received(1).CreateAsync(eventMessage);
await _storageEventWriteService.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any<EventMessage>());
}
[Theory, BitAutoData]
public async Task CreateManyAsync_FlagDisabled_EventsSentToStorageService(IEnumerable<EventMessage> eventMessages)
{
_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(false);
await Subject.CreateManyAsync(eventMessages);
await _broadcastEventWriteService.DidNotReceiveWithAnyArgs().CreateManyAsync(Arg.Any<IEnumerable<EventMessage>>());
await _storageEventWriteService.Received(1).CreateManyAsync(eventMessages);
}
[Theory, BitAutoData]
public async Task CreateManyAsync_FlagEnabled_EventsSentToBroadcastService(IEnumerable<EventMessage> eventMessages)
{
_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(true);
await Subject.CreateManyAsync(eventMessages);
await _broadcastEventWriteService.Received(1).CreateManyAsync(eventMessages);
await _storageEventWriteService.DidNotReceiveWithAnyArgs().CreateManyAsync(Arg.Any<IEnumerable<EventMessage>>());
}
}

View File

@@ -42,6 +42,35 @@ public class IntegrationFilterServiceTests
Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage));
}
[Theory, BitAutoData]
public void EvaluateFilterGroup_EqualsUserIdString_Matches(EventMessage eventMessage)
{
var userId = Guid.NewGuid();
eventMessage.UserId = userId;
var group = new IntegrationFilterGroup
{
AndOperator = true,
Rules =
[
new()
{
Property = "UserId",
Operation = IntegrationFilterOperation.Equals,
Value = userId.ToString()
}
]
};
var result = _service.EvaluateFilterGroup(group, eventMessage);
Assert.True(result);
var jsonGroup = JsonSerializer.Serialize(group);
var roundtrippedGroup = JsonSerializer.Deserialize<IntegrationFilterGroup>(jsonGroup);
Assert.NotNull(roundtrippedGroup);
Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage));
}
[Theory, BitAutoData]
public void EvaluateFilterGroup_EqualsUserId_DoesNotMatch(EventMessage eventMessage)
{
@@ -281,6 +310,45 @@ public class IntegrationFilterServiceTests
Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage));
}
[Theory, BitAutoData]
public void EvaluateFilterGroup_NestedGroups_AnyMatch(EventMessage eventMessage)
{
var id = Guid.NewGuid();
var collectionId = Guid.NewGuid();
eventMessage.UserId = id;
eventMessage.CollectionId = collectionId;
var nestedGroup = new IntegrationFilterGroup
{
AndOperator = false,
Rules =
[
new() { Property = "UserId", Operation = IntegrationFilterOperation.Equals, Value = id },
new()
{
Property = "CollectionId",
Operation = IntegrationFilterOperation.In,
Value = new Guid?[] { Guid.NewGuid() }
}
]
};
var topGroup = new IntegrationFilterGroup
{
AndOperator = false,
Groups = [nestedGroup]
};
var result = _service.EvaluateFilterGroup(topGroup, eventMessage);
Assert.True(result);
var jsonGroup = JsonSerializer.Serialize(topGroup);
var roundtrippedGroup = JsonSerializer.Deserialize<IntegrationFilterGroup>(jsonGroup);
Assert.NotNull(roundtrippedGroup);
Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage));
}
[Theory, BitAutoData]
public void EvaluateFilterGroup_UnknownProperty_ReturnsFalse(EventMessage eventMessage)
{

View File

@@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Models.Slack;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@@ -28,6 +29,9 @@ public class SlackIntegrationHandlerTests
var sutProvider = GetSutProvider();
message.Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token);
_slackService.SendSlackMessageByChannelIdAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(new SlackSendMessageResponse() { Ok = true, Channel = _channelId });
var result = await sutProvider.Sut.HandleAsync(message);
Assert.True(result.Success);
@@ -39,4 +43,97 @@ public class SlackIntegrationHandlerTests
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId))
);
}
[Theory]
[InlineData("service_unavailable")]
[InlineData("ratelimited")]
[InlineData("rate_limited")]
[InlineData("internal_error")]
[InlineData("message_limit_exceeded")]
public async Task HandleAsync_FailedRetryableRequest_ReturnsFailureWithRetryable(string error)
{
var sutProvider = GetSutProvider();
var message = new IntegrationMessage<SlackIntegrationConfigurationDetails>()
{
Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token),
MessageId = "MessageId",
RenderedTemplate = "Test Message"
};
_slackService.SendSlackMessageByChannelIdAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(new SlackSendMessageResponse() { Ok = false, Channel = _channelId, Error = error });
var result = await sutProvider.Sut.HandleAsync(message);
Assert.False(result.Success);
Assert.True(result.Retryable);
Assert.NotNull(result.FailureReason);
Assert.Equal(result.Message, message);
await sutProvider.GetDependency<ISlackService>().Received(1).SendSlackMessageByChannelIdAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_token)),
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId))
);
}
[Theory]
[InlineData("access_denied")]
[InlineData("channel_not_found")]
[InlineData("token_expired")]
[InlineData("token_revoked")]
public async Task HandleAsync_FailedNonRetryableRequest_ReturnsNonRetryableFailure(string error)
{
var sutProvider = GetSutProvider();
var message = new IntegrationMessage<SlackIntegrationConfigurationDetails>()
{
Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token),
MessageId = "MessageId",
RenderedTemplate = "Test Message"
};
_slackService.SendSlackMessageByChannelIdAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(new SlackSendMessageResponse() { Ok = false, Channel = _channelId, Error = error });
var result = await sutProvider.Sut.HandleAsync(message);
Assert.False(result.Success);
Assert.False(result.Retryable);
Assert.NotNull(result.FailureReason);
Assert.Equal(result.Message, message);
await sutProvider.GetDependency<ISlackService>().Received(1).SendSlackMessageByChannelIdAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_token)),
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId))
);
}
[Fact]
public async Task HandleAsync_NullResponse_ReturnsNonRetryableFailure()
{
var sutProvider = GetSutProvider();
var message = new IntegrationMessage<SlackIntegrationConfigurationDetails>()
{
Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token),
MessageId = "MessageId",
RenderedTemplate = "Test Message"
};
_slackService.SendSlackMessageByChannelIdAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns((SlackSendMessageResponse?)null);
var result = await sutProvider.Sut.HandleAsync(message);
Assert.False(result.Success);
Assert.False(result.Retryable);
Assert.Equal("Slack response was null", result.FailureReason);
Assert.Equal(result.Message, message);
await sutProvider.GetDependency<ISlackService>().Received(1).SendSlackMessageByChannelIdAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_token)),
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId))
);
}
}

View File

@@ -146,6 +146,27 @@ public class SlackServiceTests
Assert.Empty(result);
}
[Fact]
public async Task GetChannelIdAsync_NoChannelFound_ReturnsEmptyResult()
{
var emptyResponse = JsonSerializer.Serialize(
new
{
ok = true,
channels = Array.Empty<string>(),
response_metadata = new { next_cursor = "" }
});
_handler.When(HttpMethod.Get)
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(emptyResponse));
var sutProvider = GetSutProvider();
var result = await sutProvider.Sut.GetChannelIdAsync(_token, "general");
Assert.Empty(result);
}
[Fact]
public async Task GetChannelIdAsync_ReturnsCorrectChannelId()
{
@@ -235,6 +256,32 @@ public class SlackServiceTests
Assert.Equal(string.Empty, result);
}
[Fact]
public async Task GetDmChannelByEmailAsync_ApiErrorUnparsableDmResponse_ReturnsEmptyString()
{
var sutProvider = GetSutProvider();
var email = "user@example.com";
var userId = "U12345";
var userResponse = new
{
ok = true,
user = new { id = userId }
};
_handler.When($"https://slack.com/api/users.lookupByEmail?email={email}")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(JsonSerializer.Serialize(userResponse)));
_handler.When("https://slack.com/api/conversations.open")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent("NOT JSON"));
var result = await sutProvider.Sut.GetDmChannelByEmailAsync(_token, email);
Assert.Equal(string.Empty, result);
}
[Fact]
public async Task GetDmChannelByEmailAsync_ApiErrorUserResponse_ReturnsEmptyString()
{
@@ -244,7 +291,7 @@ public class SlackServiceTests
var userResponse = new
{
ok = false,
error = "An error occured"
error = "An error occurred"
};
_handler.When($"https://slack.com/api/users.lookupByEmail?email={email}")
@@ -256,6 +303,21 @@ public class SlackServiceTests
Assert.Equal(string.Empty, result);
}
[Fact]
public async Task GetDmChannelByEmailAsync_ApiErrorUnparsableUserResponse_ReturnsEmptyString()
{
var sutProvider = GetSutProvider();
var email = "user@example.com";
_handler.When($"https://slack.com/api/users.lookupByEmail?email={email}")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent("Not JSON"));
var result = await sutProvider.Sut.GetDmChannelByEmailAsync(_token, email);
Assert.Equal(string.Empty, result);
}
[Fact]
public void GetRedirectUrl_ReturnsCorrectUrl()
{
@@ -341,18 +403,29 @@ public class SlackServiceTests
}
[Fact]
public async Task SendSlackMessageByChannelId_Sends_Correct_Message()
public async Task SendSlackMessageByChannelId_Success_ReturnsSuccessfulResponse()
{
var sutProvider = GetSutProvider();
var channelId = "C12345";
var message = "Hello, Slack!";
var jsonResponse = JsonSerializer.Serialize(new
{
ok = true,
channel = channelId,
});
_handler.When(HttpMethod.Post)
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(string.Empty));
.WithContent(new StringContent(jsonResponse));
await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId);
var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId);
// Response was parsed correctly
Assert.NotNull(result);
Assert.True(result.Ok);
// Request was sent correctly
Assert.Single(_handler.CapturedRequests);
var request = _handler.CapturedRequests[0];
Assert.NotNull(request);
@@ -365,4 +438,62 @@ public class SlackServiceTests
Assert.Equal(message, json.RootElement.GetProperty("text").GetString() ?? string.Empty);
Assert.Equal(channelId, json.RootElement.GetProperty("channel").GetString() ?? string.Empty);
}
[Fact]
public async Task SendSlackMessageByChannelId_Failure_ReturnsErrorResponse()
{
var sutProvider = GetSutProvider();
var channelId = "C12345";
var message = "Hello, Slack!";
var jsonResponse = JsonSerializer.Serialize(new
{
ok = false,
channel = channelId,
error = "error"
});
_handler.When(HttpMethod.Post)
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(jsonResponse));
var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId);
// Response was parsed correctly
Assert.NotNull(result);
Assert.False(result.Ok);
Assert.NotNull(result.Error);
}
[Fact]
public async Task SendSlackMessageByChannelIdAsync_InvalidJson_ReturnsNull()
{
var sutProvider = GetSutProvider();
var channelId = "C12345";
var message = "Hello world!";
_handler.When(HttpMethod.Post)
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent("Not JSON"));
var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId);
Assert.Null(result);
}
[Fact]
public async Task SendSlackMessageByChannelIdAsync_HttpServerError_ReturnsNull()
{
var sutProvider = GetSutProvider();
var channelId = "C12345";
var message = "Hello world!";
_handler.When(HttpMethod.Post)
.RespondWith(HttpStatusCode.InternalServerError)
.WithContent(new StringContent(string.Empty));
var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId);
Assert.Null(result);
}
}

View File

@@ -7,6 +7,7 @@ using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.UserFeatures.Registration.Implementations;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
@@ -80,6 +81,120 @@ public class RegisterUserCommandTests
.SendWelcomeEmailAsync(Arg.Any<User>());
}
// -----------------------------------------------------------------------------------------------
// RegisterSSOAutoProvisionedUserAsync tests
// -----------------------------------------------------------------------------------------------
[Theory, BitAutoData]
public async Task RegisterSSOAutoProvisionedUserAsync_Success(
User user,
Organization organization,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
user.Id = Guid.NewGuid();
organization.Id = Guid.NewGuid();
organization.Name = "Test Organization";
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user)
.Returns(IdentityResult.Success);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
// Act
var result = await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization);
// Assert
Assert.True(result.Succeeded);
await sutProvider.GetDependency<IUserService>()
.Received(1)
.CreateUserAsync(user);
}
[Theory, BitAutoData]
public async Task RegisterSSOAutoProvisionedUserAsync_UserRegistrationFails_ReturnsFailedResult(
User user,
Organization organization,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
var expectedError = new IdentityError();
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user)
.Returns(IdentityResult.Failed(expectedError));
// Act
var result = await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization);
// Assert
Assert.False(result.Succeeded);
Assert.Contains(expectedError, result.Errors);
await sutProvider.GetDependency<IMailService>()
.DidNotReceive()
.SendOrganizationUserWelcomeEmailAsync(Arg.Any<User>(), Arg.Any<string>());
}
[Theory]
[BitAutoData(PlanType.EnterpriseAnnually)]
[BitAutoData(PlanType.EnterpriseMonthly)]
[BitAutoData(PlanType.TeamsAnnually)]
public async Task RegisterSSOAutoProvisionedUserAsync_EnterpriseOrg_SendsOrganizationWelcomeEmail(
PlanType planType,
User user,
Organization organization,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
organization.PlanType = planType;
organization.Name = "Enterprise Org";
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user)
.Returns(IdentityResult.Success);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns((OrganizationUser)null);
// Act
await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization);
// Assert
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendOrganizationUserWelcomeEmailAsync(user, organization.Name);
}
[Theory, BitAutoData]
public async Task RegisterSSOAutoProvisionedUserAsync_FeatureFlagDisabled_SendsLegacyWelcomeEmail(
User user,
Organization organization,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user)
.Returns(IdentityResult.Success);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(false);
// Act
await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization);
// Assert
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendWelcomeEmailAsync(user);
}
// -----------------------------------------------------------------------------------------------
// RegisterUserWithOrganizationInviteToken tests
// -----------------------------------------------------------------------------------------------
@@ -646,5 +761,186 @@ public class RegisterUserCommandTests
Assert.Equal("Open registration has been disabled by the system administrator.", result.Message);
}
// -----------------------------------------------------------------------------------------------
// SendWelcomeEmail tests
// -----------------------------------------------------------------------------------------------
[Theory]
[BitAutoData(PlanType.FamiliesAnnually)]
[BitAutoData(PlanType.FamiliesAnnually2019)]
[BitAutoData(PlanType.Free)]
public async Task SendWelcomeEmail_FamilyOrg_SendsFamilyWelcomeEmail(
PlanType planType,
User user,
Organization organization,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
organization.PlanType = planType;
organization.Name = "Family Org";
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user)
.Returns(IdentityResult.Success);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns((OrganizationUser)null);
// Act
await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization);
// Assert
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, organization.Name);
}
[Theory]
[BitAutoData]
public async Task SendWelcomeEmail_OrganizationNull_SendsIndividualWelcomeEmail(
User user,
OrganizationUser orgUser,
string orgInviteToken,
string masterPasswordHash,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
user.ReferenceData = null;
orgUser.Email = user.Email;
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user, masterPasswordHash)
.Returns(IdentityResult.Success);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(orgUser.Id)
.Returns(orgUser);
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), PolicyType.TwoFactorAuthentication)
.Returns((Policy)null);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(orgUser.OrganizationId)
.Returns((Organization)null);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
var orgInviteTokenable = new OrgUserInviteTokenable(orgUser);
sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>()
.TryUnprotect(orgInviteToken, out Arg.Any<OrgUserInviteTokenable>())
.Returns(callInfo =>
{
callInfo[1] = orgInviteTokenable;
return true;
});
// Act
var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUser.Id);
// Assert
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendIndividualUserWelcomeEmailAsync(user);
}
[Theory]
[BitAutoData]
public async Task SendWelcomeEmail_OrganizationDisplayNameNull_SendsIndividualWelcomeEmail(
User user,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
Organization organization = new Organization
{
Name = null
};
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user)
.Returns(IdentityResult.Success);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns((OrganizationUser)null);
// Act
await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization);
// Assert
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendIndividualUserWelcomeEmailAsync(user);
}
[Theory]
[BitAutoData]
public async Task GetOrganizationWelcomeEmailDetailsAsync_HappyPath_ReturnsOrganizationWelcomeEmailDetails(
Organization organization,
User user,
OrganizationUser orgUser,
string masterPasswordHash,
string orgInviteToken,
SutProvider<RegisterUserCommand> sutProvider)
{
// Arrange
user.ReferenceData = null;
orgUser.Email = user.Email;
organization.PlanType = PlanType.EnterpriseAnnually;
sutProvider.GetDependency<IUserService>()
.CreateUserAsync(user, masterPasswordHash)
.Returns(IdentityResult.Success);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByIdAsync(orgUser.Id)
.Returns(orgUser);
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), PolicyType.TwoFactorAuthentication)
.Returns((Policy)null);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(orgUser.OrganizationId)
.Returns(organization);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
var orgInviteTokenable = new OrgUserInviteTokenable(orgUser);
sutProvider.GetDependency<IDataProtectorTokenFactory<OrgUserInviteTokenable>>()
.TryUnprotect(orgInviteToken, out Arg.Any<OrgUserInviteTokenable>())
.Returns(callInfo =>
{
callInfo[1] = orgInviteTokenable;
return true;
});
// Act
var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUser.Id);
// Assert
Assert.True(result.Succeeded);
await sutProvider.GetDependency<IOrganizationRepository>()
.Received(1)
.GetByIdAsync(orgUser.OrganizationId);
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendOrganizationUserWelcomeEmailAsync(user, organization.DisplayName());
}
}

View File

@@ -21,15 +21,6 @@ namespace Bit.Core.Test.Billing.Organizations.Queries;
[SutProviderCustomize]
public class GetOrganizationMetadataQueryTests
{
[Theory, BitAutoData]
public async Task Run_NullOrganization_ReturnsNull(
SutProvider<GetOrganizationMetadataQuery> sutProvider)
{
var result = await sutProvider.Sut.Run(null);
Assert.Null(result);
}
[Theory, BitAutoData]
public async Task Run_SelfHosted_ReturnsDefault(
Organization organization,
@@ -74,8 +65,7 @@ public class GetOrganizationMetadataQueryTests
.Returns(new OrganizationSeatCounts { Users = 5, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.GetCustomer(organization)
.ReturnsNull();
var result = await sutProvider.Sut.Run(organization);
@@ -100,12 +90,12 @@ public class GetOrganizationMetadataQueryTests
.Returns(new OrganizationSeatCounts { Users = 7, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.GetCustomer(organization)
.Returns(customer);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization)
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.Contains("discounts.coupon.applies_to")))
.ReturnsNull();
var result = await sutProvider.Sut.Run(organization);
@@ -124,23 +114,24 @@ public class GetOrganizationMetadataQueryTests
organization.PlanType = PlanType.EnterpriseAnnually;
var productId = "product_123";
var customer = new Customer
{
Discount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
AppliesTo = new CouponAppliesTo
{
Products = [productId]
}
}
}
};
var customer = new Customer();
var subscription = new Subscription
{
Discounts =
[
new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
AppliesTo = new CouponAppliesTo
{
Products = [productId]
}
}
}
],
Items = new StripeList<SubscriptionItem>
{
Data =
@@ -162,12 +153,12 @@ public class GetOrganizationMetadataQueryTests
.Returns(new OrganizationSeatCounts { Users = 15, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.GetCustomer(organization)
.Returns(customer);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization)
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.Contains("discounts.coupon.applies_to")))
.Returns(subscription);
sutProvider.GetDependency<IPricingClient>()
@@ -189,13 +180,11 @@ public class GetOrganizationMetadataQueryTests
organization.GatewaySubscriptionId = "sub_123";
organization.PlanType = PlanType.TeamsAnnually;
var customer = new Customer
{
Discount = null
};
var customer = new Customer();
var subscription = new Subscription
{
Discounts = null,
Items = new StripeList<SubscriptionItem>
{
Data =
@@ -217,12 +206,12 @@ public class GetOrganizationMetadataQueryTests
.Returns(new OrganizationSeatCounts { Users = 20, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.GetCustomer(organization)
.Returns(customer);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization)
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.Contains("discounts.coupon.applies_to")))
.Returns(subscription);
sutProvider.GetDependency<IPricingClient>()
@@ -244,23 +233,24 @@ public class GetOrganizationMetadataQueryTests
organization.GatewaySubscriptionId = "sub_123";
organization.PlanType = PlanType.EnterpriseAnnually;
var customer = new Customer
{
Discount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
AppliesTo = new CouponAppliesTo
{
Products = ["different_product_id"]
}
}
}
};
var customer = new Customer();
var subscription = new Subscription
{
Discounts =
[
new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
AppliesTo = new CouponAppliesTo
{
Products = ["different_product_id"]
}
}
}
],
Items = new StripeList<SubscriptionItem>
{
Data =
@@ -282,12 +272,12 @@ public class GetOrganizationMetadataQueryTests
.Returns(new OrganizationSeatCounts { Users = 12, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.GetCustomer(organization)
.Returns(customer);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization)
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.Contains("discounts.coupon.applies_to")))
.Returns(subscription);
sutProvider.GetDependency<IPricingClient>()
@@ -310,23 +300,24 @@ public class GetOrganizationMetadataQueryTests
organization.PlanType = PlanType.FamiliesAnnually;
var productId = "product_123";
var customer = new Customer
{
Discount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
AppliesTo = new CouponAppliesTo
{
Products = [productId]
}
}
}
};
var customer = new Customer();
var subscription = new Subscription
{
Discounts =
[
new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
AppliesTo = new CouponAppliesTo
{
Products = [productId]
}
}
}
],
Items = new StripeList<SubscriptionItem>
{
Data =
@@ -348,12 +339,12 @@ public class GetOrganizationMetadataQueryTests
.Returns(new OrganizationSeatCounts { Users = 8, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.GetCustomer(organization)
.Returns(customer);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization)
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.Contains("discounts.coupon.applies_to")))
.Returns(subscription);
sutProvider.GetDependency<IPricingClient>()

View File

@@ -53,7 +53,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
Available = true,
LegacyYear = null,
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = StripeConstants.Prices.PremiumAnnually },
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal }
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal, Provided = 1 }
};
_pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan);
@@ -720,4 +720,63 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
await _stripeAdapter.DidNotReceive().SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
await _userService.DidNotReceive().SaveUserAsync(Arg.Any<User>());
}
[Theory, BitAutoData]
public async Task Run_WithAdditionalStorage_SetsCorrectMaxStorageGb(
User user,
TokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = false;
user.GatewayCustomerId = null;
user.Email = "test@example.com";
paymentMethod.Type = TokenizablePaymentMethodType.Card;
paymentMethod.Token = "card_token_123";
billingAddress.Country = "US";
billingAddress.PostalCode = "12345";
const short additionalStorage = 2;
// Setup premium plan with 5GB provided storage
var premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = StripeConstants.Prices.PremiumAnnually },
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal, Provided = 1 }
};
_pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan);
var mockCustomer = Substitute.For<StripeCustomer>();
mockCustomer.Id = "cust_123";
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
mockCustomer.Metadata = new Dictionary<string, string>();
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "active";
mockSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
_stripeAdapter.CustomerCreateAsync(Arg.Any<CustomerCreateOptions>()).Returns(mockCustomer);
_stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(mockSubscription);
// Act
var result = await _command.Run(user, paymentMethod, billingAddress, additionalStorage);
// Assert
Assert.True(result.IsT0);
Assert.Equal((short)3, user.MaxStorageGb); // 1 (provided) + 2 (additional) = 3
await _userService.Received(1).SaveUserAsync(user);
}
}

View File

@@ -0,0 +1,527 @@
using System.Net;
using Bit.Core.Billing;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Logging;
using NSubstitute;
using RichardSzalay.MockHttp;
using Xunit;
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
namespace Bit.Core.Test.Billing.Pricing;
[SutProviderCustomize]
public class PricingClientTests
{
#region GetLookupKey Tests (via GetPlan)
[Fact]
public async Task GetPlan_WithFamiliesAnnually2025AndFeatureFlagEnabled_UsesFamilies2025LookupKey()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
var planJson = CreatePlanJson("families-2025", "Families 2025", "families", 40M, "price_id");
mockHttp.Expect(HttpMethod.Get, "https://test.com/plans/organization/families-2025")
.Respond("application/json", planJson);
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
.Respond("application/json", planJson);
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
var globalSettings = new GlobalSettings { SelfHosted = false };
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://test.com/")
};
var logger = Substitute.For<ILogger<PricingClient>>();
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
// Act
var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025);
// Assert
Assert.NotNull(result);
Assert.Equal(PlanType.FamiliesAnnually2025, result.Type);
mockHttp.VerifyNoOutstandingExpectation();
}
[Fact]
public async Task GetPlan_WithFamiliesAnnually2025AndFeatureFlagDisabled_UsesFamiliesLookupKey()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
var planJson = CreatePlanJson("families", "Families", "families", 40M, "price_id");
mockHttp.Expect(HttpMethod.Get, "https://test.com/plans/organization/families")
.Respond("application/json", planJson);
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
.Respond("application/json", planJson);
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false);
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
var globalSettings = new GlobalSettings { SelfHosted = false };
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://test.com/")
};
var logger = Substitute.For<ILogger<PricingClient>>();
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
// Act
var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025);
// Assert
Assert.NotNull(result);
// PreProcessFamiliesPreMigrationPlan should change "families" to "families-2025" when FF is disabled
Assert.Equal(PlanType.FamiliesAnnually2025, result.Type);
mockHttp.VerifyNoOutstandingExpectation();
}
#endregion
#region PreProcessFamiliesPreMigrationPlan Tests (via GetPlan)
[Fact]
public async Task GetPlan_WithFamiliesAnnually2025AndFeatureFlagDisabled_ReturnsFamiliesAnnually2025PlanType()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
// billing-pricing returns "families" lookup key because the flag is off
var planJson = CreatePlanJson("families", "Families", "families", 40M, "price_id");
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
.Respond("application/json", planJson);
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false);
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
var globalSettings = new GlobalSettings { SelfHosted = false };
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://test.com/")
};
var logger = Substitute.For<ILogger<PricingClient>>();
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
// Act
var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025);
// Assert
Assert.NotNull(result);
// PreProcessFamiliesPreMigrationPlan should convert the families lookup key to families-2025
// and the PlanAdapter should assign the correct FamiliesAnnually2025 plan type
Assert.Equal(PlanType.FamiliesAnnually2025, result.Type);
mockHttp.VerifyNoOutstandingExpectation();
}
[Fact]
public async Task GetPlan_WithFamiliesAnnually2025AndFeatureFlagEnabled_ReturnsFamiliesAnnually2025PlanType()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
var planJson = CreatePlanJson("families-2025", "Families", "families", 40M, "price_id");
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
.Respond("application/json", planJson);
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
var globalSettings = new GlobalSettings { SelfHosted = false };
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://test.com/")
};
var logger = Substitute.For<ILogger<PricingClient>>();
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
// Act
var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025);
// Assert
Assert.NotNull(result);
// PreProcessFamiliesPreMigrationPlan should ignore the lookup key because the flag is on
// and the PlanAdapter should assign the correct FamiliesAnnually2025 plan type
Assert.Equal(PlanType.FamiliesAnnually2025, result.Type);
mockHttp.VerifyNoOutstandingExpectation();
}
[Fact]
public async Task GetPlan_WithFamiliesAnnuallyAndFeatureFlagEnabled_ReturnsFamiliesAnnuallyPlanType()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
var planJson = CreatePlanJson("families", "Families", "families", 40M, "price_id");
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
.Respond("application/json", planJson);
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
var globalSettings = new GlobalSettings { SelfHosted = false };
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://test.com/")
};
var logger = Substitute.For<ILogger<PricingClient>>();
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
// Act
var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually);
// Assert
Assert.NotNull(result);
// PreProcessFamiliesPreMigrationPlan should ignore the lookup key because the flag is on
// and the PlanAdapter should assign the correct FamiliesAnnually plan type
Assert.Equal(PlanType.FamiliesAnnually, result.Type);
mockHttp.VerifyNoOutstandingExpectation();
}
[Fact]
public async Task GetPlan_WithOtherLookupKey_KeepsLookupKeyUnchanged()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
var planJson = CreatePlanJson("enterprise-annually", "Enterprise", "enterprise", 144M, "price_id");
mockHttp.Expect(HttpMethod.Get, "https://test.com/plans/organization/enterprise-annually")
.Respond("application/json", planJson);
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
.Respond("application/json", planJson);
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false);
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
var globalSettings = new GlobalSettings { SelfHosted = false };
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://test.com/")
};
var logger = Substitute.For<ILogger<PricingClient>>();
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
// Act
var result = await pricingClient.GetPlan(PlanType.EnterpriseAnnually);
// Assert
Assert.NotNull(result);
Assert.Equal(PlanType.EnterpriseAnnually, result.Type);
mockHttp.VerifyNoOutstandingExpectation();
}
#endregion
#region ListPlans Tests
[Fact]
public async Task ListPlans_WithFeatureFlagDisabled_ReturnsListWithPreProcessing()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
// biling-pricing would return "families" because the flag is disabled
var plansJson = $@"[
{CreatePlanJson("families", "Families", "families", 40M, "price_id")},
{CreatePlanJson("enterprise-annually", "Enterprise", "enterprise", 144M, "price_id")}
]";
mockHttp.When(HttpMethod.Get, "*/plans/organization")
.Respond("application/json", plansJson);
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false);
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
var globalSettings = new GlobalSettings { SelfHosted = false };
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://test.com/")
};
var logger = Substitute.For<ILogger<PricingClient>>();
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
// Act
var result = await pricingClient.ListPlans();
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Count);
// First plan should have been preprocessed from "families" to "families-2025"
Assert.Equal(PlanType.FamiliesAnnually2025, result[0].Type);
// Second plan should remain unchanged
Assert.Equal(PlanType.EnterpriseAnnually, result[1].Type);
mockHttp.VerifyNoOutstandingExpectation();
}
[Fact]
public async Task ListPlans_WithFeatureFlagEnabled_ReturnsListWithoutPreProcessing()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
var plansJson = $@"[
{CreatePlanJson("families", "Families", "families", 40M, "price_id")}
]";
mockHttp.When(HttpMethod.Get, "*/plans/organization")
.Respond("application/json", plansJson);
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
var globalSettings = new GlobalSettings { SelfHosted = false };
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://test.com/")
};
var logger = Substitute.For<ILogger<PricingClient>>();
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
// Act
var result = await pricingClient.ListPlans();
// Assert
Assert.NotNull(result);
Assert.Single(result);
// Plan should remain as FamiliesAnnually when FF is enabled
Assert.Equal(PlanType.FamiliesAnnually, result[0].Type);
mockHttp.VerifyNoOutstandingExpectation();
}
#endregion
#region GetPlan - Additional Coverage
[Theory, BitAutoData]
public async Task GetPlan_WhenSelfHosted_ReturnsNull(
SutProvider<PricingClient> sutProvider)
{
// Arrange
var globalSettings = sutProvider.GetDependency<GlobalSettings>();
globalSettings.SelfHosted = true;
// Act
var result = await sutProvider.Sut.GetPlan(PlanType.FamiliesAnnually2025);
// Assert
Assert.Null(result);
}
[Theory, BitAutoData]
public async Task GetPlan_WhenPricingServiceDisabled_ReturnsStaticStorePlan(
SutProvider<PricingClient> sutProvider)
{
// Arrange
sutProvider.GetDependency<GlobalSettings>().SelfHosted = false;
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.UsePricingService)
.Returns(false);
// Act
var result = await sutProvider.Sut.GetPlan(PlanType.FamiliesAnnually);
// Assert
Assert.NotNull(result);
Assert.Equal(PlanType.FamiliesAnnually, result.Type);
}
[Theory, BitAutoData]
public async Task GetPlan_WhenLookupKeyNotFound_ReturnsNull(
SutProvider<PricingClient> sutProvider)
{
// Arrange
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.UsePricingService)
.Returns(true);
// Act - Using PlanType that doesn't have a lookup key mapping
var result = await sutProvider.Sut.GetPlan(unchecked((PlanType)999));
// Assert
Assert.Null(result);
}
[Fact]
public async Task GetPlan_WhenPricingServiceReturnsNotFound_ReturnsNull()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
.Respond(HttpStatusCode.NotFound);
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
var globalSettings = new GlobalSettings { SelfHosted = false };
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://test.com/")
};
var logger = Substitute.For<ILogger<PricingClient>>();
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
// Act
var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025);
// Assert
Assert.Null(result);
}
[Fact]
public async Task GetPlan_WhenPricingServiceReturnsError_ThrowsBillingException()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
mockHttp.When(HttpMethod.Get, "*/plans/organization/*")
.Respond(HttpStatusCode.InternalServerError);
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
var globalSettings = new GlobalSettings { SelfHosted = false };
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://test.com/")
};
var logger = Substitute.For<ILogger<PricingClient>>();
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
// Act & Assert
await Assert.ThrowsAsync<BillingException>(() =>
pricingClient.GetPlan(PlanType.FamiliesAnnually2025));
}
#endregion
#region ListPlans - Additional Coverage
[Theory, BitAutoData]
public async Task ListPlans_WhenSelfHosted_ReturnsEmptyList(
SutProvider<PricingClient> sutProvider)
{
// Arrange
var globalSettings = sutProvider.GetDependency<GlobalSettings>();
globalSettings.SelfHosted = true;
// Act
var result = await sutProvider.Sut.ListPlans();
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Theory, BitAutoData]
public async Task ListPlans_WhenPricingServiceDisabled_ReturnsStaticStorePlans(
SutProvider<PricingClient> sutProvider)
{
// Arrange
sutProvider.GetDependency<GlobalSettings>().SelfHosted = false;
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.UsePricingService)
.Returns(false);
// Act
var result = await sutProvider.Sut.ListPlans();
// Assert
Assert.NotNull(result);
Assert.NotEmpty(result);
Assert.Equal(StaticStore.Plans.Count(), result.Count);
}
[Fact]
public async Task ListPlans_WhenPricingServiceReturnsError_ThrowsBillingException()
{
// Arrange
var mockHttp = new MockHttpMessageHandler();
mockHttp.When(HttpMethod.Get, "*/plans/organization")
.Respond(HttpStatusCode.InternalServerError);
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true);
var globalSettings = new GlobalSettings { SelfHosted = false };
var httpClient = new HttpClient(mockHttp)
{
BaseAddress = new Uri("https://test.com/")
};
var logger = Substitute.For<ILogger<PricingClient>>();
var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger);
// Act & Assert
await Assert.ThrowsAsync<BillingException>(() =>
pricingClient.ListPlans());
}
#endregion
private static string CreatePlanJson(
string lookupKey,
string name,
string tier,
decimal seatsPrice,
string seatsStripePriceId,
int seatsQuantity = 1)
{
return $@"{{
""lookupKey"": ""{lookupKey}"",
""name"": ""{name}"",
""tier"": ""{tier}"",
""features"": [],
""seats"": {{
""type"": ""packaged"",
""quantity"": {seatsQuantity},
""price"": {seatsPrice},
""stripePriceId"": ""{seatsStripePriceId}""
}},
""canUpgradeTo"": [],
""additionalData"": {{
""nameLocalizationKey"": ""{lookupKey}Name"",
""descriptionLocalizationKey"": ""{lookupKey}Description""
}}
}}";
}
}

View File

@@ -38,31 +38,32 @@ public class OrganizationBillingServiceTests
var subscriberService = sutProvider.GetDependency<ISubscriberService>();
var organizationSeatCount = new OrganizationSeatCounts { Users = 1, Sponsored = 0 };
var customer = new Customer
{
Discount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
AppliesTo = new CouponAppliesTo
{
Products = ["product_id"]
}
}
}
};
var customer = new Customer();
subscriberService
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.GetCustomer(organization)
.Returns(customer);
subscriberService.GetSubscription(organization).Returns(new Subscription
{
Items = new StripeList<SubscriptionItem>
subscriberService.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.Contains("discounts.coupon.applies_to"))).Returns(new Subscription
{
Data =
Discounts =
[
new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
AppliesTo = new CouponAppliesTo
{
Products = ["product_id"]
}
}
}
],
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
@@ -72,8 +73,8 @@ public class OrganizationBillingServiceTests
}
}
]
}
});
}
});
sutProvider.GetDependency<IOrganizationRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
@@ -109,11 +110,12 @@ public class OrganizationBillingServiceTests
// Set up subscriber service to return null for customer
subscriberService
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options => options.Expand.FirstOrDefault() == "discount.coupon.applies_to"))
.GetCustomer(organization)
.Returns((Customer)null);
// Set up subscriber service to return null for subscription
subscriberService.GetSubscription(organization).Returns((Subscription)null);
subscriberService.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
options.Expand.Contains("discounts.coupon.applies_to"))).Returns((Subscription)null);
var metadata = await sutProvider.Sut.GetMetadata(organizationId);

View File

@@ -0,0 +1,497 @@
using Bit.Core.Models.Business;
using Bit.Test.Common.AutoFixture.Attributes;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Models.Business;
public class BillingCustomerDiscountTests
{
[Theory]
[BitAutoData]
public void Constructor_PercentageDiscount_SetsIdActivePercentOffAndAppliesTo(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 25.5m,
AmountOff = null,
AppliesTo = new CouponAppliesTo
{
Products = new List<string> { "product1", "product2" }
}
},
End = null // Active discount
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(couponId, result.Id);
Assert.True(result.Active);
Assert.Equal(25.5m, result.PercentOff);
Assert.Null(result.AmountOff);
Assert.NotNull(result.AppliesTo);
Assert.Equal(2, result.AppliesTo.Count);
Assert.Contains("product1", result.AppliesTo);
Assert.Contains("product2", result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_AmountDiscount_ConvertsFromCentsToDollars(string couponId)
{
// Arrange - Stripe sends 1400 cents for $14.00
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = null,
AmountOff = 1400, // 1400 cents
AppliesTo = new CouponAppliesTo
{
Products = new List<string>()
}
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(couponId, result.Id);
Assert.True(result.Active);
Assert.Null(result.PercentOff);
Assert.Equal(14.00m, result.AmountOff); // Converted to dollars
Assert.NotNull(result.AppliesTo);
Assert.Empty(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_InactiveDiscount_SetsActiveToFalse(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 15m
},
End = DateTime.UtcNow.AddDays(-1) // Expired discount
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(couponId, result.Id);
Assert.False(result.Active);
Assert.Equal(15m, result.PercentOff);
}
[Fact]
public void Constructor_NullCoupon_SetsDiscountPropertiesToNull()
{
// Arrange
var discount = new Discount
{
Coupon = null,
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.Id);
Assert.True(result.Active);
Assert.Null(result.PercentOff);
Assert.Null(result.AmountOff);
Assert.Null(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_NullAmountOff_SetsAmountOffToNull(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 10m,
AmountOff = null
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_ZeroAmountOff_ConvertsCorrectly(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
AmountOff = 0
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(0m, result.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_LargeAmountOff_ConvertsCorrectly(string couponId)
{
// Arrange - $100.00 discount
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
AmountOff = 10000 // 10000 cents = $100.00
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(100.00m, result.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_SmallAmountOff_ConvertsCorrectly(string couponId)
{
// Arrange - $0.50 discount
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
AmountOff = 50 // 50 cents = $0.50
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(0.50m, result.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_BothDiscountTypes_SetsPercentOffAndAmountOff(string couponId)
{
// Arrange - Coupon with both percentage and amount (edge case)
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 20m,
AmountOff = 500 // $5.00
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(20m, result.PercentOff);
Assert.Equal(5.00m, result.AmountOff);
}
[Theory]
[BitAutoData]
public void Constructor_WithNullAppliesTo_SetsAppliesToNull(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 10m,
AppliesTo = null
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_WithNullProductsList_SetsAppliesToNull(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 10m,
AppliesTo = new CouponAppliesTo
{
Products = null
}
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_WithDecimalAmountOff_RoundsCorrectly(string couponId)
{
// Arrange - 1425 cents = $14.25
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
AmountOff = 1425
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Equal(14.25m, result.AmountOff);
}
[Fact]
public void Constructor_DefaultConstructor_InitializesAllPropertiesToNullOrFalse()
{
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount();
// Assert
Assert.Null(result.Id);
Assert.False(result.Active);
Assert.Null(result.PercentOff);
Assert.Null(result.AmountOff);
Assert.Null(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_WithFutureEndDate_SetsActiveToFalse(string couponId)
{
// Arrange - Discount expires in the future
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 20m
},
End = DateTime.UtcNow.AddDays(30) // Expires in 30 days
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.False(result.Active); // Should be inactive because End is not null
}
[Theory]
[BitAutoData]
public void Constructor_WithPastEndDate_SetsActiveToFalse(string couponId)
{
// Arrange - Discount already expired
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 20m
},
End = DateTime.UtcNow.AddDays(-30) // Expired 30 days ago
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.False(result.Active); // Should be inactive because End is not null
}
[Fact]
public void Constructor_WithNullCouponId_SetsIdToNull()
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = null,
PercentOff = 20m
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.Id);
Assert.True(result.Active);
Assert.Equal(20m, result.PercentOff);
}
[Theory]
[BitAutoData]
public void Constructor_WithNullPercentOff_SetsPercentOffToNull(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = null,
AmountOff = 1000
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.Null(result.PercentOff);
Assert.Equal(10.00m, result.AmountOff);
}
[Fact]
public void Constructor_WithCompleteStripeDiscount_MapsAllProperties()
{
// Arrange - Comprehensive test with all Stripe Discount properties set
var discount = new Discount
{
Coupon = new Coupon
{
Id = "premium_discount_2024",
PercentOff = 25m,
AmountOff = 1500, // $15.00
AppliesTo = new CouponAppliesTo
{
Products = new List<string> { "prod_premium", "prod_family", "prod_teams" }
}
},
End = null // Active
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert - Verify all properties mapped correctly
Assert.Equal("premium_discount_2024", result.Id);
Assert.True(result.Active);
Assert.Equal(25m, result.PercentOff);
Assert.Equal(15.00m, result.AmountOff);
Assert.NotNull(result.AppliesTo);
Assert.Equal(3, result.AppliesTo.Count);
Assert.Contains("prod_premium", result.AppliesTo);
Assert.Contains("prod_family", result.AppliesTo);
Assert.Contains("prod_teams", result.AppliesTo);
}
[Fact]
public void Constructor_WithMinimalStripeDiscount_HandlesNullsGracefully()
{
// Arrange - Minimal Stripe Discount with most properties null
var discount = new Discount
{
Coupon = new Coupon
{
Id = null,
PercentOff = null,
AmountOff = null,
AppliesTo = null
},
End = DateTime.UtcNow.AddDays(10) // Has end date
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert - Should handle all nulls gracefully
Assert.Null(result.Id);
Assert.False(result.Active);
Assert.Null(result.PercentOff);
Assert.Null(result.AmountOff);
Assert.Null(result.AppliesTo);
}
[Theory]
[BitAutoData]
public void Constructor_WithEmptyProductsList_PreservesEmptyList(string couponId)
{
// Arrange
var discount = new Discount
{
Coupon = new Coupon
{
Id = couponId,
PercentOff = 10m,
AppliesTo = new CouponAppliesTo
{
Products = new List<string>() // Empty but not null
}
},
End = null
};
// Act
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
// Assert
Assert.NotNull(result.AppliesTo);
Assert.Empty(result.AppliesTo);
}
}

View File

@@ -22,7 +22,7 @@ public class SecretsManagerSubscriptionUpdateTests
}
public static TheoryData<Plan> NonSmPlans =>
ToPlanTheory([PlanType.Custom, PlanType.FamiliesAnnually, PlanType.FamiliesAnnually2019]);
ToPlanTheory([PlanType.Custom, PlanType.FamiliesAnnually, PlanType.FamiliesAnnually2025, PlanType.FamiliesAnnually2019]);
public static TheoryData<Plan> SmPlans => ToPlanTheory([
PlanType.EnterpriseAnnually2019,

View File

@@ -0,0 +1,125 @@
using Bit.Core.Models.Business;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Models.Business;
public class SubscriptionInfoTests
{
[Fact]
public void BillingSubscriptionItem_NullPlan_HandlesGracefully()
{
// Arrange - SubscriptionItem with null Plan
var subscriptionItem = new SubscriptionItem
{
Plan = null,
Quantity = 1
};
// Act
var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem);
// Assert - Should handle null Plan gracefully
Assert.Null(result.ProductId);
Assert.Null(result.Name);
Assert.Equal(0m, result.Amount); // Defaults to 0 when Plan is null
Assert.Null(result.Interval);
Assert.Equal(1, result.Quantity);
Assert.False(result.SponsoredSubscriptionItem);
Assert.False(result.AddonSubscriptionItem);
}
[Fact]
public void BillingSubscriptionItem_NullAmount_SetsToZero()
{
// Arrange - SubscriptionItem with Plan but null Amount
var subscriptionItem = new SubscriptionItem
{
Plan = new Plan
{
ProductId = "prod_test",
Nickname = "Test Plan",
Amount = null, // Null amount
Interval = "month"
},
Quantity = 1
};
// Act
var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem);
// Assert - Should default to 0 when Amount is null
Assert.Equal("prod_test", result.ProductId);
Assert.Equal("Test Plan", result.Name);
Assert.Equal(0m, result.Amount); // Business rule: defaults to 0 when null
Assert.Equal("month", result.Interval);
Assert.Equal(1, result.Quantity);
}
[Fact]
public void BillingSubscriptionItem_ZeroAmount_PreservesZero()
{
// Arrange - SubscriptionItem with Plan and zero Amount
var subscriptionItem = new SubscriptionItem
{
Plan = new Plan
{
ProductId = "prod_test",
Nickname = "Test Plan",
Amount = 0, // Zero amount (0 cents)
Interval = "month"
},
Quantity = 1
};
// Act
var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem);
// Assert - Should preserve zero amount
Assert.Equal("prod_test", result.ProductId);
Assert.Equal("Test Plan", result.Name);
Assert.Equal(0m, result.Amount); // Zero amount preserved
Assert.Equal("month", result.Interval);
}
[Fact]
public void BillingUpcomingInvoice_ZeroAmountDue_ConvertsToZero()
{
// Arrange - Invoice with zero AmountDue
// Note: Stripe's Invoice.AmountDue is non-nullable long, so we test with 0
// The null-coalescing operator (?? 0) in the constructor handles the case where
// ConvertFromStripeMinorUnits returns null, but since AmountDue is non-nullable,
// this test verifies the conversion path works correctly for zero values
var invoice = new Invoice
{
AmountDue = 0, // Zero amount due (0 cents)
Created = DateTime.UtcNow
};
// Act
var result = new SubscriptionInfo.BillingUpcomingInvoice(invoice);
// Assert - Should convert zero correctly
Assert.Equal(0m, result.Amount);
Assert.NotNull(result.Date);
}
[Fact]
public void BillingUpcomingInvoice_ValidAmountDue_ConvertsCorrectly()
{
// Arrange - Invoice with valid AmountDue
var invoice = new Invoice
{
AmountDue = 2500, // 2500 cents = $25.00
Created = DateTime.UtcNow
};
// Act
var result = new SubscriptionInfo.BillingUpcomingInvoice(invoice);
// Assert - Should convert correctly
Assert.Equal(25.00m, result.Amount); // Converted from cents
Assert.NotNull(result.Date);
}
}

View File

@@ -1,5 +1,8 @@
using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Settings;
using Bit.Core.Test.Platform.Mailer.TestMail;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Platform.Mailer;
@@ -9,7 +12,10 @@ public class HandlebarMailRendererTests
[Fact]
public async Task RenderAsync_ReturnsExpectedHtmlAndTxt()
{
var renderer = new HandlebarMailRenderer();
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
var globalSettings = new GlobalSettings { SelfHosted = false };
var renderer = new HandlebarMailRenderer(logger, globalSettings);
var view = new TestMailView { Name = "John Smith" };
var (html, txt) = await renderer.RenderAsync(view);
@@ -17,4 +23,150 @@ public class HandlebarMailRendererTests
Assert.Equal("Hello <b>John Smith</b>", html.Trim());
Assert.Equal("Hello John Smith", txt.Trim());
}
[Fact]
public async Task RenderAsync_LoadsFromDisk_WhenSelfHostedAndFileExists()
{
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(tempDir);
try
{
var globalSettings = new GlobalSettings
{
SelfHosted = true,
MailTemplateDirectory = tempDir
};
// Create test template files on disk
var htmlTemplatePath = Path.Combine(tempDir, "Bit.Core.Test.Platform.Mailer.TestMail.TestMailView.html.hbs");
var txtTemplatePath = Path.Combine(tempDir, "Bit.Core.Test.Platform.Mailer.TestMail.TestMailView.text.hbs");
await File.WriteAllTextAsync(htmlTemplatePath, "Custom HTML: <b>{{Name}}</b>");
await File.WriteAllTextAsync(txtTemplatePath, "Custom TXT: {{Name}}");
var renderer = new HandlebarMailRenderer(logger, globalSettings);
var view = new TestMailView { Name = "Jane Doe" };
var (html, txt) = await renderer.RenderAsync(view);
Assert.Equal("Custom HTML: <b>Jane Doe</b>", html.Trim());
Assert.Equal("Custom TXT: Jane Doe", txt.Trim());
}
finally
{
// Cleanup
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, true);
}
}
}
[Theory]
[InlineData("../../../etc/passwd")]
[InlineData("../../../../malicious.txt")]
[InlineData("../../malicious.txt")]
[InlineData("../malicious.txt")]
public async Task ReadSourceFromDiskAsync_PrevenetsPathTraversal_WhenMaliciousPathProvided(string maliciousPath)
{
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(tempDir);
try
{
var globalSettings = new GlobalSettings
{
SelfHosted = true,
MailTemplateDirectory = tempDir
};
// Create a malicious file outside the template directory
var maliciousFile = Path.Combine(Path.GetTempPath(), "malicious.txt");
await File.WriteAllTextAsync(maliciousFile, "Malicious Content");
var renderer = new HandlebarMailRenderer(logger, globalSettings);
// Use reflection to call the private ReadSourceFromDiskAsync method
var method = typeof(HandlebarMailRenderer).GetMethod("ReadSourceFromDiskAsync",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var task = (Task<string?>)method!.Invoke(renderer, new object[] { maliciousPath })!;
var result = await task;
// Should return null and not load the malicious file
Assert.Null(result);
// Verify that a warning was logged for the path traversal attempt
logger.Received(1).Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
// Cleanup malicious file
if (File.Exists(maliciousFile))
{
File.Delete(maliciousFile);
}
}
finally
{
// Cleanup
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, true);
}
}
}
[Fact]
public async Task ReadSourceFromDiskAsync_AllowsValidFileWithDifferentCase_WhenCaseInsensitiveFileSystem()
{
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(tempDir);
try
{
var globalSettings = new GlobalSettings
{
SelfHosted = true,
MailTemplateDirectory = tempDir
};
// Create a test template file
var templateFileName = "TestTemplate.hbs";
var templatePath = Path.Combine(tempDir, templateFileName);
await File.WriteAllTextAsync(templatePath, "Test Content");
var renderer = new HandlebarMailRenderer(logger, globalSettings);
// Try to read with different case (should work on case-insensitive file systems like Windows/macOS)
var method = typeof(HandlebarMailRenderer).GetMethod("ReadSourceFromDiskAsync",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var task = (Task<string?>)method!.Invoke(renderer, new object[] { templateFileName })!;
var result = await task;
// Should successfully read the file
Assert.Equal("Test Content", result);
// Verify no warning was logged
logger.DidNotReceive().Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
}
finally
{
// Cleanup
if (Directory.Exists(tempDir))
{
Directory.Delete(tempDir, true);
}
}
}
}

View File

@@ -1,18 +1,24 @@
using Bit.Core.Models.Mail;
using Bit.Core.Platform.Mail.Delivery;
using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Settings;
using Bit.Core.Test.Platform.Mailer.TestMail;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Platform.Mailer;
public class MailerTest
{
[Fact]
public async Task SendEmailAsync()
{
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
var globalSettings = new GlobalSettings { SelfHosted = false };
var deliveryService = Substitute.For<IMailDeliveryService>();
var mailer = new Core.Platform.Mail.Mailer.Mailer(new HandlebarMailRenderer(), deliveryService);
var mailer = new Core.Platform.Mail.Mailer.Mailer(new HandlebarMailRenderer(logger, globalSettings), deliveryService);
var mail = new TestMail.TestMail()
{

View File

@@ -268,4 +268,115 @@ public class HandlebarsMailServiceTests
// Assert
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Any<MailMessage>());
}
[Fact]
public async Task SendIndividualUserWelcomeEmailAsync_SendsCorrectEmail()
{
// Arrange
var user = new User
{
Id = Guid.NewGuid(),
Email = "test@example.com"
};
// Act
await _sut.SendIndividualUserWelcomeEmailAsync(user);
// Assert
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is<MailMessage>(m =>
m.MetaData != null &&
m.ToEmails.Contains("test@example.com") &&
m.Subject == "Welcome to Bitwarden!" &&
m.Category == "Welcome"));
}
[Fact]
public async Task SendOrganizationUserWelcomeEmailAsync_SendsCorrectEmailWithOrganizationName()
{
// Arrange
var user = new User
{
Id = Guid.NewGuid(),
Email = "user@company.com"
};
var organizationName = "Bitwarden Corp";
// Act
await _sut.SendOrganizationUserWelcomeEmailAsync(user, organizationName);
// Assert
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is<MailMessage>(m =>
m.MetaData != null &&
m.ToEmails.Contains("user@company.com") &&
m.Subject == "Welcome to Bitwarden!" &&
m.HtmlContent.Contains("Bitwarden Corp") &&
m.Category == "Welcome"));
}
[Fact]
public async Task SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync_SendsCorrectEmailWithFamilyTemplate()
{
// Arrange
var user = new User
{
Id = Guid.NewGuid(),
Email = "family@example.com"
};
var familyOrganizationName = "Smith Family";
// Act
await _sut.SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, familyOrganizationName);
// Assert
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is<MailMessage>(m =>
m.MetaData != null &&
m.ToEmails.Contains("family@example.com") &&
m.Subject == "Welcome to Bitwarden!" &&
m.HtmlContent.Contains("Smith Family") &&
m.Category == "Welcome"));
}
[Theory]
[InlineData("Acme Corp", "Acme Corp")]
[InlineData("Company & Associates", "Company &amp; Associates")]
[InlineData("Test \"Quoted\" Org", "Test &quot;Quoted&quot; Org")]
public async Task SendOrganizationUserWelcomeEmailAsync_SanitizesOrganizationNameForEmail(string inputOrgName, string expectedSanitized)
{
// Arrange
var user = new User
{
Id = Guid.NewGuid(),
Email = "test@example.com"
};
// Act
await _sut.SendOrganizationUserWelcomeEmailAsync(user, inputOrgName);
// Assert
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is<MailMessage>(m =>
m.HtmlContent.Contains(expectedSanitized) &&
!m.HtmlContent.Contains("<script>") && // Ensure script tags are removed
m.Category == "Welcome"));
}
[Theory]
[InlineData("test@example.com")]
[InlineData("user+tag@domain.co.uk")]
[InlineData("admin@organization.org")]
public async Task SendIndividualUserWelcomeEmailAsync_HandlesVariousEmailFormats(string email)
{
// Arrange
var user = new User
{
Id = Guid.NewGuid(),
Email = email
};
// Act
await _sut.SendIndividualUserWelcomeEmailAsync(user);
// Assert
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is<MailMessage>(m =>
m.ToEmails.Contains(email)));
}
}

View File

@@ -3,6 +3,7 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models.StaticStore.Plans;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Tax.Requests;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
@@ -515,4 +516,399 @@ public class StripePaymentServiceTests
options.CustomerDetails.TaxExempt == StripeConstants.TaxExempt.Reverse
));
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithCustomerDiscount_ReturnsDiscountFromCustomer(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var customerDiscount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
PercentOff = 20m,
AmountOff = 1400
},
End = null
};
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = customerDiscount
},
Discounts = new List<Discount>(), // Empty list
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.Equal(20m, result.CustomerDiscount.PercentOff);
Assert.Equal(14.00m, result.CustomerDiscount.AmountOff); // Converted from cents
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithoutCustomerDiscount_FallsBackToSubscriptionDiscounts(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var subscriptionDiscount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
PercentOff = 15m,
AmountOff = null
},
End = null
};
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = null // No customer discount
},
Discounts = new List<Discount> { subscriptionDiscount },
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Should use subscription discount as fallback
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.Equal(15m, result.CustomerDiscount.PercentOff);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithBothDiscounts_PrefersCustomerDiscount(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var customerDiscount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
PercentOff = 25m
},
End = null
};
var subscriptionDiscount = new Discount
{
Coupon = new Coupon
{
Id = "different-coupon-id",
PercentOff = 10m
},
End = null
};
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = customerDiscount // Should prefer this
},
Discounts = new List<Discount> { subscriptionDiscount },
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Should prefer customer discount over subscription discount
Assert.NotNull(result.CustomerDiscount);
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
Assert.Equal(25m, result.CustomerDiscount.PercentOff);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithNoDiscounts_ReturnsNullDiscount(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = null
},
Discounts = new List<Discount>(), // Empty list, no discounts
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithMultipleSubscriptionDiscounts_SelectsFirstDiscount(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange - Multiple subscription-level discounts, no customer discount
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var firstDiscount = new Discount
{
Coupon = new Coupon
{
Id = "coupon-10-percent",
PercentOff = 10m
},
End = null
};
var secondDiscount = new Discount
{
Coupon = new Coupon
{
Id = "coupon-20-percent",
PercentOff = 20m
},
End = null
};
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = null // No customer discount
},
// Multiple subscription discounts - FirstOrDefault() should select the first one
Discounts = new List<Discount> { firstDiscount, secondDiscount },
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Should select the first discount from the list (FirstOrDefault() behavior)
Assert.NotNull(result.CustomerDiscount);
Assert.Equal("coupon-10-percent", result.CustomerDiscount.Id);
Assert.Equal(10m, result.CustomerDiscount.PercentOff);
// Verify the second discount was not selected
Assert.NotEqual("coupon-20-percent", result.CustomerDiscount.Id);
Assert.NotEqual(20m, result.CustomerDiscount.PercentOff);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithNullCustomer_HandlesGracefully(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange - Subscription with null Customer (defensive null check scenario)
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = null, // Customer not expanded or null
Discounts = new List<Discount>(), // Empty discounts
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Should handle null Customer gracefully without throwing NullReferenceException
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithNullDiscounts_HandlesGracefully(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange - Subscription with null Discounts (defensive null check scenario)
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer
{
Discount = null // No customer discount
},
Discounts = null, // Discounts not expanded or null
Items = new StripeList<SubscriptionItem> { Data = [] }
};
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Should handle null Discounts gracefully without throwing NullReferenceException
Assert.Null(result.CustomerDiscount);
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_VerifiesCorrectExpandOptions(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.Gateway = GatewayType.Stripe;
subscriber.GatewayCustomerId = "cus_test123";
subscriber.GatewaySubscriptionId = "sub_test123";
var subscription = new Subscription
{
Id = "sub_test123",
Status = "active",
CollectionMethod = "charge_automatically",
Customer = new Customer { Discount = null },
Discounts = new List<Discount>(), // Empty list
Items = new StripeList<SubscriptionItem> { Data = [] }
};
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter
.SubscriptionGetAsync(
Arg.Any<string>(),
Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
// Act
await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert - Verify expand options are correct
await stripeAdapter.Received(1).SubscriptionGetAsync(
subscriber.GatewaySubscriptionId,
Arg.Is<SubscriptionGetOptions>(o =>
o.Expand.Contains("customer.discount.coupon.applies_to") &&
o.Expand.Contains("discounts.coupon.applies_to") &&
o.Expand.Contains("test_clock")));
}
[Theory]
[BitAutoData]
public async Task GetSubscriptionAsync_WithEmptyGatewaySubscriptionId_ReturnsEmptySubscriptionInfo(
SutProvider<StripePaymentService> sutProvider,
User subscriber)
{
// Arrange
subscriber.GatewaySubscriptionId = null;
// Act
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
// Assert
Assert.NotNull(result);
Assert.Null(result.Subscription);
Assert.Null(result.CustomerDiscount);
Assert.Null(result.UpcomingInvoice);
// Verify no Stripe API calls were made
await sutProvider.GetDependency<IStripeAdapter>()
.DidNotReceive()
.SubscriptionGetAsync(Arg.Any<string>(), Arg.Any<SubscriptionGetOptions>());
}
}

View File

@@ -238,4 +238,55 @@ public class ImportCiphersAsyncCommandTests
Assert.Equal("This organization can only have a maximum of " +
$"{organization.MaxCollections} collections.", exception.Message);
}
[Theory, BitAutoData]
public async Task ImportIntoOrganizationalVaultAsync_WithNullImportingOrgUser_SkipsCollectionUserCreation(
Organization organization,
Guid importingUserId,
List<Collection> collections,
List<CipherDetails> ciphers,
SutProvider<ImportCiphersCommand> sutProvider)
{
organization.MaxCollections = null;
foreach (var collection in collections)
{
collection.OrganizationId = organization.Id;
}
foreach (var cipher in ciphers)
{
cipher.OrganizationId = organization.Id;
}
KeyValuePair<int, int>[] collectionRelationships = {
new(0, 0),
new(1, 1),
new(2, 2)
};
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
// Simulate provider-created org with no members - importing user is NOT an org member
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByOrganizationAsync(organization.Id, importingUserId)
.Returns((OrganizationUser)null);
sutProvider.GetDependency<ICollectionRepository>()
.GetManyByOrganizationIdAsync(organization.Id)
.Returns(new List<Collection>());
await sutProvider.Sut.ImportIntoOrganizationalVaultAsync(collections, ciphers, collectionRelationships, importingUserId);
// Verify ciphers were created but no CollectionUser entries were created (because the organization user (importingUserId) is null)
await sutProvider.GetDependency<ICipherRepository>().Received(1).CreateAsync(
ciphers,
Arg.Is<IEnumerable<Collection>>(cols => cols.Count() == collections.Count),
Arg.Is<IEnumerable<CollectionCipher>>(cc => cc.Count() == ciphers.Count),
Arg.Is<IEnumerable<CollectionUser>>(cus => !cus.Any()));
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
}
}

View File

@@ -0,0 +1,171 @@
using Bit.Core.Settings;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using NSubstitute;
using StackExchange.Redis;
using Xunit;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.Test.Utilities;
public class ExtendedCacheServiceCollectionExtensionsTests
{
private readonly IServiceCollection _services;
private readonly GlobalSettings _globalSettings;
public ExtendedCacheServiceCollectionExtensionsTests()
{
_services = new ServiceCollection();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
_globalSettings = new GlobalSettings();
config.GetSection("GlobalSettings").Bind(_globalSettings);
_services.TryAddSingleton(config);
_services.TryAddSingleton(_globalSettings);
_services.TryAddSingleton<IGlobalSettings>(_globalSettings);
_services.AddLogging();
}
[Fact]
public void TryAddFusionCoreServices_CustomSettings_OverridesDefaults()
{
var settings = CreateGlobalSettings(new Dictionary<string, string?>
{
{ "GlobalSettings:DistributedCache:Duration", "00:12:00" },
{ "GlobalSettings:DistributedCache:FailSafeMaxDuration", "01:30:00" },
{ "GlobalSettings:DistributedCache:FailSafeThrottleDuration", "00:01:00" },
{ "GlobalSettings:DistributedCache:EagerRefreshThreshold", "0.75" },
{ "GlobalSettings:DistributedCache:FactorySoftTimeout", "00:00:00.020" },
{ "GlobalSettings:DistributedCache:FactoryHardTimeout", "00:00:03" },
{ "GlobalSettings:DistributedCache:DistributedCacheSoftTimeout", "00:00:00.500" },
{ "GlobalSettings:DistributedCache:DistributedCacheHardTimeout", "00:00:01.500" },
{ "GlobalSettings:DistributedCache:JitterMaxDuration", "00:00:05" },
{ "GlobalSettings:DistributedCache:IsFailSafeEnabled", "false" },
{ "GlobalSettings:DistributedCache:AllowBackgroundDistributedCacheOperations", "false" },
});
_services.TryAddExtendedCacheServices(settings);
using var provider = _services.BuildServiceProvider();
var fusionCache = provider.GetRequiredService<IFusionCache>();
var options = fusionCache.DefaultEntryOptions;
Assert.Equal(TimeSpan.FromMinutes(12), options.Duration);
Assert.False(options.IsFailSafeEnabled);
Assert.Equal(TimeSpan.FromHours(1.5), options.FailSafeMaxDuration);
Assert.Equal(TimeSpan.FromMinutes(1), options.FailSafeThrottleDuration);
Assert.Equal(0.75f, options.EagerRefreshThreshold);
Assert.Equal(TimeSpan.FromMilliseconds(20), options.FactorySoftTimeout);
Assert.Equal(TimeSpan.FromMilliseconds(3000), options.FactoryHardTimeout);
Assert.Equal(TimeSpan.FromSeconds(0.5), options.DistributedCacheSoftTimeout);
Assert.Equal(TimeSpan.FromSeconds(1.5), options.DistributedCacheHardTimeout);
Assert.False(options.AllowBackgroundDistributedCacheOperations);
Assert.Equal(TimeSpan.FromSeconds(5), options.JitterMaxDuration);
}
[Fact]
public void TryAddFusionCoreServices_DefaultSettings_ConfiguresExpectedValues()
{
_services.TryAddExtendedCacheServices(_globalSettings);
using var provider = _services.BuildServiceProvider();
var fusionCache = provider.GetRequiredService<IFusionCache>();
var options = fusionCache.DefaultEntryOptions;
Assert.Equal(TimeSpan.FromMinutes(30), options.Duration);
Assert.True(options.IsFailSafeEnabled);
Assert.Equal(TimeSpan.FromHours(2), options.FailSafeMaxDuration);
Assert.Equal(TimeSpan.FromSeconds(30), options.FailSafeThrottleDuration);
Assert.Equal(0.9f, options.EagerRefreshThreshold);
Assert.Equal(TimeSpan.FromMilliseconds(100), options.FactorySoftTimeout);
Assert.Equal(TimeSpan.FromMilliseconds(1500), options.FactoryHardTimeout);
Assert.Equal(TimeSpan.FromSeconds(1), options.DistributedCacheSoftTimeout);
Assert.Equal(TimeSpan.FromSeconds(2), options.DistributedCacheHardTimeout);
Assert.True(options.AllowBackgroundDistributedCacheOperations);
Assert.Equal(TimeSpan.FromSeconds(2), options.JitterMaxDuration);
}
[Fact]
public void TryAddFusionCoreServices_MultipleCalls_OnlyConfiguresOnce()
{
var settings = CreateGlobalSettings(new Dictionary<string, string?>
{
{ "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" },
});
_services.AddSingleton(Substitute.For<IConnectionMultiplexer>());
_services.TryAddExtendedCacheServices(settings);
_services.TryAddExtendedCacheServices(settings);
_services.TryAddExtendedCacheServices(settings);
var registrations = _services.Where(s => s.ServiceType == typeof(IFusionCache)).ToList();
Assert.Single(registrations);
using var provider = _services.BuildServiceProvider();
var fusionCache = provider.GetRequiredService<IFusionCache>();
Assert.NotNull(fusionCache);
}
[Fact]
public void TryAddFusionCoreServices_WithRedis_EnablesDistributedCacheAndBackplane()
{
var settings = CreateGlobalSettings(new Dictionary<string, string?>
{
{ "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" },
});
_services.AddSingleton(Substitute.For<IConnectionMultiplexer>());
_services.TryAddExtendedCacheServices(settings);
using var provider = _services.BuildServiceProvider();
var fusionCache = provider.GetRequiredService<IFusionCache>();
Assert.True(fusionCache.HasDistributedCache);
Assert.True(fusionCache.HasBackplane);
}
[Fact]
public void TryAddFusionCoreServices_WithExistingRedis_EnablesDistributedCacheAndBackplane()
{
var settings = CreateGlobalSettings(new Dictionary<string, string?>
{
{ "GlobalSettings:DistributedCache:Redis:ConnectionString", "localhost:6379" },
});
_services.AddSingleton(Substitute.For<IConnectionMultiplexer>());
_services.AddSingleton(Substitute.For<IDistributedCache>());
_services.TryAddExtendedCacheServices(settings);
using var provider = _services.BuildServiceProvider();
var fusionCache = provider.GetRequiredService<IFusionCache>();
Assert.True(fusionCache.HasDistributedCache);
Assert.True(fusionCache.HasBackplane);
var distributedCache = provider.GetRequiredService<IDistributedCache>();
Assert.NotNull(distributedCache);
}
[Fact]
public void TryAddFusionCoreServices_WithoutRedis_DisablesDistributedCacheAndBackplane()
{
_services.TryAddExtendedCacheServices(_globalSettings);
using var provider = _services.BuildServiceProvider();
var fusionCache = provider.GetRequiredService<IFusionCache>();
Assert.False(fusionCache.HasDistributedCache);
Assert.False(fusionCache.HasBackplane);
}
private static GlobalSettings CreateGlobalSettings(Dictionary<string, string?> data)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(data)
.Build();
var settings = new GlobalSettings();
config.GetSection("GlobalSettings").Bind(settings);
return settings;
}
}

View File

@@ -13,7 +13,7 @@ public class StaticStoreTests
var plans = StaticStore.Plans.ToList();
Assert.NotNull(plans);
Assert.NotEmpty(plans);
Assert.Equal(22, plans.Count);
Assert.Equal(23, plans.Count);
}
[Theory]
@@ -34,8 +34,8 @@ public class StaticStoreTests
{
// Ref: https://daniel.haxx.se/blog/2025/05/16/detecting-malicious-unicode/
// URLs can contain unicode characters that to a computer would point to completely seperate domains but to the
// naked eye look completely identical. For example 'g' and 'ց' look incredibly similar but when included in a
// URL would lead you somewhere different. There is an opening for an attacker to contribute to Bitwarden with a
// naked eye look completely identical. For example 'g' and 'ց' look incredibly similar but when included in a
// URL would lead you somewhere different. There is an opening for an attacker to contribute to Bitwarden with a
// url update that could be missed in code review and then if they got a user to that URL Bitwarden could
// consider it equivalent with a cipher in the users vault and offer autofill when we should not.
// GitHub does now show a warning on non-ascii characters but it could still be missed.