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:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>>());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
527
test/Core.Test/Billing/Pricing/PricingClientTests.cs
Normal file
527
test/Core.Test/Billing/Pricing/PricingClientTests.cs
Normal 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""
|
||||
}}
|
||||
}}";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
497
test/Core.Test/Models/Business/BillingCustomerDiscountTests.cs
Normal file
497
test/Core.Test/Models/Business/BillingCustomerDiscountTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
125
test/Core.Test/Models/Business/SubscriptionInfoTests.cs
Normal file
125
test/Core.Test/Models/Business/SubscriptionInfoTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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 & Associates")]
|
||||
[InlineData("Test \"Quoted\" Org", "Test "Quoted" 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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user