mirror of
https://github.com/bitwarden/server
synced 2026-01-17 16:03:49 +00:00
Merge branch 'main' into tools/pm-21918/send-authentication-commands
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
using AutoFixture;
|
||||
using System.Reflection;
|
||||
using AutoFixture;
|
||||
using AutoFixture.Xunit2;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
@@ -23,6 +25,7 @@ public class CurrentContextOrganizationCustomization : ICustomization
|
||||
}
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public class CurrentContextOrganizationCustomizeAttribute : BitCustomizeAttribute
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
@@ -38,3 +41,19 @@ public class CurrentContextOrganizationCustomizeAttribute : BitCustomizeAttribut
|
||||
AccessSecretsManager = AccessSecretsManager
|
||||
};
|
||||
}
|
||||
|
||||
public class CurrentContextOrganizationAttribute : CustomizeAttribute
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public OrganizationUserType Type { get; set; } = OrganizationUserType.User;
|
||||
public Permissions Permissions { get; set; } = new();
|
||||
public bool AccessSecretsManager { get; set; } = false;
|
||||
|
||||
public override ICustomization GetCustomization(ParameterInfo _) => new CurrentContextOrganizationCustomization
|
||||
{
|
||||
Id = Id,
|
||||
Type = Type,
|
||||
Permissions = Permissions,
|
||||
AccessSecretsManager = AccessSecretsManager
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace Bit.Core.Test.Models.Data.EventIntegrations;
|
||||
public class IntegrationMessageTests
|
||||
{
|
||||
private const string _messageId = "TestMessageId";
|
||||
private const string _organizationId = "TestOrganizationId";
|
||||
|
||||
[Fact]
|
||||
public void ApplyRetry_IncrementsRetryCountAndSetsDelayUntilDate()
|
||||
@@ -16,6 +17,7 @@ public class IntegrationMessageTests
|
||||
{
|
||||
Configuration = new WebhookIntegrationConfigurationDetails(new Uri("https://localhost"), "Bearer", "AUTH-TOKEN"),
|
||||
MessageId = _messageId,
|
||||
OrganizationId = _organizationId,
|
||||
RetryCount = 2,
|
||||
RenderedTemplate = string.Empty,
|
||||
DelayUntilDate = null
|
||||
@@ -36,6 +38,7 @@ public class IntegrationMessageTests
|
||||
{
|
||||
Configuration = new WebhookIntegrationConfigurationDetails(new Uri("https://localhost"), "Bearer", "AUTH-TOKEN"),
|
||||
MessageId = _messageId,
|
||||
OrganizationId = _organizationId,
|
||||
RenderedTemplate = "This is the message",
|
||||
IntegrationType = IntegrationType.Webhook,
|
||||
RetryCount = 2,
|
||||
@@ -48,6 +51,7 @@ public class IntegrationMessageTests
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(message.Configuration, result.Configuration);
|
||||
Assert.Equal(message.MessageId, result.MessageId);
|
||||
Assert.Equal(message.OrganizationId, result.OrganizationId);
|
||||
Assert.Equal(message.RenderedTemplate, result.RenderedTemplate);
|
||||
Assert.Equal(message.IntegrationType, result.IntegrationType);
|
||||
Assert.Equal(message.RetryCount, result.RetryCount);
|
||||
@@ -67,6 +71,7 @@ public class IntegrationMessageTests
|
||||
var message = new IntegrationMessage
|
||||
{
|
||||
MessageId = _messageId,
|
||||
OrganizationId = _organizationId,
|
||||
RenderedTemplate = "This is the message",
|
||||
IntegrationType = IntegrationType.Webhook,
|
||||
RetryCount = 2,
|
||||
@@ -77,6 +82,7 @@ public class IntegrationMessageTests
|
||||
var result = JsonSerializer.Deserialize<IntegrationMessage>(json);
|
||||
|
||||
Assert.Equal(message.MessageId, result.MessageId);
|
||||
Assert.Equal(message.OrganizationId, result.OrganizationId);
|
||||
Assert.Equal(message.RenderedTemplate, result.RenderedTemplate);
|
||||
Assert.Equal(message.IntegrationType, result.IntegrationType);
|
||||
Assert.Equal(message.RetryCount, result.RetryCount);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.AdminConsole.Models.Data.EventIntegrations;
|
||||
|
||||
public class IntegrationOAuthStateTests
|
||||
{
|
||||
private readonly FakeTimeProvider _fakeTimeProvider = new(
|
||||
new DateTime(2014, 3, 2, 1, 0, 0, DateTimeKind.Utc)
|
||||
);
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void FromIntegration_ToString_RoundTripsCorrectly(OrganizationIntegration integration)
|
||||
{
|
||||
var state = IntegrationOAuthState.FromIntegration(integration, _fakeTimeProvider);
|
||||
var parsed = IntegrationOAuthState.FromString(state.ToString(), _fakeTimeProvider);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(state.IntegrationId, parsed.IntegrationId);
|
||||
Assert.True(parsed.ValidateOrg(integration.OrganizationId));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("not-a-valid-state")]
|
||||
public void FromString_InvalidString_ReturnsNull(string state)
|
||||
{
|
||||
var parsed = IntegrationOAuthState.FromString(state, _fakeTimeProvider);
|
||||
|
||||
Assert.Null(parsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromString_InvalidGuid_ReturnsNull()
|
||||
{
|
||||
var badState = $"not-a-guid.ABCD1234.1706313600";
|
||||
|
||||
var parsed = IntegrationOAuthState.FromString(badState, _fakeTimeProvider);
|
||||
|
||||
Assert.Null(parsed);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void FromString_ExpiredState_ReturnsNull(OrganizationIntegration integration)
|
||||
{
|
||||
var state = IntegrationOAuthState.FromIntegration(integration, _fakeTimeProvider);
|
||||
|
||||
// Advance time 30 minutes to exceed the 20-minute max age
|
||||
_fakeTimeProvider.Advance(TimeSpan.FromMinutes(30));
|
||||
|
||||
var parsed = IntegrationOAuthState.FromString(state.ToString(), _fakeTimeProvider);
|
||||
|
||||
Assert.Null(parsed);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void ValidateOrg_WithCorrectOrgId_ReturnsTrue(OrganizationIntegration integration)
|
||||
{
|
||||
var state = IntegrationOAuthState.FromIntegration(integration, _fakeTimeProvider);
|
||||
|
||||
Assert.True(state.ValidateOrg(integration.OrganizationId));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void ValidateOrg_WithWrongOrgId_ReturnsFalse(OrganizationIntegration integration)
|
||||
{
|
||||
var state = IntegrationOAuthState.FromIntegration(integration, _fakeTimeProvider);
|
||||
|
||||
Assert.False(state.ValidateOrg(Guid.NewGuid()));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void ValidateOrg_ModifiedTimestamp_ReturnsFalse(OrganizationIntegration integration)
|
||||
{
|
||||
var state = IntegrationOAuthState.FromIntegration(integration, _fakeTimeProvider);
|
||||
var parts = state.ToString().Split('.');
|
||||
|
||||
parts[2] = $"{_fakeTimeProvider.GetUtcNow().ToUnixTimeSeconds() - 1}";
|
||||
var modifiedState = IntegrationOAuthState.FromString(string.Join(".", parts), _fakeTimeProvider);
|
||||
|
||||
Assert.True(state.ValidateOrg(integration.OrganizationId));
|
||||
Assert.NotNull(modifiedState);
|
||||
Assert.False(modifiedState.ValidateOrg(integration.OrganizationId));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -13,4 +13,8 @@ public class TestListenerConfiguration : IIntegrationListenerConfiguration
|
||||
public string IntegrationSubscriptionName => "integration_subscription";
|
||||
public string IntegrationTopicName => "integration_topic";
|
||||
public int MaxRetries => 3;
|
||||
public int EventMaxConcurrentCalls => 1;
|
||||
public int EventPrefetchCount => 0;
|
||||
public int IntegrationMaxConcurrentCalls => 1;
|
||||
public int IntegrationPrefetchCount => 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using Bit.Core.AdminConsole.Models.Teams;
|
||||
using Microsoft.Bot.Connector.Authentication;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Models.Data.Teams;
|
||||
|
||||
public class TeamsBotCredentialProviderTests
|
||||
{
|
||||
private string _clientId = "client id";
|
||||
private string _clientSecret = "client secret";
|
||||
|
||||
[Fact]
|
||||
public async Task IsValidAppId_MustMatchClientId()
|
||||
{
|
||||
var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret);
|
||||
|
||||
Assert.True(await sut.IsValidAppIdAsync(_clientId));
|
||||
Assert.False(await sut.IsValidAppIdAsync("Different id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAppPasswordAsync_MatchingClientId_ReturnsClientSecret()
|
||||
{
|
||||
var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret);
|
||||
var password = await sut.GetAppPasswordAsync(_clientId);
|
||||
Assert.Equal(_clientSecret, password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAppPasswordAsync_NotMatchingClientId_ReturnsNull()
|
||||
{
|
||||
var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret);
|
||||
Assert.Null(await sut.GetAppPasswordAsync("Different id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsAuthenticationDisabledAsync_ReturnsFalse()
|
||||
{
|
||||
var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret);
|
||||
Assert.False(await sut.IsAuthenticationDisabledAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateIssuerAsync_ExpectedIssuer_ReturnsTrue()
|
||||
{
|
||||
var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret);
|
||||
Assert.True(await sut.ValidateIssuerAsync(AuthenticationConstants.ToBotFromChannelTokenIssuer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateIssuerAsync_UnexpectedIssuer_ReturnsFalse()
|
||||
{
|
||||
var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret);
|
||||
Assert.False(await sut.ValidateIssuerAsync("unexpected issuer"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
using AutoFixture;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Test.AutoFixture.OrganizationUserFixtures;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.AccountRecovery;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class AdminRecoverAccountCommandTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RecoverAccountAsync_Success(
|
||||
string newMasterPassword,
|
||||
string key,
|
||||
Organization organization,
|
||||
OrganizationUser organizationUser,
|
||||
User user,
|
||||
SutProvider<AdminRecoverAccountCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
SetupValidOrganization(sutProvider, organization);
|
||||
SetupValidPolicy(sutProvider, organization);
|
||||
SetupValidOrganizationUser(organizationUser, organization.Id);
|
||||
SetupValidUser(sutProvider, user, organizationUser);
|
||||
SetupSuccessfulPasswordUpdate(sutProvider, user, newMasterPassword);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Succeeded);
|
||||
await AssertSuccessAsync(sutProvider, user, key, organization, organizationUser);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RecoverAccountAsync_OrganizationDoesNotExist_ThrowsBadRequest(
|
||||
[OrganizationUser] OrganizationUser organizationUser,
|
||||
string newMasterPassword,
|
||||
string key,
|
||||
SutProvider<AdminRecoverAccountCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var orgId = Guid.NewGuid();
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(orgId)
|
||||
.Returns((Organization)null);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.RecoverAccountAsync(orgId, organizationUser, newMasterPassword, key));
|
||||
Assert.Equal("Organization does not allow password reset.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RecoverAccountAsync_OrganizationDoesNotAllowResetPassword_ThrowsBadRequest(
|
||||
string newMasterPassword,
|
||||
string key,
|
||||
Organization organization,
|
||||
[OrganizationUser] OrganizationUser organizationUser,
|
||||
SutProvider<AdminRecoverAccountCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
organization.UseResetPassword = false;
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(organization.Id)
|
||||
.Returns(organization);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key));
|
||||
Assert.Equal("Organization does not allow password reset.", exception.Message);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> InvalidPolicies => new object[][]
|
||||
{
|
||||
[new Policy { Type = PolicyType.ResetPassword, Enabled = false }], [null]
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[BitMemberAutoData(nameof(InvalidPolicies))]
|
||||
public async Task RecoverAccountAsync_InvalidPolicy_ThrowsBadRequest(
|
||||
Policy resetPasswordPolicy,
|
||||
string newMasterPassword,
|
||||
string key,
|
||||
Organization organization,
|
||||
SutProvider<AdminRecoverAccountCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
SetupValidOrganization(sutProvider, organization);
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword)
|
||||
.Returns(resetPasswordPolicy);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.RecoverAccountAsync(organization.Id, new OrganizationUser { Id = Guid.NewGuid() },
|
||||
newMasterPassword, key));
|
||||
Assert.Equal("Organization does not have the password reset policy enabled.", exception.Message);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> InvalidOrganizationUsers()
|
||||
{
|
||||
// Make an organization so we can use its Id
|
||||
var organization = new Fixture().Create<Organization>();
|
||||
|
||||
var nonConfirmed = new OrganizationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = organization.Id,
|
||||
Status = OrganizationUserStatusType.Invited
|
||||
};
|
||||
yield return [nonConfirmed, organization];
|
||||
|
||||
var wrongOrganization = new OrganizationUser
|
||||
{
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
OrganizationId = Guid.NewGuid(), // Different org
|
||||
ResetPasswordKey = "test-key",
|
||||
UserId = Guid.NewGuid(),
|
||||
};
|
||||
yield return [wrongOrganization, organization];
|
||||
|
||||
var nullResetPasswordKey = new OrganizationUser
|
||||
{
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
OrganizationId = organization.Id,
|
||||
ResetPasswordKey = null,
|
||||
UserId = Guid.NewGuid(),
|
||||
};
|
||||
yield return [nullResetPasswordKey, organization];
|
||||
|
||||
var emptyResetPasswordKey = new OrganizationUser
|
||||
{
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
OrganizationId = organization.Id,
|
||||
ResetPasswordKey = "",
|
||||
UserId = Guid.NewGuid(),
|
||||
};
|
||||
yield return [emptyResetPasswordKey, organization];
|
||||
|
||||
var nullUserId = new OrganizationUser
|
||||
{
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
OrganizationId = organization.Id,
|
||||
ResetPasswordKey = "test-key",
|
||||
UserId = null,
|
||||
};
|
||||
yield return [nullUserId, organization];
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitMemberAutoData(nameof(InvalidOrganizationUsers))]
|
||||
public async Task RecoverAccountAsync_OrganizationUserIsInvalid_ThrowsBadRequest(
|
||||
OrganizationUser organizationUser,
|
||||
Organization organization,
|
||||
string newMasterPassword,
|
||||
string key,
|
||||
SutProvider<AdminRecoverAccountCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
SetupValidOrganization(sutProvider, organization);
|
||||
SetupValidPolicy(sutProvider, organization);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key));
|
||||
Assert.Equal("Organization User not valid", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RecoverAccountAsync_UserDoesNotExist_ThrowsNotFoundException(
|
||||
string newMasterPassword,
|
||||
string key,
|
||||
Organization organization,
|
||||
OrganizationUser organizationUser,
|
||||
SutProvider<AdminRecoverAccountCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
SetupValidOrganization(sutProvider, organization);
|
||||
SetupValidPolicy(sutProvider, organization);
|
||||
SetupValidOrganizationUser(organizationUser, organization.Id);
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetUserByIdAsync(organizationUser.UserId!.Value)
|
||||
.Returns((User)null);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotFoundException>(() =>
|
||||
sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RecoverAccountAsync_UserUsesKeyConnector_ThrowsBadRequest(
|
||||
string newMasterPassword,
|
||||
string key,
|
||||
Organization organization,
|
||||
OrganizationUser organizationUser,
|
||||
User user,
|
||||
SutProvider<AdminRecoverAccountCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
SetupValidOrganization(sutProvider, organization);
|
||||
SetupValidPolicy(sutProvider, organization);
|
||||
SetupValidOrganizationUser(organizationUser, organization.Id);
|
||||
user.UsesKeyConnector = true;
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetUserByIdAsync(organizationUser.UserId!.Value)
|
||||
.Returns(user);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key));
|
||||
Assert.Equal("Cannot reset password of a user with Key Connector.", exception.Message);
|
||||
}
|
||||
|
||||
private static void SetupValidOrganization(SutProvider<AdminRecoverAccountCommand> sutProvider, Organization organization)
|
||||
{
|
||||
organization.UseResetPassword = true;
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(organization.Id)
|
||||
.Returns(organization);
|
||||
}
|
||||
|
||||
private static void SetupValidPolicy(SutProvider<AdminRecoverAccountCommand> sutProvider, Organization organization)
|
||||
{
|
||||
var policy = new Policy { Type = PolicyType.ResetPassword, Enabled = true };
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword)
|
||||
.Returns(policy);
|
||||
}
|
||||
|
||||
private static void SetupValidOrganizationUser(OrganizationUser organizationUser, Guid orgId)
|
||||
{
|
||||
organizationUser.Status = OrganizationUserStatusType.Confirmed;
|
||||
organizationUser.OrganizationId = orgId;
|
||||
organizationUser.ResetPasswordKey = "test-key";
|
||||
organizationUser.Type = OrganizationUserType.User;
|
||||
}
|
||||
|
||||
private static void SetupValidUser(SutProvider<AdminRecoverAccountCommand> sutProvider, User user, OrganizationUser organizationUser)
|
||||
{
|
||||
user.Id = organizationUser.UserId!.Value;
|
||||
user.UsesKeyConnector = false;
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetUserByIdAsync(user.Id)
|
||||
.Returns(user);
|
||||
}
|
||||
|
||||
private static void SetupSuccessfulPasswordUpdate(SutProvider<AdminRecoverAccountCommand> sutProvider, User user, string newMasterPassword)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(user, newMasterPassword)
|
||||
.Returns(IdentityResult.Success);
|
||||
}
|
||||
|
||||
private static async Task AssertSuccessAsync(SutProvider<AdminRecoverAccountCommand> sutProvider, User user, string key,
|
||||
Organization organization, OrganizationUser organizationUser)
|
||||
{
|
||||
await sutProvider.GetDependency<IUserRepository>().Received(1).ReplaceAsync(
|
||||
Arg.Is<User>(u =>
|
||||
u.Id == user.Id &&
|
||||
u.Key == key &&
|
||||
u.ForcePasswordReset == true &&
|
||||
u.RevisionDate == u.AccountRevisionDate &&
|
||||
u.LastPasswordChangeDate == u.RevisionDate));
|
||||
|
||||
await sutProvider.GetDependency<IMailService>().Received(1).SendAdminResetPasswordEmailAsync(
|
||||
Arg.Is(user.Email),
|
||||
Arg.Is(user.Name),
|
||||
Arg.Is(organization.DisplayName()));
|
||||
|
||||
await sutProvider.GetDependency<IEventService>().Received(1).LogOrganizationUserEventAsync(
|
||||
Arg.Is(organizationUser),
|
||||
Arg.Is(EventType.OrganizationUser_AdminResetPassword));
|
||||
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushLogOutAsync(
|
||||
Arg.Is(user.Id));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Models.Data;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@@ -191,6 +192,37 @@ public class VerifyOrganizationDomainCommandTests
|
||||
x.PerformedBy.UserId == userId));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UserVerifyOrganizationDomainAsync_WhenPolicyValidatorsRefactorFlagEnabled_UsesVNextSavePolicyCommand(
|
||||
OrganizationDomain domain, Guid userId, SutProvider<VerifyOrganizationDomainCommand> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IOrganizationDomainRepository>()
|
||||
.GetClaimedDomainsByDomainNameAsync(domain.DomainName)
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IDnsResolverService>()
|
||||
.ResolveAsync(domain.DomainName, domain.Txt)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>()
|
||||
.UserId.Returns(userId);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)
|
||||
.Returns(true);
|
||||
|
||||
_ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain);
|
||||
|
||||
await sutProvider.GetDependency<IVNextSavePolicyCommand>()
|
||||
.Received(1)
|
||||
.SaveAsync(Arg.Is<SavePolicyModel>(m =>
|
||||
m.PolicyUpdate.Type == PolicyType.SingleOrg &&
|
||||
m.PolicyUpdate.OrganizationId == domain.OrganizationId &&
|
||||
m.PolicyUpdate.Enabled &&
|
||||
m.PerformedBy is StandardUser &&
|
||||
m.PerformedBy.UserId == userId));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UserVerifyOrganizationDomainAsync_WhenDomainIsNotVerified_ThenSingleOrgPolicyShouldNotBeEnabled(
|
||||
OrganizationDomain domain, SutProvider<VerifyOrganizationDomainCommand> sutProvider)
|
||||
|
||||
@@ -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,124 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
using OneOf.Types;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies;
|
||||
|
||||
public class PolicyEventHandlerHandlerFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetHandler_ReturnsHandler_WhenHandlerExists()
|
||||
{
|
||||
// Arrange
|
||||
var expectedHandler = new FakeSingleOrgDependencyEvent();
|
||||
var factory = new PolicyEventHandlerHandlerFactory([expectedHandler]);
|
||||
|
||||
// Act
|
||||
var result = factory.GetHandler<IEnforceDependentPoliciesEvent>(PolicyType.SingleOrg);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
Assert.Equal(expectedHandler, result.AsT0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHandler_ReturnsNone_WhenHandlerDoesNotExist()
|
||||
{
|
||||
// Arrange
|
||||
var factory = new PolicyEventHandlerHandlerFactory([new FakeSingleOrgDependencyEvent()]);
|
||||
|
||||
// Act
|
||||
var result = factory.GetHandler<IEnforceDependentPoliciesEvent>(PolicyType.RequireSso);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT1);
|
||||
Assert.IsType<None>(result.AsT1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHandler_ReturnsNone_WhenHandlerTypeDoesNotMatch()
|
||||
{
|
||||
// Arrange
|
||||
var factory = new PolicyEventHandlerHandlerFactory([new FakeSingleOrgDependencyEvent()]);
|
||||
|
||||
// Act
|
||||
var result = factory.GetHandler<IPolicyValidationEvent>(PolicyType.SingleOrg);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT1);
|
||||
Assert.IsType<None>(result.AsT1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHandler_ReturnsCorrectHandler_WhenMultipleHandlerTypesExist()
|
||||
{
|
||||
// Arrange
|
||||
var dependencyEvent = new FakeSingleOrgDependencyEvent();
|
||||
var validationEvent = new FakeSingleOrgValidationEvent();
|
||||
var factory = new PolicyEventHandlerHandlerFactory([dependencyEvent, validationEvent]);
|
||||
|
||||
// Act
|
||||
var dependencyResult = factory.GetHandler<IEnforceDependentPoliciesEvent>(PolicyType.SingleOrg);
|
||||
var validationResult = factory.GetHandler<IPolicyValidationEvent>(PolicyType.SingleOrg);
|
||||
|
||||
// Assert
|
||||
Assert.True(dependencyResult.IsT0);
|
||||
Assert.Equal(dependencyEvent, dependencyResult.AsT0);
|
||||
|
||||
Assert.True(validationResult.IsT0);
|
||||
Assert.Equal(validationEvent, validationResult.AsT0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHandler_ReturnsCorrectHandler_WhenMultiplePolicyTypesExist()
|
||||
{
|
||||
// Arrange
|
||||
var singleOrgEvent = new FakeSingleOrgDependencyEvent();
|
||||
var requireSsoEvent = new FakeRequireSsoDependencyEvent();
|
||||
var factory = new PolicyEventHandlerHandlerFactory([singleOrgEvent, requireSsoEvent]);
|
||||
|
||||
// Act
|
||||
var singleOrgResult = factory.GetHandler<IEnforceDependentPoliciesEvent>(PolicyType.SingleOrg);
|
||||
var requireSsoResult = factory.GetHandler<IEnforceDependentPoliciesEvent>(PolicyType.RequireSso);
|
||||
|
||||
// Assert
|
||||
Assert.True(singleOrgResult.IsT0);
|
||||
Assert.Equal(singleOrgEvent, singleOrgResult.AsT0);
|
||||
|
||||
Assert.True(requireSsoResult.IsT0);
|
||||
Assert.Equal(requireSsoEvent, requireSsoResult.AsT0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHandler_Throws_WhenDuplicateHandlersExist()
|
||||
{
|
||||
// Arrange
|
||||
var factory = new PolicyEventHandlerHandlerFactory([
|
||||
new FakeSingleOrgDependencyEvent(),
|
||||
new FakeSingleOrgDependencyEvent()
|
||||
]);
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
factory.GetHandler<IEnforceDependentPoliciesEvent>(PolicyType.SingleOrg));
|
||||
|
||||
Assert.Contains("Multiple IPolicyUpdateEvent handlers of type IEnforceDependentPoliciesEvent found for PolicyType SingleOrg", exception.Message);
|
||||
Assert.Contains("Expected one IEnforceDependentPoliciesEvent handler per PolicyType", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetHandler_ReturnsNone_WhenNoHandlersProvided()
|
||||
{
|
||||
// Arrange
|
||||
var factory = new PolicyEventHandlerHandlerFactory([]);
|
||||
|
||||
// Act
|
||||
var result = factory.GetHandler<IEnforceDependentPoliciesEvent>(PolicyType.SingleOrg);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT1);
|
||||
Assert.IsType<None>(result.AsT1);
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,12 @@ public class PolicyRequirementQueryTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task GetAsync_IgnoresOtherPolicyTypes(Guid userId)
|
||||
{
|
||||
var thisPolicy = new PolicyDetails { PolicyType = PolicyType.SingleOrg };
|
||||
var otherPolicy = new PolicyDetails { PolicyType = PolicyType.RequireSso };
|
||||
var thisPolicy = new OrganizationPolicyDetails { PolicyType = PolicyType.SingleOrg, UserId = userId };
|
||||
var otherPolicy = new OrganizationPolicyDetails { PolicyType = PolicyType.RequireSso, UserId = userId };
|
||||
var policyRepository = Substitute.For<IPolicyRepository>();
|
||||
policyRepository.GetPolicyDetailsByUserId(userId).Returns([otherPolicy, thisPolicy]);
|
||||
policyRepository.GetPolicyDetailsByUserIdsAndPolicyType(
|
||||
Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(userId)), PolicyType.SingleOrg)
|
||||
.Returns([otherPolicy, thisPolicy]);
|
||||
|
||||
var factory = new TestPolicyRequirementFactory(_ => true);
|
||||
var sut = new PolicyRequirementQuery(policyRepository, [factory]);
|
||||
@@ -33,9 +35,11 @@ public class PolicyRequirementQueryTests
|
||||
{
|
||||
// Arrange policies
|
||||
var policyRepository = Substitute.For<IPolicyRepository>();
|
||||
var thisPolicy = new PolicyDetails { PolicyType = PolicyType.SingleOrg };
|
||||
var otherPolicy = new PolicyDetails { PolicyType = PolicyType.SingleOrg };
|
||||
policyRepository.GetPolicyDetailsByUserId(userId).Returns([thisPolicy, otherPolicy]);
|
||||
var thisPolicy = new OrganizationPolicyDetails { PolicyType = PolicyType.SingleOrg, UserId = userId };
|
||||
var otherPolicy = new OrganizationPolicyDetails { PolicyType = PolicyType.SingleOrg, UserId = userId };
|
||||
policyRepository.GetPolicyDetailsByUserIdsAndPolicyType(
|
||||
Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(userId)), PolicyType.SingleOrg)
|
||||
.Returns([thisPolicy, otherPolicy]);
|
||||
|
||||
// Arrange a substitute Enforce function so that we can inspect the received calls
|
||||
var callback = Substitute.For<Func<PolicyDetails, bool>>();
|
||||
@@ -70,7 +74,9 @@ public class PolicyRequirementQueryTests
|
||||
public async Task GetAsync_HandlesNoPolicies(Guid userId)
|
||||
{
|
||||
var policyRepository = Substitute.For<IPolicyRepository>();
|
||||
policyRepository.GetPolicyDetailsByUserId(userId).Returns([]);
|
||||
policyRepository.GetPolicyDetailsByUserIdsAndPolicyType(
|
||||
Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(userId)), PolicyType.SingleOrg)
|
||||
.Returns([]);
|
||||
|
||||
var factory = new TestPolicyRequirementFactory(x => x.IsProvider);
|
||||
var sut = new PolicyRequirementQuery(policyRepository, [factory]);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies;
|
||||
|
||||
public class FakeSingleOrgDependencyEvent : IEnforceDependentPoliciesEvent
|
||||
{
|
||||
public PolicyType Type => PolicyType.SingleOrg;
|
||||
public IEnumerable<PolicyType> RequiredPolicies => [];
|
||||
}
|
||||
|
||||
public class FakeRequireSsoDependencyEvent : IEnforceDependentPoliciesEvent
|
||||
{
|
||||
public PolicyType Type => PolicyType.RequireSso;
|
||||
public IEnumerable<PolicyType> RequiredPolicies => [PolicyType.SingleOrg];
|
||||
}
|
||||
|
||||
public class FakeVaultTimeoutDependencyEvent : IEnforceDependentPoliciesEvent
|
||||
{
|
||||
public PolicyType Type => PolicyType.MaximumVaultTimeout;
|
||||
public IEnumerable<PolicyType> RequiredPolicies => [PolicyType.SingleOrg];
|
||||
}
|
||||
|
||||
public class FakeSingleOrgValidationEvent : IPolicyValidationEvent
|
||||
{
|
||||
public PolicyType Type => PolicyType.SingleOrg;
|
||||
|
||||
public readonly Func<SavePolicyModel, Policy?, Task<string>> ValidateAsyncMock = Substitute.For<Func<SavePolicyModel, Policy?, Task<string>>>();
|
||||
|
||||
public Task<string> ValidateAsync(SavePolicyModel policyRequest, Policy? currentPolicy)
|
||||
{
|
||||
return ValidateAsyncMock(policyRequest, currentPolicy);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -72,4 +72,65 @@ public class FreeFamiliesForEnterprisePolicyValidatorTests
|
||||
organizationSponsorships[0].SponsoredOrganizationId.ToString(), organization.Name);
|
||||
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ExecutePreUpsertSideEffectAsync_DoesNotNotifyUserWhenPolicyDisabled(
|
||||
Organization organization,
|
||||
List<OrganizationSponsorship> organizationSponsorships,
|
||||
[PolicyUpdate(PolicyType.FreeFamiliesSponsorshipPolicy)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.FreeFamiliesSponsorshipPolicy, true)] Policy policy,
|
||||
SutProvider<FreeFamiliesForEnterprisePolicyValidator> sutProvider)
|
||||
{
|
||||
policy.Enabled = true;
|
||||
policyUpdate.Enabled = false;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns(organization);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationSponsorshipRepository>()
|
||||
.GetManyBySponsoringOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns(organizationSponsorships);
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, policy);
|
||||
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(default, default, default, default);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ExecutePreUpsertSideEffectAsync_DoesNotifyUserWhenPolicyEnabled(
|
||||
Organization organization,
|
||||
List<OrganizationSponsorship> organizationSponsorships,
|
||||
[PolicyUpdate(PolicyType.FreeFamiliesSponsorshipPolicy)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.FreeFamiliesSponsorshipPolicy, false)] Policy policy,
|
||||
SutProvider<FreeFamiliesForEnterprisePolicyValidator> sutProvider)
|
||||
{
|
||||
policy.Enabled = false;
|
||||
policyUpdate.Enabled = true;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns(organization);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationSponsorshipRepository>()
|
||||
.GetManyBySponsoringOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns(organizationSponsorships);
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, policy);
|
||||
|
||||
var offerAcceptanceDate = organizationSponsorships[0].ValidUntil!.Value.AddDays(-7).ToString("MM/dd/yyyy");
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.Received(1)
|
||||
.SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(
|
||||
organizationSponsorships[0].FriendlyName,
|
||||
offerAcceptanceDate,
|
||||
organizationSponsorships[0].SponsoredOrganizationId.ToString(),
|
||||
organization.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
|
||||
.Returns(false);
|
||||
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
|
||||
@@ -58,7 +58,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
|
||||
.Returns(true);
|
||||
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
|
||||
@@ -84,7 +84,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
|
||||
.Returns(true);
|
||||
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
|
||||
@@ -110,7 +110,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
var collectionRepository = Substitute.For<ICollectionRepository>();
|
||||
|
||||
var sut = ArrangeSut(factory, policyRepository, collectionRepository);
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
|
||||
|
||||
// Act
|
||||
await sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
|
||||
@@ -199,7 +199,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
var collectionRepository = Substitute.For<ICollectionRepository>();
|
||||
|
||||
var sut = ArrangeSut(factory, policyRepository, collectionRepository);
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
|
||||
|
||||
// Act
|
||||
await sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
|
||||
@@ -238,7 +238,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
|
||||
.Returns(true);
|
||||
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, null, metadata);
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, metadata);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
|
||||
@@ -274,4 +274,176 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
return sut;
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ExecutePostUpsertSideEffectAsync_FeatureFlagDisabled_DoesNothing(
|
||||
[PolicyUpdate(PolicyType.OrganizationDataOwnership, false)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.OrganizationDataOwnership, false)] Policy postUpdatedPolicy,
|
||||
[Policy(PolicyType.OrganizationDataOwnership, false)] Policy previousPolicyState,
|
||||
SutProvider<OrganizationDataOwnershipPolicyValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
|
||||
.Returns(false);
|
||||
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<ICollectionRepository>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.UpsertDefaultCollectionsAsync(default, default, default);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ExecutePostUpsertSideEffectAsync_PolicyAlreadyEnabled_DoesNothing(
|
||||
[PolicyUpdate(PolicyType.OrganizationDataOwnership, true)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.OrganizationDataOwnership, true)] Policy postUpdatedPolicy,
|
||||
[Policy(PolicyType.OrganizationDataOwnership, true)] Policy previousPolicyState,
|
||||
SutProvider<OrganizationDataOwnershipPolicyValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
previousPolicyState.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
|
||||
.Returns(true);
|
||||
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<ICollectionRepository>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.UpsertDefaultCollectionsAsync(default, default, default);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ExecutePostUpsertSideEffectAsync_PolicyBeingDisabled_DoesNothing(
|
||||
[PolicyUpdate(PolicyType.OrganizationDataOwnership, false)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.OrganizationDataOwnership, false)] Policy postUpdatedPolicy,
|
||||
[Policy(PolicyType.OrganizationDataOwnership)] Policy previousPolicyState,
|
||||
SutProvider<OrganizationDataOwnershipPolicyValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
previousPolicyState.OrganizationId = policyUpdate.OrganizationId;
|
||||
postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
|
||||
.Returns(true);
|
||||
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<ICollectionRepository>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.UpsertDefaultCollectionsAsync(default, default, default);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ExecutePostUpsertSideEffectAsync_WhenNoUsersExist_DoNothing(
|
||||
[PolicyUpdate(PolicyType.OrganizationDataOwnership, true)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.OrganizationDataOwnership, true)] Policy postUpdatedPolicy,
|
||||
[Policy(PolicyType.OrganizationDataOwnership, false)] Policy previousPolicyState,
|
||||
OrganizationDataOwnershipPolicyRequirementFactory factory)
|
||||
{
|
||||
// Arrange
|
||||
postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
previousPolicyState.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var policyRepository = ArrangePolicyRepository([]);
|
||||
var collectionRepository = Substitute.For<ICollectionRepository>();
|
||||
|
||||
var sut = ArrangeSut(factory, policyRepository, collectionRepository);
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
|
||||
|
||||
// Act
|
||||
await sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
|
||||
|
||||
// Assert
|
||||
await collectionRepository
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.UpsertDefaultCollectionsAsync(
|
||||
default,
|
||||
default,
|
||||
default);
|
||||
|
||||
await policyRepository
|
||||
.Received(1)
|
||||
.GetPolicyDetailsByOrganizationIdAsync(
|
||||
policyUpdate.OrganizationId,
|
||||
PolicyType.OrganizationDataOwnership);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitMemberAutoData(nameof(ShouldUpsertDefaultCollectionsTestCases))]
|
||||
public async Task ExecutePostUpsertSideEffectAsync_WithRequirements_ShouldUpsertDefaultCollections(
|
||||
Policy postUpdatedPolicy,
|
||||
Policy? previousPolicyState,
|
||||
[PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate,
|
||||
[OrganizationPolicyDetails(PolicyType.OrganizationDataOwnership)] IEnumerable<OrganizationPolicyDetails> orgPolicyDetails,
|
||||
OrganizationDataOwnershipPolicyRequirementFactory factory)
|
||||
{
|
||||
// Arrange
|
||||
var orgPolicyDetailsList = orgPolicyDetails.ToList();
|
||||
foreach (var policyDetail in orgPolicyDetailsList)
|
||||
{
|
||||
policyDetail.OrganizationId = policyUpdate.OrganizationId;
|
||||
}
|
||||
|
||||
var policyRepository = ArrangePolicyRepository(orgPolicyDetailsList);
|
||||
var collectionRepository = Substitute.For<ICollectionRepository>();
|
||||
|
||||
var sut = ArrangeSut(factory, policyRepository, collectionRepository);
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
|
||||
|
||||
// Act
|
||||
await sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
|
||||
|
||||
// Assert
|
||||
await collectionRepository
|
||||
.Received(1)
|
||||
.UpsertDefaultCollectionsAsync(
|
||||
policyUpdate.OrganizationId,
|
||||
Arg.Is<IEnumerable<Guid>>(ids => ids.Count() == 3),
|
||||
_defaultUserCollectionName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitMemberAutoData(nameof(WhenDefaultCollectionsDoesNotExistTestCases))]
|
||||
public async Task ExecutePostUpsertSideEffectAsync_WhenDefaultCollectionNameIsInvalid_DoesNothing(
|
||||
IPolicyMetadataModel metadata,
|
||||
[PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.OrganizationDataOwnership, true)] Policy postUpdatedPolicy,
|
||||
[Policy(PolicyType.OrganizationDataOwnership, false)] Policy previousPolicyState,
|
||||
SutProvider<OrganizationDataOwnershipPolicyValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
previousPolicyState.OrganizationId = policyUpdate.OrganizationId;
|
||||
policyUpdate.Enabled = true;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
|
||||
.Returns(true);
|
||||
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, metadata);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<ICollectionRepository>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.UpsertDefaultCollectionsAsync(default, default, default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,4 +72,66 @@ public class RequireSsoPolicyValidatorTests
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, policy);
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_WithSavePolicyModel_DisablingPolicy_KeyConnectorEnabled_ValidationError(
|
||||
[PolicyUpdate(PolicyType.RequireSso, false)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.RequireSso)] Policy policy,
|
||||
SutProvider<RequireSsoPolicyValidator> sutProvider)
|
||||
{
|
||||
policy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var ssoConfig = new SsoConfig { Enabled = true };
|
||||
ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.KeyConnector });
|
||||
|
||||
sutProvider.GetDependency<ISsoConfigRepository>()
|
||||
.GetByOrganizationIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns(ssoConfig);
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy);
|
||||
Assert.Contains("Key Connector is enabled", result, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_WithSavePolicyModel_DisablingPolicy_TdeEnabled_ValidationError(
|
||||
[PolicyUpdate(PolicyType.RequireSso, false)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.RequireSso)] Policy policy,
|
||||
SutProvider<RequireSsoPolicyValidator> sutProvider)
|
||||
{
|
||||
policy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var ssoConfig = new SsoConfig { Enabled = true };
|
||||
ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption });
|
||||
|
||||
sutProvider.GetDependency<ISsoConfigRepository>()
|
||||
.GetByOrganizationIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns(ssoConfig);
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy);
|
||||
Assert.Contains("Trusted device encryption is on", result, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_WithSavePolicyModel_DisablingPolicy_DecryptionOptionsNotEnabled_Success(
|
||||
[PolicyUpdate(PolicyType.RequireSso, false)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.RequireSso)] Policy policy,
|
||||
SutProvider<RequireSsoPolicyValidator> sutProvider)
|
||||
{
|
||||
policy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var ssoConfig = new SsoConfig { Enabled = false };
|
||||
|
||||
sutProvider.GetDependency<ISsoConfigRepository>()
|
||||
.GetByOrganizationIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns(ssoConfig);
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy);
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,4 +68,59 @@ public class ResetPasswordPolicyValidatorTests
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, policy);
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(true, false)]
|
||||
[BitAutoData(false, true)]
|
||||
[BitAutoData(false, false)]
|
||||
public async Task ValidateAsync_WithSavePolicyModel_DisablingPolicy_TdeEnabled_ValidationError(
|
||||
bool policyEnabled,
|
||||
bool autoEnrollEnabled,
|
||||
[PolicyUpdate(PolicyType.ResetPassword)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.ResetPassword)] Policy policy,
|
||||
SutProvider<ResetPasswordPolicyValidator> sutProvider)
|
||||
{
|
||||
policyUpdate.Enabled = policyEnabled;
|
||||
policyUpdate.SetDataModel(new ResetPasswordDataModel
|
||||
{
|
||||
AutoEnrollEnabled = autoEnrollEnabled
|
||||
});
|
||||
policy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var ssoConfig = new SsoConfig { Enabled = true };
|
||||
ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption });
|
||||
|
||||
sutProvider.GetDependency<ISsoConfigRepository>()
|
||||
.GetByOrganizationIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns(ssoConfig);
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy);
|
||||
Assert.Contains("Trusted device encryption is on and requires this policy.", result, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_WithSavePolicyModel_DisablingPolicy_TdeNotEnabled_Success(
|
||||
[PolicyUpdate(PolicyType.ResetPassword, false)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.ResetPassword)] Policy policy,
|
||||
SutProvider<ResetPasswordPolicyValidator> sutProvider)
|
||||
{
|
||||
policyUpdate.SetDataModel(new ResetPasswordDataModel
|
||||
{
|
||||
AutoEnrollEnabled = false
|
||||
});
|
||||
policy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var ssoConfig = new SsoConfig { Enabled = false };
|
||||
|
||||
sutProvider.GetDependency<ISsoConfigRepository>()
|
||||
.GetByOrganizationIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns(ssoConfig);
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy);
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
@@ -145,4 +146,135 @@ public class SingleOrgPolicyValidatorTests
|
||||
.Received(1)
|
||||
.SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), nonCompliantUser.Email);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_WithSavePolicyModel_DisablingPolicy_KeyConnectorEnabled_ValidationError(
|
||||
[PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy policy,
|
||||
SutProvider<SingleOrgPolicyValidator> sutProvider)
|
||||
{
|
||||
policy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var ssoConfig = new SsoConfig { Enabled = true };
|
||||
ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.KeyConnector });
|
||||
|
||||
sutProvider.GetDependency<ISsoConfigRepository>()
|
||||
.GetByOrganizationIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns(ssoConfig);
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy);
|
||||
Assert.Contains("Key Connector is enabled", result, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_WithSavePolicyModel_DisablingPolicy_KeyConnectorNotEnabled_Success(
|
||||
[PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy policy,
|
||||
SutProvider<SingleOrgPolicyValidator> sutProvider)
|
||||
{
|
||||
policy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var ssoConfig = new SsoConfig { Enabled = false };
|
||||
|
||||
sutProvider.GetDependency<ISsoConfigRepository>()
|
||||
.GetByOrganizationIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns(ssoConfig);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationHasVerifiedDomainsQuery>()
|
||||
.HasVerifiedDomainsAsync(policyUpdate.OrganizationId)
|
||||
.Returns(false);
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy);
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ExecutePreUpsertSideEffectAsync_RevokesNonCompliantUsers(
|
||||
[PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg, false)] Policy policy,
|
||||
Guid savingUserId,
|
||||
Guid nonCompliantUserId,
|
||||
Organization organization,
|
||||
SutProvider<SingleOrgPolicyValidator> sutProvider)
|
||||
{
|
||||
policy.OrganizationId = organization.Id = policyUpdate.OrganizationId;
|
||||
|
||||
var compliantUser1 = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = organization.Id,
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
UserId = new Guid(),
|
||||
Email = "user1@example.com"
|
||||
};
|
||||
|
||||
var compliantUser2 = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = organization.Id,
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
UserId = new Guid(),
|
||||
Email = "user2@example.com"
|
||||
};
|
||||
|
||||
var nonCompliantUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = organization.Id,
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
UserId = nonCompliantUserId,
|
||||
Email = "user3@example.com"
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([compliantUser1, compliantUser2, nonCompliantUser]);
|
||||
|
||||
var otherOrganizationUser = new OrganizationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = new Guid(),
|
||||
UserId = nonCompliantUserId,
|
||||
Status = OrganizationUserStatusType.Confirmed
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(nonCompliantUserId)))
|
||||
.Returns([otherOrganizationUser]);
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(savingUserId);
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(policyUpdate.OrganizationId).Returns(organization);
|
||||
|
||||
sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
|
||||
.RevokeNonCompliantOrganizationUsersAsync(Arg.Any<RevokeOrganizationUsersRequest>())
|
||||
.Returns(new CommandResult());
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, policy);
|
||||
|
||||
await sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
|
||||
.Received(1)
|
||||
.RevokeNonCompliantOrganizationUsersAsync(
|
||||
Arg.Is<RevokeOrganizationUsersRequest>(r =>
|
||||
r.OrganizationId == organization.Id &&
|
||||
r.OrganizationUsers.Count() == 1 &&
|
||||
r.OrganizationUsers.First().Id == nonCompliantUser.Id));
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.DidNotReceive()
|
||||
.SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), compliantUser1.Email);
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.DidNotReceive()
|
||||
.SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), compliantUser2.Email);
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.Received(1)
|
||||
.SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), nonCompliantUser.Email);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,4 +136,124 @@ public class TwoFactorAuthenticationPolicyValidatorTests
|
||||
.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(),
|
||||
compliantUser.Email);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ExecutePreUpsertSideEffectAsync_GivenNonCompliantUsersWithoutMasterPassword_Throws(
|
||||
Organization organization,
|
||||
[PolicyUpdate(PolicyType.TwoFactorAuthentication)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.TwoFactorAuthentication, false)] Policy policy,
|
||||
SutProvider<TwoFactorAuthenticationPolicyValidator> sutProvider)
|
||||
{
|
||||
policy.OrganizationId = organization.Id = policyUpdate.OrganizationId;
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
|
||||
var orgUserDetailUserWithout2Fa = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
Type = OrganizationUserType.User,
|
||||
Email = "user3@test.com",
|
||||
Name = "TEST",
|
||||
UserId = Guid.NewGuid(),
|
||||
HasMasterPassword = false
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([orgUserDetailUserWithout2Fa]);
|
||||
|
||||
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
|
||||
.TwoFactorIsEnabledAsync(Arg.Any<IEnumerable<OrganizationUserUserDetails>>())
|
||||
.Returns(new List<(OrganizationUserUserDetails user, bool hasTwoFactor)>()
|
||||
{
|
||||
(orgUserDetailUserWithout2Fa, false),
|
||||
});
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
|
||||
sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, policy));
|
||||
|
||||
Assert.Equal(TwoFactorAuthenticationPolicyValidator.NonCompliantMembersWillLoseAccessMessage, exception.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ExecutePreUpsertSideEffectAsync_RevokesOnlyNonCompliantUsers(
|
||||
Organization organization,
|
||||
[PolicyUpdate(PolicyType.TwoFactorAuthentication)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.TwoFactorAuthentication, false)] Policy policy,
|
||||
SutProvider<TwoFactorAuthenticationPolicyValidator> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
policy.OrganizationId = policyUpdate.OrganizationId;
|
||||
organization.Id = policyUpdate.OrganizationId;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
|
||||
|
||||
var nonCompliantUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
Type = OrganizationUserType.User,
|
||||
Email = "user3@test.com",
|
||||
Name = "TEST",
|
||||
UserId = Guid.NewGuid(),
|
||||
HasMasterPassword = true
|
||||
};
|
||||
|
||||
var compliantUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
Type = OrganizationUserType.User,
|
||||
Email = "user4@test.com",
|
||||
Name = "TEST",
|
||||
UserId = Guid.NewGuid(),
|
||||
HasMasterPassword = true
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([nonCompliantUser, compliantUser]);
|
||||
|
||||
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
|
||||
.TwoFactorIsEnabledAsync(Arg.Any<IEnumerable<OrganizationUserUserDetails>>())
|
||||
.Returns(new List<(OrganizationUserUserDetails user, bool hasTwoFactor)>()
|
||||
{
|
||||
(nonCompliantUser, false),
|
||||
(compliantUser, true)
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
|
||||
.RevokeNonCompliantOrganizationUsersAsync(Arg.Any<RevokeOrganizationUsersRequest>())
|
||||
.Returns(new CommandResult());
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, policy);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
|
||||
.Received(1)
|
||||
.RevokeNonCompliantOrganizationUsersAsync(Arg.Any<RevokeOrganizationUsersRequest>());
|
||||
|
||||
await sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
|
||||
.Received(1)
|
||||
.RevokeNonCompliantOrganizationUsersAsync(Arg.Is<RevokeOrganizationUsersRequest>(req =>
|
||||
req.OrganizationId == policyUpdate.OrganizationId &&
|
||||
req.OrganizationUsers.SequenceEqual(new[] { nonCompliantUser })
|
||||
));
|
||||
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.Received(1)
|
||||
.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(),
|
||||
nonCompliantUser.Email);
|
||||
|
||||
// Did not send out an email for compliantUser
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.Received(0)
|
||||
.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(),
|
||||
compliantUser.Email);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
|
||||
|
||||
public class UriMatchDefaultPolicyValidatorTests
|
||||
{
|
||||
private readonly UriMatchDefaultPolicyValidator _validator = new();
|
||||
|
||||
[Fact]
|
||||
// Test that the Type property returns the correct PolicyType for this validator
|
||||
public void Type_ReturnsUriMatchDefaults()
|
||||
{
|
||||
Assert.Equal(PolicyType.UriMatchDefaults, _validator.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
// Test that the RequiredPolicies property returns exactly one policy (SingleOrg) as a prerequisite
|
||||
// for enabling the UriMatchDefaults policy, ensuring proper policy dependency enforcement
|
||||
public void RequiredPolicies_ReturnsSingleOrgPolicy()
|
||||
{
|
||||
var requiredPolicies = _validator.RequiredPolicies.ToList();
|
||||
|
||||
Assert.Single(requiredPolicies);
|
||||
Assert.Contains(PolicyType.SingleOrg, requiredPolicies);
|
||||
}
|
||||
}
|
||||
@@ -288,7 +288,7 @@ public class SavePolicyCommandTests
|
||||
{
|
||||
// Arrange
|
||||
var sutProvider = SutProviderFactory();
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
@@ -332,7 +332,7 @@ public class SavePolicyCommandTests
|
||||
|
||||
|
||||
var sutProvider = SutProviderFactory();
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type)
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data.Organizations;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Test.AdminConsole.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using NSubstitute;
|
||||
using OneOf.Types;
|
||||
using Xunit;
|
||||
using EventType = Bit.Core.Enums.EventType;
|
||||
|
||||
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies;
|
||||
|
||||
public class VNextSavePolicyCommandTests
|
||||
{
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveAsync_NewPolicy_Success([PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate)
|
||||
{
|
||||
// Arrange
|
||||
var fakePolicyValidationEvent = new FakeSingleOrgValidationEvent();
|
||||
fakePolicyValidationEvent.ValidateAsyncMock(Arg.Any<SavePolicyModel>(), Arg.Any<Policy>()).Returns("");
|
||||
var sutProvider = SutProviderFactory([
|
||||
new FakeSingleOrgDependencyEvent(),
|
||||
fakePolicyValidationEvent
|
||||
]);
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
var newPolicy = new Policy
|
||||
{
|
||||
Type = policyUpdate.Type,
|
||||
OrganizationId = policyUpdate.OrganizationId,
|
||||
Enabled = false
|
||||
};
|
||||
|
||||
ArrangeOrganization(sutProvider, policyUpdate);
|
||||
sutProvider.GetDependency<IPolicyRepository>().GetManyByOrganizationIdAsync(policyUpdate.OrganizationId).Returns([newPolicy]);
|
||||
|
||||
var creationDate = sutProvider.GetDependency<FakeTimeProvider>().Start;
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SaveAsync(savePolicyModel);
|
||||
|
||||
// Assert
|
||||
await fakePolicyValidationEvent.ValidateAsyncMock
|
||||
.Received(1)
|
||||
.Invoke(Arg.Any<SavePolicyModel>(), Arg.Any<Policy>());
|
||||
|
||||
await AssertPolicySavedAsync(sutProvider, policyUpdate);
|
||||
|
||||
await sutProvider.GetDependency<IPolicyRepository>()
|
||||
.Received(1)
|
||||
.UpsertAsync(Arg.Is<Policy>(p =>
|
||||
p.CreationDate == creationDate &&
|
||||
p.RevisionDate == creationDate));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveAsync_ExistingPolicy_Success(
|
||||
[PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg, false)] Policy currentPolicy)
|
||||
{
|
||||
// Arrange
|
||||
var fakePolicyValidationEvent = new FakeSingleOrgValidationEvent();
|
||||
fakePolicyValidationEvent.ValidateAsyncMock(Arg.Any<SavePolicyModel>(), Arg.Any<Policy>()).Returns("");
|
||||
var sutProvider = SutProviderFactory([
|
||||
new FakeSingleOrgDependencyEvent(),
|
||||
fakePolicyValidationEvent
|
||||
]);
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type)
|
||||
.Returns(currentPolicy);
|
||||
|
||||
ArrangeOrganization(sutProvider, policyUpdate);
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns([currentPolicy]);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SaveAsync(savePolicyModel);
|
||||
|
||||
// Assert
|
||||
await fakePolicyValidationEvent.ValidateAsyncMock
|
||||
.Received(1)
|
||||
.Invoke(Arg.Any<SavePolicyModel>(), currentPolicy);
|
||||
|
||||
await AssertPolicySavedAsync(sutProvider, policyUpdate);
|
||||
|
||||
|
||||
var revisionDate = sutProvider.GetDependency<FakeTimeProvider>().Start;
|
||||
|
||||
await sutProvider.GetDependency<IPolicyRepository>()
|
||||
.Received(1)
|
||||
.UpsertAsync(Arg.Is<Policy>(p =>
|
||||
p.Id == currentPolicy.Id &&
|
||||
p.OrganizationId == currentPolicy.OrganizationId &&
|
||||
p.Type == currentPolicy.Type &&
|
||||
p.CreationDate == currentPolicy.CreationDate &&
|
||||
p.RevisionDate == revisionDate));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveAsync_OrganizationDoesNotExist_ThrowsBadRequest([PolicyUpdate(PolicyType.ActivateAutofill)] PolicyUpdate policyUpdate)
|
||||
{
|
||||
// Arrange
|
||||
var sutProvider = SutProviderFactory();
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
sutProvider.GetDependency<IApplicationCacheService>()
|
||||
.GetOrganizationAbilityAsync(policyUpdate.OrganizationId)
|
||||
.Returns(Task.FromResult<OrganizationAbility?>(null));
|
||||
|
||||
// Act
|
||||
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.SaveAsync(savePolicyModel));
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Organization not found", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
|
||||
await AssertPolicyNotSavedAsync(sutProvider);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveAsync_OrganizationCannotUsePolicies_ThrowsBadRequest([PolicyUpdate(PolicyType.ActivateAutofill)] PolicyUpdate policyUpdate)
|
||||
{
|
||||
// Arrange
|
||||
var sutProvider = SutProviderFactory();
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
sutProvider.GetDependency<IApplicationCacheService>()
|
||||
.GetOrganizationAbilityAsync(policyUpdate.OrganizationId)
|
||||
.Returns(new OrganizationAbility
|
||||
{
|
||||
Id = policyUpdate.OrganizationId,
|
||||
UsePolicies = false
|
||||
});
|
||||
|
||||
// Act
|
||||
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.SaveAsync(savePolicyModel));
|
||||
|
||||
// Assert
|
||||
Assert.Contains("cannot use policies", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
|
||||
await AssertPolicyNotSavedAsync(sutProvider);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveAsync_RequiredPolicyIsNull_Throws(
|
||||
[PolicyUpdate(PolicyType.RequireSso)] PolicyUpdate policyUpdate)
|
||||
{
|
||||
// Arrange
|
||||
var sutProvider = SutProviderFactory(
|
||||
[
|
||||
new FakeRequireSsoDependencyEvent(),
|
||||
new FakeSingleOrgDependencyEvent()
|
||||
]);
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
var requireSsoPolicy = new Policy
|
||||
{
|
||||
Type = PolicyType.RequireSso,
|
||||
OrganizationId = policyUpdate.OrganizationId,
|
||||
Enabled = false
|
||||
};
|
||||
|
||||
ArrangeOrganization(sutProvider, policyUpdate);
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns([requireSsoPolicy]);
|
||||
|
||||
// Act
|
||||
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.SaveAsync(savePolicyModel));
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Turn on the Single organization policy because it is required for the Require single sign-on authentication policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
|
||||
await AssertPolicyNotSavedAsync(sutProvider);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveAsync_RequiredPolicyNotEnabled_Throws(
|
||||
[PolicyUpdate(PolicyType.RequireSso)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg, false)] Policy singleOrgPolicy)
|
||||
{
|
||||
// Arrange
|
||||
var sutProvider = SutProviderFactory(
|
||||
[
|
||||
new FakeRequireSsoDependencyEvent(),
|
||||
new FakeSingleOrgDependencyEvent()
|
||||
]);
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
var requireSsoPolicy = new Policy
|
||||
{
|
||||
Type = PolicyType.RequireSso,
|
||||
OrganizationId = policyUpdate.OrganizationId,
|
||||
Enabled = false
|
||||
};
|
||||
|
||||
ArrangeOrganization(sutProvider, policyUpdate);
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns([singleOrgPolicy, requireSsoPolicy]);
|
||||
|
||||
// Act
|
||||
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.SaveAsync(savePolicyModel));
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Turn on the Single organization policy because it is required for the Require single sign-on authentication policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
|
||||
await AssertPolicyNotSavedAsync(sutProvider);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveAsync_RequiredPolicyEnabled_Success(
|
||||
[PolicyUpdate(PolicyType.RequireSso)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy)
|
||||
{
|
||||
// Arrange
|
||||
var sutProvider = SutProviderFactory(
|
||||
[
|
||||
new FakeRequireSsoDependencyEvent(),
|
||||
new FakeSingleOrgDependencyEvent()
|
||||
]);
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
var requireSsoPolicy = new Policy
|
||||
{
|
||||
Type = PolicyType.RequireSso,
|
||||
OrganizationId = policyUpdate.OrganizationId,
|
||||
Enabled = false
|
||||
};
|
||||
|
||||
ArrangeOrganization(sutProvider, policyUpdate);
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns([singleOrgPolicy, requireSsoPolicy]);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SaveAsync(savePolicyModel);
|
||||
|
||||
// Assert
|
||||
await AssertPolicySavedAsync(sutProvider, policyUpdate);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveAsync_DependentPolicyIsEnabled_Throws(
|
||||
[PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy currentPolicy,
|
||||
[Policy(PolicyType.RequireSso)] Policy requireSsoPolicy)
|
||||
{
|
||||
// Arrange
|
||||
var sutProvider = SutProviderFactory(
|
||||
[
|
||||
new FakeRequireSsoDependencyEvent(),
|
||||
new FakeSingleOrgDependencyEvent()
|
||||
]);
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
ArrangeOrganization(sutProvider, policyUpdate);
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns([currentPolicy, requireSsoPolicy]);
|
||||
|
||||
// Act
|
||||
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.SaveAsync(savePolicyModel));
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Turn off the Require single sign-on authentication policy because it requires the Single organization policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
|
||||
await AssertPolicyNotSavedAsync(sutProvider);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveAsync_MultipleDependentPoliciesAreEnabled_Throws(
|
||||
[PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy currentPolicy,
|
||||
[Policy(PolicyType.RequireSso)] Policy requireSsoPolicy,
|
||||
[Policy(PolicyType.MaximumVaultTimeout)] Policy vaultTimeoutPolicy)
|
||||
{
|
||||
// Arrange
|
||||
var sutProvider = SutProviderFactory(
|
||||
[
|
||||
new FakeRequireSsoDependencyEvent(),
|
||||
new FakeSingleOrgDependencyEvent(),
|
||||
new FakeVaultTimeoutDependencyEvent()
|
||||
]);
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
ArrangeOrganization(sutProvider, policyUpdate);
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns([currentPolicy, requireSsoPolicy, vaultTimeoutPolicy]);
|
||||
|
||||
// Act
|
||||
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.SaveAsync(savePolicyModel));
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Turn off all of the policies that require the Single organization policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
|
||||
await AssertPolicyNotSavedAsync(sutProvider);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveAsync_DependentPolicyNotEnabled_Success(
|
||||
[PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy currentPolicy,
|
||||
[Policy(PolicyType.RequireSso, false)] Policy requireSsoPolicy)
|
||||
{
|
||||
// Arrange
|
||||
var sutProvider = SutProviderFactory(
|
||||
[
|
||||
new FakeRequireSsoDependencyEvent(),
|
||||
new FakeSingleOrgDependencyEvent()
|
||||
]);
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
ArrangeOrganization(sutProvider, policyUpdate);
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns([currentPolicy, requireSsoPolicy]);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SaveAsync(savePolicyModel);
|
||||
|
||||
// Assert
|
||||
await AssertPolicySavedAsync(sutProvider, policyUpdate);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveAsync_ThrowsOnValidationError([PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate)
|
||||
{
|
||||
// Arrange
|
||||
var fakePolicyValidationEvent = new FakeSingleOrgValidationEvent();
|
||||
fakePolicyValidationEvent.ValidateAsyncMock(Arg.Any<SavePolicyModel>(), Arg.Any<Policy>()).Returns("Validation error!");
|
||||
var sutProvider = SutProviderFactory([
|
||||
new FakeSingleOrgDependencyEvent(),
|
||||
fakePolicyValidationEvent
|
||||
]);
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
var singleOrgPolicy = new Policy
|
||||
{
|
||||
Type = PolicyType.SingleOrg,
|
||||
OrganizationId = policyUpdate.OrganizationId,
|
||||
Enabled = false
|
||||
};
|
||||
|
||||
ArrangeOrganization(sutProvider, policyUpdate);
|
||||
sutProvider.GetDependency<IPolicyRepository>().GetManyByOrganizationIdAsync(policyUpdate.OrganizationId).Returns([singleOrgPolicy]);
|
||||
|
||||
// Act
|
||||
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.SaveAsync(savePolicyModel));
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Validation error!", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
|
||||
await AssertPolicyNotSavedAsync(sutProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a new SutProvider with the PolicyUpdateEvents registered in the Sut.
|
||||
/// </summary>
|
||||
private static SutProvider<VNextSavePolicyCommand> SutProviderFactory(
|
||||
IEnumerable<IPolicyUpdateEvent>? policyUpdateEvents = null)
|
||||
{
|
||||
var policyEventHandlerFactory = Substitute.For<IPolicyEventHandlerFactory>();
|
||||
var handlers = policyUpdateEvents ?? [];
|
||||
|
||||
// Setup factory to return handlers based on type
|
||||
policyEventHandlerFactory.GetHandler<IEnforceDependentPoliciesEvent>(Arg.Any<PolicyType>())
|
||||
.Returns(callInfo =>
|
||||
{
|
||||
var policyType = callInfo.Arg<PolicyType>();
|
||||
var handler = handlers.OfType<IEnforceDependentPoliciesEvent>().FirstOrDefault(e => e.Type == policyType);
|
||||
return handler != null ? OneOf.OneOf<IEnforceDependentPoliciesEvent, None>.FromT0(handler) : OneOf.OneOf<IEnforceDependentPoliciesEvent, None>.FromT1(new None());
|
||||
});
|
||||
|
||||
policyEventHandlerFactory.GetHandler<IPolicyValidationEvent>(Arg.Any<PolicyType>())
|
||||
.Returns(callInfo =>
|
||||
{
|
||||
var policyType = callInfo.Arg<PolicyType>();
|
||||
var handler = handlers.OfType<IPolicyValidationEvent>().FirstOrDefault(e => e.Type == policyType);
|
||||
return handler != null ? OneOf.OneOf<IPolicyValidationEvent, None>.FromT0(handler) : OneOf.OneOf<IPolicyValidationEvent, None>.FromT1(new None());
|
||||
});
|
||||
|
||||
policyEventHandlerFactory.GetHandler<IOnPolicyPreUpdateEvent>(Arg.Any<PolicyType>())
|
||||
.Returns(new None());
|
||||
|
||||
policyEventHandlerFactory.GetHandler<IOnPolicyPostUpdateEvent>(Arg.Any<PolicyType>())
|
||||
.Returns(new None());
|
||||
|
||||
return new SutProvider<VNextSavePolicyCommand>()
|
||||
.WithFakeTimeProvider()
|
||||
.SetDependency(handlers)
|
||||
.SetDependency(policyEventHandlerFactory)
|
||||
.Create();
|
||||
}
|
||||
|
||||
private static void ArrangeOrganization(SutProvider<VNextSavePolicyCommand> sutProvider, PolicyUpdate policyUpdate)
|
||||
{
|
||||
sutProvider.GetDependency<IApplicationCacheService>()
|
||||
.GetOrganizationAbilityAsync(policyUpdate.OrganizationId)
|
||||
.Returns(new OrganizationAbility
|
||||
{
|
||||
Id = policyUpdate.OrganizationId,
|
||||
UsePolicies = true
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task AssertPolicyNotSavedAsync(SutProvider<VNextSavePolicyCommand> sutProvider)
|
||||
{
|
||||
await sutProvider.GetDependency<IPolicyRepository>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.UpsertAsync(default!);
|
||||
|
||||
await sutProvider.GetDependency<IEventService>()
|
||||
.DidNotReceiveWithAnyArgs()
|
||||
.LogPolicyEventAsync(default, default);
|
||||
}
|
||||
|
||||
private static async Task AssertPolicySavedAsync(SutProvider<VNextSavePolicyCommand> sutProvider, PolicyUpdate policyUpdate)
|
||||
{
|
||||
await sutProvider.GetDependency<IPolicyRepository>().Received(1).UpsertAsync(ExpectedPolicy());
|
||||
|
||||
await sutProvider.GetDependency<IEventService>().Received(1)
|
||||
.LogPolicyEventAsync(ExpectedPolicy(), EventType.Policy_Updated);
|
||||
|
||||
return;
|
||||
|
||||
Policy ExpectedPolicy() => Arg.Is<Policy>(
|
||||
p =>
|
||||
p.Type == policyUpdate.Type
|
||||
&& p.OrganizationId == policyUpdate.OrganizationId
|
||||
&& p.Enabled == policyUpdate.Enabled
|
||||
&& p.Data == policyUpdate.Data);
|
||||
}
|
||||
}
|
||||
@@ -22,18 +22,34 @@ public class EventIntegrationEventWriteServiceTests
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateAsync_EventPublishedToEventQueue(EventMessage eventMessage)
|
||||
{
|
||||
var expected = JsonSerializer.Serialize(eventMessage);
|
||||
await Subject.CreateAsync(eventMessage);
|
||||
await _eventIntegrationPublisher.Received(1).PublishEventAsync(
|
||||
Arg.Is<string>(body => AssertJsonStringsMatch(eventMessage, body)));
|
||||
body: Arg.Is<string>(body => AssertJsonStringsMatch(eventMessage, body)),
|
||||
organizationId: Arg.Is<string>(orgId => eventMessage.OrganizationId.ToString().Equals(orgId)));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateManyAsync_EventsPublishedToEventQueue(IEnumerable<EventMessage> eventMessages)
|
||||
{
|
||||
var eventMessage = eventMessages.First();
|
||||
await Subject.CreateManyAsync(eventMessages);
|
||||
await _eventIntegrationPublisher.Received(1).PublishEventAsync(
|
||||
Arg.Is<string>(body => AssertJsonStringsMatch(eventMessages, body)));
|
||||
body: Arg.Is<string>(body => AssertJsonStringsMatch(eventMessages, body)),
|
||||
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)
|
||||
|
||||
@@ -23,6 +23,7 @@ public class EventIntegrationHandlerTests
|
||||
private const string _templateWithOrganization = "Org: #OrganizationName#";
|
||||
private const string _templateWithUser = "#UserName#, #UserEmail#";
|
||||
private const string _templateWithActingUser = "#ActingUserName#, #ActingUserEmail#";
|
||||
private static readonly Guid _organizationId = Guid.NewGuid();
|
||||
private static readonly Uri _uri = new Uri("https://localhost");
|
||||
private static readonly Uri _uri2 = new Uri("https://example.com");
|
||||
private readonly IEventIntegrationPublisher _eventIntegrationPublisher = Substitute.For<IEventIntegrationPublisher>();
|
||||
@@ -50,6 +51,7 @@ public class EventIntegrationHandlerTests
|
||||
{
|
||||
IntegrationType = IntegrationType.Webhook,
|
||||
MessageId = "TestMessageId",
|
||||
OrganizationId = _organizationId.ToString(),
|
||||
Configuration = new WebhookIntegrationConfigurationDetails(_uri),
|
||||
RenderedTemplate = template,
|
||||
RetryCount = 0,
|
||||
@@ -118,10 +120,21 @@ 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)
|
||||
{
|
||||
var sutProvider = GetSutProvider(OneConfiguration(_templateBase));
|
||||
eventMessage.OrganizationId = _organizationId;
|
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage);
|
||||
|
||||
@@ -140,6 +153,7 @@ public class EventIntegrationHandlerTests
|
||||
public async Task HandleEventAsync_BaseTemplateTwoConfigurations_PublishesIntegrationMessages(EventMessage eventMessage)
|
||||
{
|
||||
var sutProvider = GetSutProvider(TwoConfigurations(_templateBase));
|
||||
eventMessage.OrganizationId = _organizationId;
|
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage);
|
||||
|
||||
@@ -164,6 +178,7 @@ public class EventIntegrationHandlerTests
|
||||
var user = Substitute.For<User>();
|
||||
user.Email = "test@example.com";
|
||||
user.Name = "Test";
|
||||
eventMessage.OrganizationId = _organizationId;
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(Arg.Any<Guid>()).Returns(user);
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage);
|
||||
@@ -183,6 +198,7 @@ public class EventIntegrationHandlerTests
|
||||
var sutProvider = GetSutProvider(OneConfiguration(_templateWithOrganization));
|
||||
var organization = Substitute.For<Organization>();
|
||||
organization.Name = "Test";
|
||||
eventMessage.OrganizationId = _organizationId;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(Arg.Any<Guid>()).Returns(organization);
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage);
|
||||
@@ -205,6 +221,7 @@ public class EventIntegrationHandlerTests
|
||||
var user = Substitute.For<User>();
|
||||
user.Email = "test@example.com";
|
||||
user.Name = "Test";
|
||||
eventMessage.OrganizationId = _organizationId;
|
||||
|
||||
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(Arg.Any<Guid>()).Returns(user);
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage);
|
||||
@@ -235,6 +252,7 @@ public class EventIntegrationHandlerTests
|
||||
var sutProvider = GetSutProvider(ValidFilterConfiguration());
|
||||
sutProvider.GetDependency<IIntegrationFilterService>().EvaluateFilterGroup(
|
||||
Arg.Any<IntegrationFilterGroup>(), Arg.Any<EventMessage>()).Returns(true);
|
||||
eventMessage.OrganizationId = _organizationId;
|
||||
|
||||
await sutProvider.Sut.HandleEventAsync(eventMessage);
|
||||
|
||||
@@ -284,7 +302,7 @@ public class EventIntegrationHandlerTests
|
||||
$"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}"
|
||||
);
|
||||
await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is(
|
||||
AssertHelper.AssertPropertyEqual(expectedMessage, new[] { "MessageId" })));
|
||||
AssertHelper.AssertPropertyEqual(expectedMessage, new[] { "MessageId", "OrganizationId" })));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,12 +319,12 @@ public class EventIntegrationHandlerTests
|
||||
var expectedMessage = EventIntegrationHandlerTests.expectedMessage(
|
||||
$"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}"
|
||||
);
|
||||
await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is(
|
||||
AssertHelper.AssertPropertyEqual(expectedMessage, new[] { "MessageId" })));
|
||||
await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is(AssertHelper.AssertPropertyEqual(
|
||||
expectedMessage, new[] { "MessageId", "OrganizationId" })));
|
||||
|
||||
expectedMessage.Configuration = new WebhookIntegrationConfigurationDetails(_uri2);
|
||||
await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is(
|
||||
AssertHelper.AssertPropertyEqual(expectedMessage, new[] { "MessageId" })));
|
||||
await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is(AssertHelper.AssertPropertyEqual(
|
||||
expectedMessage, new[] { "MessageId", "OrganizationId" })));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ public class IntegrationHandlerTests
|
||||
{
|
||||
Configuration = new WebhookIntegrationConfigurationDetails(new Uri("https://localhost"), "Bearer", "AUTH-TOKEN"),
|
||||
MessageId = "TestMessageId",
|
||||
OrganizationId = "TestOrganizationId",
|
||||
IntegrationType = IntegrationType.Webhook,
|
||||
RenderedTemplate = "Template",
|
||||
DelayUntilDate = null,
|
||||
@@ -25,6 +26,8 @@ public class IntegrationHandlerTests
|
||||
var result = await sut.HandleAsync(expected.ToJson());
|
||||
var typedResult = Assert.IsType<IntegrationMessage<WebhookIntegrationConfigurationDetails>>(result.Message);
|
||||
|
||||
Assert.Equal(expected.MessageId, typedResult.MessageId);
|
||||
Assert.Equal(expected.OrganizationId, typedResult.OrganizationId);
|
||||
Assert.Equal(expected.Configuration, typedResult.Configuration);
|
||||
Assert.Equal(expected.RenderedTemplate, typedResult.RenderedTemplate);
|
||||
Assert.Equal(expected.IntegrationType, typedResult.IntegrationType);
|
||||
|
||||
@@ -5,17 +5,6 @@ namespace Bit.Core.Test.Services;
|
||||
|
||||
public class IntegrationTypeTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToRoutingKey_Slack_Succeeds()
|
||||
{
|
||||
Assert.Equal("slack", IntegrationType.Slack.ToRoutingKey());
|
||||
}
|
||||
[Fact]
|
||||
public void ToRoutingKey_Webhook_Succeeds()
|
||||
{
|
||||
Assert.Equal("webhook", IntegrationType.Webhook.ToRoutingKey());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToRoutingKey_CloudBillingSync_ThrowsException()
|
||||
{
|
||||
@@ -27,4 +16,34 @@ public class IntegrationTypeTests
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => IntegrationType.Scim.ToRoutingKey());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToRoutingKey_Slack_Succeeds()
|
||||
{
|
||||
Assert.Equal("slack", IntegrationType.Slack.ToRoutingKey());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToRoutingKey_Webhook_Succeeds()
|
||||
{
|
||||
Assert.Equal("webhook", IntegrationType.Webhook.ToRoutingKey());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToRoutingKey_Hec_Succeeds()
|
||||
{
|
||||
Assert.Equal("hec", IntegrationType.Hec.ToRoutingKey());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToRoutingKey_Datadog_Succeeds()
|
||||
{
|
||||
Assert.Equal("datadog", IntegrationType.Datadog.ToRoutingKey());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToRoutingKey_Teams_Succeeds()
|
||||
{
|
||||
Assert.Equal("teams", IntegrationType.Teams.ToRoutingKey());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Web;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
@@ -145,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()
|
||||
{
|
||||
@@ -234,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()
|
||||
{
|
||||
@@ -243,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}")
|
||||
@@ -255,16 +303,40 @@ 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()
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
var clientId = sutProvider.GetDependency<GlobalSettings>().Slack.ClientId;
|
||||
var scopes = sutProvider.GetDependency<GlobalSettings>().Slack.Scopes;
|
||||
var redirectUrl = "https://example.com/callback";
|
||||
var expectedUrl = $"https://slack.com/oauth/v2/authorize?client_id={clientId}&scope={scopes}&redirect_uri={redirectUrl}";
|
||||
var result = sutProvider.Sut.GetRedirectUrl(redirectUrl);
|
||||
Assert.Equal(expectedUrl, result);
|
||||
var callbackUrl = "https://example.com/callback";
|
||||
var state = Guid.NewGuid().ToString();
|
||||
var result = sutProvider.Sut.GetRedirectUrl(callbackUrl, state);
|
||||
|
||||
var uri = new Uri(result);
|
||||
var query = HttpUtility.ParseQueryString(uri.Query);
|
||||
|
||||
Assert.Equal(clientId, query["client_id"]);
|
||||
Assert.Equal(scopes, query["scope"]);
|
||||
Assert.Equal(callbackUrl, query["redirect_uri"]);
|
||||
Assert.Equal(state, query["state"]);
|
||||
Assert.Equal("slack.com", uri.Host);
|
||||
Assert.Equal("/oauth/v2/authorize", uri.AbsolutePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -286,6 +358,18 @@ public class SlackServiceTests
|
||||
Assert.Equal("test-access-token", result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("test-code", "")]
|
||||
[InlineData("", "https://example.com/callback")]
|
||||
[InlineData("", "")]
|
||||
public async Task ObtainTokenViaOAuth_ReturnsEmptyString_WhenCodeOrRedirectUrlIsEmpty(string code, string redirectUrl)
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
var result = await sutProvider.Sut.ObtainTokenViaOAuth(code, redirectUrl);
|
||||
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ObtainTokenViaOAuth_ReturnsEmptyString_WhenErrorResponse()
|
||||
{
|
||||
@@ -319,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);
|
||||
@@ -343,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Bit.Test.Common.Helpers;
|
||||
using Microsoft.Rest;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Services;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class TeamsIntegrationHandlerTests
|
||||
{
|
||||
private readonly ITeamsService _teamsService = Substitute.For<ITeamsService>();
|
||||
private readonly string _channelId = "C12345";
|
||||
private readonly Uri _serviceUrl = new Uri("http://localhost");
|
||||
|
||||
private SutProvider<TeamsIntegrationHandler> GetSutProvider()
|
||||
{
|
||||
return new SutProvider<TeamsIntegrationHandler>()
|
||||
.SetDependency(_teamsService)
|
||||
.Create();
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleAsync_SuccessfulRequest_ReturnsSuccess(IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
|
||||
|
||||
var result = await sutProvider.Sut.HandleAsync(message);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(result.Message, message);
|
||||
|
||||
await sutProvider.GetDependency<ITeamsService>().Received(1).SendMessageToChannelAsync(
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)),
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)),
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleAsync_HttpExceptionNonRetryable_ReturnsFalseAndNotRetryable(IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
|
||||
|
||||
sutProvider.GetDependency<ITeamsService>()
|
||||
.SendMessageToChannelAsync(Arg.Any<Uri>(), Arg.Any<string>(), Arg.Any<string>())
|
||||
.ThrowsAsync(new HttpOperationException("Server error")
|
||||
{
|
||||
Response = new HttpResponseMessageWrapper(
|
||||
new HttpResponseMessage(System.Net.HttpStatusCode.Forbidden),
|
||||
"Forbidden"
|
||||
)
|
||||
}
|
||||
);
|
||||
var result = await sutProvider.Sut.HandleAsync(message);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.False(result.Retryable);
|
||||
Assert.Equal(result.Message, message);
|
||||
|
||||
await sutProvider.GetDependency<ITeamsService>().Received(1).SendMessageToChannelAsync(
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)),
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)),
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate))
|
||||
);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleAsync_HttpExceptionRetryable_ReturnsFalseAndRetryable(IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
|
||||
|
||||
sutProvider.GetDependency<ITeamsService>()
|
||||
.SendMessageToChannelAsync(Arg.Any<Uri>(), Arg.Any<string>(), Arg.Any<string>())
|
||||
.ThrowsAsync(new HttpOperationException("Server error")
|
||||
{
|
||||
Response = new HttpResponseMessageWrapper(
|
||||
new HttpResponseMessage(System.Net.HttpStatusCode.TooManyRequests),
|
||||
"Too Many Requests"
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
var result = await sutProvider.Sut.HandleAsync(message);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.True(result.Retryable);
|
||||
Assert.Equal(result.Message, message);
|
||||
|
||||
await sutProvider.GetDependency<ITeamsService>().Received(1).SendMessageToChannelAsync(
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)),
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)),
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate))
|
||||
);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleAsync_UnknownException_ReturnsFalseAndNotRetryable(IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
|
||||
|
||||
sutProvider.GetDependency<ITeamsService>()
|
||||
.SendMessageToChannelAsync(Arg.Any<Uri>(), Arg.Any<string>(), Arg.Any<string>())
|
||||
.ThrowsAsync(new Exception("Unknown error"));
|
||||
var result = await sutProvider.Sut.HandleAsync(message);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.False(result.Retryable);
|
||||
Assert.Equal(result.Message, message);
|
||||
|
||||
await sutProvider.GetDependency<ITeamsService>().Received(1).SendMessageToChannelAsync(
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)),
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)),
|
||||
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate))
|
||||
);
|
||||
}
|
||||
}
|
||||
289
test/Core.Test/AdminConsole/Services/TeamsServiceTests.cs
Normal file
289
test/Core.Test/AdminConsole/Services/TeamsServiceTests.cs
Normal file
@@ -0,0 +1,289 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Web;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||
using Bit.Core.Models.Teams;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Bit.Test.Common.MockedHttpClient;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
|
||||
|
||||
namespace Bit.Core.Test.Services;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class TeamsServiceTests
|
||||
{
|
||||
private readonly MockedHttpMessageHandler _handler;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public TeamsServiceTests()
|
||||
{
|
||||
_handler = new MockedHttpMessageHandler();
|
||||
_httpClient = _handler.ToHttpClient();
|
||||
}
|
||||
|
||||
private SutProvider<TeamsService> GetSutProvider()
|
||||
{
|
||||
var clientFactory = Substitute.For<IHttpClientFactory>();
|
||||
clientFactory.CreateClient(TeamsService.HttpClientName).Returns(_httpClient);
|
||||
|
||||
var globalSettings = Substitute.For<GlobalSettings>();
|
||||
globalSettings.Teams.LoginBaseUrl.Returns("https://login.example.com");
|
||||
globalSettings.Teams.GraphBaseUrl.Returns("https://graph.example.com");
|
||||
|
||||
return new SutProvider<TeamsService>()
|
||||
.SetDependency(clientFactory)
|
||||
.SetDependency(globalSettings)
|
||||
.Create();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRedirectUrl_ReturnsCorrectUrl()
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
var clientId = sutProvider.GetDependency<GlobalSettings>().Teams.ClientId;
|
||||
var scopes = sutProvider.GetDependency<GlobalSettings>().Teams.Scopes;
|
||||
var callbackUrl = "https://example.com/callback";
|
||||
var state = Guid.NewGuid().ToString();
|
||||
var result = sutProvider.Sut.GetRedirectUrl(callbackUrl, state);
|
||||
|
||||
var uri = new Uri(result);
|
||||
var query = HttpUtility.ParseQueryString(uri.Query);
|
||||
|
||||
Assert.Equal(clientId, query["client_id"]);
|
||||
Assert.Equal(scopes, query["scope"]);
|
||||
Assert.Equal(callbackUrl, query["redirect_uri"]);
|
||||
Assert.Equal(state, query["state"]);
|
||||
Assert.Equal("login.example.com", uri.Host);
|
||||
Assert.Equal("/common/oauth2/v2.0/authorize", uri.AbsolutePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ObtainTokenViaOAuth_Success_ReturnsAccessToken()
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
var jsonResponse = JsonSerializer.Serialize(new
|
||||
{
|
||||
access_token = "test-access-token"
|
||||
});
|
||||
|
||||
_handler.When("https://login.example.com/common/oauth2/v2.0/token")
|
||||
.RespondWith(HttpStatusCode.OK)
|
||||
.WithContent(new StringContent(jsonResponse));
|
||||
|
||||
var result = await sutProvider.Sut.ObtainTokenViaOAuth("test-code", "https://example.com/callback");
|
||||
|
||||
Assert.Equal("test-access-token", result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("test-code", "")]
|
||||
[InlineData("", "https://example.com/callback")]
|
||||
[InlineData("", "")]
|
||||
public async Task ObtainTokenViaOAuth_CodeOrRedirectUrlIsEmpty_ReturnsEmptyString(string code, string redirectUrl)
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
var result = await sutProvider.Sut.ObtainTokenViaOAuth(code, redirectUrl);
|
||||
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ObtainTokenViaOAuth_HttpFailure_ReturnsEmptyString()
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
_handler.When("https://login.example.com/common/oauth2/v2.0/token")
|
||||
.RespondWith(HttpStatusCode.InternalServerError)
|
||||
.WithContent(new StringContent(string.Empty));
|
||||
|
||||
var result = await sutProvider.Sut.ObtainTokenViaOAuth("test-code", "https://example.com/callback");
|
||||
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ObtainTokenViaOAuth_UnknownResponse_ReturnsEmptyString()
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
|
||||
_handler.When("https://login.example.com/common/oauth2/v2.0/token")
|
||||
.RespondWith(HttpStatusCode.OK)
|
||||
.WithContent(new StringContent("Not an expected response"));
|
||||
|
||||
var result = await sutProvider.Sut.ObtainTokenViaOAuth("test-code", "https://example.com/callback");
|
||||
|
||||
Assert.Equal(string.Empty, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetJoinedTeamsAsync_Success_ReturnsTeams()
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
|
||||
var jsonResponse = JsonSerializer.Serialize(new
|
||||
{
|
||||
value = new[]
|
||||
{
|
||||
new { id = "team1", displayName = "Team One" },
|
||||
new { id = "team2", displayName = "Team Two" }
|
||||
}
|
||||
});
|
||||
|
||||
_handler.When("https://graph.example.com/me/joinedTeams")
|
||||
.RespondWith(HttpStatusCode.OK)
|
||||
.WithContent(new StringContent(jsonResponse));
|
||||
|
||||
var result = await sutProvider.Sut.GetJoinedTeamsAsync("fake-access-token");
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Contains(result, t => t is { Id: "team1", DisplayName: "Team One" });
|
||||
Assert.Contains(result, t => t is { Id: "team2", DisplayName: "Team Two" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetJoinedTeamsAsync_ServerReturnsEmpty_ReturnsEmptyList()
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
|
||||
var jsonResponse = JsonSerializer.Serialize(new { value = (object?)null });
|
||||
|
||||
_handler.When("https://graph.example.com/me/joinedTeams")
|
||||
.RespondWith(HttpStatusCode.OK)
|
||||
.WithContent(new StringContent(jsonResponse));
|
||||
|
||||
var result = await sutProvider.Sut.GetJoinedTeamsAsync("fake-access-token");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetJoinedTeamsAsync_ServerErrorCode_ReturnsEmptyList()
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
|
||||
_handler.When("https://graph.example.com/me/joinedTeams")
|
||||
.RespondWith(HttpStatusCode.Unauthorized)
|
||||
.WithContent(new StringContent("Unauthorized"));
|
||||
|
||||
var result = await sutProvider.Sut.GetJoinedTeamsAsync("fake-access-token");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleIncomingAppInstall_Success_UpdatesTeamsIntegration(
|
||||
OrganizationIntegration integration)
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
var tenantId = Guid.NewGuid().ToString();
|
||||
var teamId = Guid.NewGuid().ToString();
|
||||
var conversationId = Guid.NewGuid().ToString();
|
||||
var serviceUrl = new Uri("https://localhost");
|
||||
var initiatedConfiguration = new TeamsIntegration(TenantId: tenantId, Teams:
|
||||
[
|
||||
new TeamInfo() { Id = teamId, DisplayName = "test team", TenantId = tenantId },
|
||||
new TeamInfo() { Id = Guid.NewGuid().ToString(), DisplayName = "other team", TenantId = tenantId },
|
||||
new TeamInfo() { Id = Guid.NewGuid().ToString(), DisplayName = "third team", TenantId = tenantId }
|
||||
]);
|
||||
integration.Configuration = JsonSerializer.Serialize(initiatedConfiguration);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
|
||||
.GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId)
|
||||
.Returns(integration);
|
||||
|
||||
OrganizationIntegration? capturedIntegration = null;
|
||||
await sutProvider.GetDependency<IOrganizationIntegrationRepository>()
|
||||
.UpsertAsync(Arg.Do<OrganizationIntegration>(x => capturedIntegration = x));
|
||||
|
||||
await sutProvider.Sut.HandleIncomingAppInstallAsync(
|
||||
conversationId: conversationId,
|
||||
serviceUrl: serviceUrl,
|
||||
teamId: teamId,
|
||||
tenantId: tenantId
|
||||
);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1).GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId);
|
||||
Assert.NotNull(capturedIntegration);
|
||||
var configuration = JsonSerializer.Deserialize<TeamsIntegration>(capturedIntegration.Configuration ?? string.Empty);
|
||||
Assert.NotNull(configuration);
|
||||
Assert.NotNull(configuration.ServiceUrl);
|
||||
Assert.Equal(serviceUrl, configuration.ServiceUrl);
|
||||
Assert.Equal(conversationId, configuration.ChannelId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleIncomingAppInstall_NoIntegrationMatched_DoesNothing()
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
await sutProvider.Sut.HandleIncomingAppInstallAsync(
|
||||
conversationId: "conversationId",
|
||||
serviceUrl: new Uri("https://localhost"),
|
||||
teamId: "teamId",
|
||||
tenantId: "tenantId"
|
||||
);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1).GetByTeamsConfigurationTenantIdTeamId("tenantId", "teamId");
|
||||
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive().UpsertAsync(Arg.Any<OrganizationIntegration>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleIncomingAppInstall_MatchedIntegrationAlreadySetup_DoesNothing(
|
||||
OrganizationIntegration integration)
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
var tenantId = Guid.NewGuid().ToString();
|
||||
var teamId = Guid.NewGuid().ToString();
|
||||
var initiatedConfiguration = new TeamsIntegration(
|
||||
TenantId: tenantId,
|
||||
Teams: [new TeamInfo() { Id = teamId, DisplayName = "test team", TenantId = tenantId }],
|
||||
ChannelId: "ChannelId",
|
||||
ServiceUrl: new Uri("https://localhost")
|
||||
);
|
||||
integration.Configuration = JsonSerializer.Serialize(initiatedConfiguration);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
|
||||
.GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId)
|
||||
.Returns(integration);
|
||||
|
||||
await sutProvider.Sut.HandleIncomingAppInstallAsync(
|
||||
conversationId: "conversationId",
|
||||
serviceUrl: new Uri("https://localhost"),
|
||||
teamId: teamId,
|
||||
tenantId: tenantId
|
||||
);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1).GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId);
|
||||
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive().UpsertAsync(Arg.Any<OrganizationIntegration>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HandleIncomingAppInstall_MatchedIntegrationWithMissingConfiguration_DoesNothing(
|
||||
OrganizationIntegration integration)
|
||||
{
|
||||
var sutProvider = GetSutProvider();
|
||||
integration.Configuration = null;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
|
||||
.GetByTeamsConfigurationTenantIdTeamId("tenantId", "teamId")
|
||||
.Returns(integration);
|
||||
|
||||
await sutProvider.Sut.HandleIncomingAppInstallAsync(
|
||||
conversationId: "conversationId",
|
||||
serviceUrl: new Uri("https://localhost"),
|
||||
teamId: "teamId",
|
||||
tenantId: "tenantId"
|
||||
);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1).GetByTeamsConfigurationTenantIdTeamId("tenantId", "teamId");
|
||||
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive().UpsertAsync(Arg.Any<OrganizationIntegration>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
using Bit.Core.AdminConsole.Utilities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.AdminConsole.Utilities;
|
||||
|
||||
public class PolicyDataValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void ValidateAndSerialize_NullData_ReturnsNull()
|
||||
{
|
||||
var result = PolicyDataValidator.ValidateAndSerialize(null, PolicyType.MasterPassword);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAndSerialize_ValidData_ReturnsSerializedJson()
|
||||
{
|
||||
var data = new Dictionary<string, object> { { "minLength", 12 } };
|
||||
|
||||
var result = PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains("\"minLength\":12", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAndSerialize_InvalidDataType_ThrowsBadRequestException()
|
||||
{
|
||||
var data = new Dictionary<string, object> { { "minLength", "not a number" } };
|
||||
|
||||
var exception = Assert.Throws<BadRequestException>(() =>
|
||||
PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword));
|
||||
|
||||
Assert.Contains("Invalid data for MasterPassword policy", exception.Message);
|
||||
Assert.Contains("minLength", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAndDeserializeMetadata_NullMetadata_ReturnsEmptyMetadataModel()
|
||||
{
|
||||
var result = PolicyDataValidator.ValidateAndDeserializeMetadata(null, PolicyType.SingleOrg);
|
||||
|
||||
Assert.IsType<EmptyMetadataModel>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateAndDeserializeMetadata_ValidMetadata_ReturnsModel()
|
||||
{
|
||||
var metadata = new Dictionary<string, object> { { "defaultUserCollectionName", "collection name" } };
|
||||
|
||||
var result = PolicyDataValidator.ValidateAndDeserializeMetadata(metadata, PolicyType.OrganizationDataOwnership);
|
||||
|
||||
Assert.IsType<OrganizationModelOwnershipPolicyModel>(result);
|
||||
}
|
||||
}
|
||||
@@ -467,10 +467,9 @@ public class AuthRequestServiceTests
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<string>());
|
||||
|
||||
var expectedLogMessage = "There are no admin emails to send to.";
|
||||
sutProvider.GetDependency<ILogger<AuthRequestService>>()
|
||||
.Received(1)
|
||||
.LogWarning(expectedLogMessage);
|
||||
.LogWarning("There are no admin emails to send to.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
@@ -12,6 +14,7 @@ using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
@@ -364,4 +367,54 @@ public class SsoConfigServiceTests
|
||||
await sutProvider.GetDependency<ISsoConfigRepository>().ReceivedWithAnyArgs()
|
||||
.UpsertAsync(default);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveAsync_Tde_WhenPolicyValidatorsRefactorEnabled_UsesVNextSavePolicyCommand(
|
||||
SutProvider<SsoConfigService> sutProvider, Organization organization)
|
||||
{
|
||||
var ssoConfig = new SsoConfig
|
||||
{
|
||||
Id = default,
|
||||
Data = new SsoConfigurationData
|
||||
{
|
||||
MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption,
|
||||
}.Serialize(),
|
||||
Enabled = true,
|
||||
OrganizationId = organization.Id,
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)
|
||||
.Returns(true);
|
||||
|
||||
await sutProvider.Sut.SaveAsync(ssoConfig, organization);
|
||||
|
||||
await sutProvider.GetDependency<IVNextSavePolicyCommand>()
|
||||
.Received(1)
|
||||
.SaveAsync(Arg.Is<SavePolicyModel>(m =>
|
||||
m.PolicyUpdate.Type == PolicyType.SingleOrg &&
|
||||
m.PolicyUpdate.OrganizationId == organization.Id &&
|
||||
m.PolicyUpdate.Enabled &&
|
||||
m.PerformedBy is SystemUser));
|
||||
|
||||
await sutProvider.GetDependency<IVNextSavePolicyCommand>()
|
||||
.Received(1)
|
||||
.SaveAsync(Arg.Is<SavePolicyModel>(m =>
|
||||
m.PolicyUpdate.Type == PolicyType.ResetPassword &&
|
||||
m.PolicyUpdate.GetDataModel<ResetPasswordDataModel>().AutoEnrollEnabled &&
|
||||
m.PolicyUpdate.OrganizationId == organization.Id &&
|
||||
m.PolicyUpdate.Enabled &&
|
||||
m.PerformedBy is SystemUser));
|
||||
|
||||
await sutProvider.GetDependency<IVNextSavePolicyCommand>()
|
||||
.Received(1)
|
||||
.SaveAsync(Arg.Is<SavePolicyModel>(m =>
|
||||
m.PolicyUpdate.Type == PolicyType.RequireSso &&
|
||||
m.PolicyUpdate.OrganizationId == organization.Id &&
|
||||
m.PolicyUpdate.Enabled &&
|
||||
m.PerformedBy is SystemUser));
|
||||
|
||||
await sutProvider.GetDependency<ISsoConfigRepository>().ReceivedWithAnyArgs()
|
||||
.UpsertAsync(default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,7 +294,8 @@ public class InvoiceExtensionsTests
|
||||
Amount = 600
|
||||
}
|
||||
);
|
||||
invoice.Tax = 120; // $1.20 in cents
|
||||
|
||||
invoice.TotalTaxes = [new InvoiceTotalTax { Amount = 120 }]; // $1.20 in cents
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
@@ -318,7 +319,7 @@ public class InvoiceExtensionsTests
|
||||
Amount = 600
|
||||
}
|
||||
);
|
||||
invoice.Tax = null;
|
||||
invoice.TotalTaxes = [];
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
@@ -341,7 +342,7 @@ public class InvoiceExtensionsTests
|
||||
Amount = 600
|
||||
}
|
||||
);
|
||||
invoice.Tax = 0;
|
||||
invoice.TotalTaxes = [new InvoiceTotalTax { Amount = 0 }];
|
||||
var subscription = new Subscription();
|
||||
|
||||
// Act
|
||||
@@ -374,7 +375,7 @@ public class InvoiceExtensionsTests
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Lines = lineItems,
|
||||
Tax = 200 // Additional $2.00 tax
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 200 }] // Additional $2.00 tax
|
||||
};
|
||||
var subscription = new Subscription();
|
||||
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
using System.Security.Claims;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Models.Business;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Models.Business;
|
||||
|
||||
public class OrganizationLicenseTests
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the license file is loaded from disk using the current OrganizationLicense class,
|
||||
/// it matches the Organization it was generated for.
|
||||
@@ -33,4 +38,225 @@ public class OrganizationLicenseTests
|
||||
});
|
||||
Assert.True(license.VerifyData(organization, claimsPrincipal, globalSettings));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Known good GetDataBytes output for hash data (forHash: true) for all OrganizationLicense versions.
|
||||
/// These values were verified to be correct on initial implementation and serve as regression baselines.
|
||||
/// NOTE: License versions are now frozen. Use the JWT Token property to add new claims instead of incrementing the version.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<int, string> _knownGoodOrganizationLicenseHashData = new()
|
||||
{
|
||||
{ 1, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|UseDirectory:true|UseGroups:true|UseTotp:true|Version:1" },
|
||||
{ 2, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|UseDirectory:true|UseGroups:true|UsersGetPremium:true|UseTotp:true|Version:2" },
|
||||
{ 3, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|UseDirectory:true|UseEvents:true|UseGroups:true|UsersGetPremium:true|UseTotp:true|Version:3" },
|
||||
{ 4, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseDirectory:true|UseEvents:true|UseGroups:true|UsersGetPremium:true|UseTotp:true|Version:4" },
|
||||
{ 5, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UsersGetPremium:true|UseTotp:true|Version:5" },
|
||||
{ 6, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UsePolicies:true|UsersGetPremium:true|UseTotp:true|Version:6" },
|
||||
{ 7, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UsePolicies:true|UsersGetPremium:true|UseSso:true|UseTotp:true|Version:7" },
|
||||
{ 8, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseSso:true|UseTotp:true|Version:8" },
|
||||
{ 9, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseSso:true|UseTotp:true|Version:9" },
|
||||
{ 10, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSso:true|UseTotp:true|Version:10" },
|
||||
{ 11, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSso:true|UseTotp:true|Version:11" },
|
||||
{ 12, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|ExpirationWithoutGracePeriod:|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSso:true|UseTotp:true|Version:12" },
|
||||
{ 13, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|ExpirationWithoutGracePeriod:|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|SmSeats:5|SmServiceAccounts:8|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePasswordManager:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSecretsManager:true|UseSso:true|UseTotp:true|Version:13" },
|
||||
{ 14, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|ExpirationWithoutGracePeriod:|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|LimitCollectionCreationDeletion:true|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|SmSeats:5|SmServiceAccounts:8|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePasswordManager:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSecretsManager:true|UseSso:true|UseTotp:true|Version:14" },
|
||||
{ 15, "license:organization|AllowAdminAccessToAllCollectionItems:true|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|ExpirationWithoutGracePeriod:|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|LimitCollectionCreationDeletion:true|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|SmSeats:5|SmServiceAccounts:8|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePasswordManager:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSecretsManager:true|UseSso:true|UseTotp:true|Version:15" },
|
||||
{ 16, "license:organization|AllowAdminAccessToAllCollectionItems:true|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|ExpirationWithoutGracePeriod:|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|LimitCollectionCreationDeletion:true|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|SmSeats:5|SmServiceAccounts:8|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePasswordManager:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSecretsManager:true|UseSso:true|UseTotp:true|Version:16" }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Known good GetDataBytes output for signature data (forHash: false) for all OrganizationLicense versions.
|
||||
/// These values were verified to be correct on initial implementation and serve as regression baselines.
|
||||
/// NOTE: License versions are now frozen. Use the JWT Token property to add new claims instead of incrementing the version.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<int, string> _knownGoodOrganizationLicenseSignatureData = new()
|
||||
{
|
||||
{ 1, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Hash:WSyM/Q+vgOuWeF6XBH+RSfUqvf7NDtP3fgNfcbXYqKc=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|UseDirectory:true|UseGroups:true|UseTotp:true|Version:1" },
|
||||
{ 2, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Hash:n4g3leUf/egbnKk+/VgkJTvdxw2YRH6/zGgx89h+J60=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|UseDirectory:true|UseGroups:true|UsersGetPremium:true|UseTotp:true|Version:2" },
|
||||
{ 3, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Hash:zDoMNV/c8YpUypc+FmBoPyj73qOsg4snsMOJDcKFp9k=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|UseDirectory:true|UseEvents:true|UseGroups:true|UsersGetPremium:true|UseTotp:true|Version:3" },
|
||||
{ 4, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Hash:Y2sP9phSZ9GqbCC+PMp1KdnUhjfNaqNg6uzfUydrKZM=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseDirectory:true|UseEvents:true|UseGroups:true|UsersGetPremium:true|UseTotp:true|Version:4" },
|
||||
{ 5, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Hash:PudZKNV7YAWJogm8BJf3wZIL+lESf3qzV/pQlZPPJjY=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UsersGetPremium:true|UseTotp:true|Version:5" },
|
||||
{ 6, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Hash:7SjSYQENeAW4pUnXtsPaux2uipIWNWJz9VIrNW2gVsI=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UsePolicies:true|UsersGetPremium:true|UseTotp:true|Version:6" },
|
||||
{ 7, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Hash:ujf4/zlDXv1g6ktlk9XBj/u3BkRZG+p5I00piGDiWp8=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UsePolicies:true|UsersGetPremium:true|UseSso:true|UseTotp:true|Version:7" },
|
||||
{ 8, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Hash:GEM3AyWbQknnlDtoxyhw0QK7edYS2C/bffX5+p4G9ig=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseSso:true|UseTotp:true|Version:8" },
|
||||
{ 9, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Hash:5SF14wtEieiA9hjj+BTcrggHcx7dLEGbH+HLksvK79o=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseSso:true|UseTotp:true|Version:9" },
|
||||
{ 10, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Hash:NmbIpfiZUNxSvwbaolbUmItQCHIcVCTjfraR/NBlmvE=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSso:true|UseTotp:true|Version:10" },
|
||||
{ 11, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Hash:dGZBQT/PORsuT/W2oRrngcjTboTyfZZVpDZBHshVK6Y=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSso:true|UseTotp:true|Version:11" },
|
||||
{ 12, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|ExpirationWithoutGracePeriod:|Expires:1740787200|Hash:rWecCXB0kuqi/RW3C8u2rLZRDMR49W3W4Q3eL2tZ3j8=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSso:true|UseTotp:true|Version:12" },
|
||||
{ 13, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|ExpirationWithoutGracePeriod:|Expires:1740787200|Hash:15fwM5v5Ba+t7JlD4ToYvtZmAoShWC3DrOD0lM5kXGE=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|SmSeats:5|SmServiceAccounts:8|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePasswordManager:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSecretsManager:true|UseSso:true|UseTotp:true|Version:13" },
|
||||
{ 14, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|ExpirationWithoutGracePeriod:|Expires:1740787200|Hash:2bTNBiH2G/Nzv6UVD1BNJQBGjT9et0UO8ComQofS8uo=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|LimitCollectionCreationDeletion:true|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|SmSeats:5|SmServiceAccounts:8|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePasswordManager:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSecretsManager:true|UseSso:true|UseTotp:true|Version:14" },
|
||||
{ 15, "license:organization|AllowAdminAccessToAllCollectionItems:true|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|ExpirationWithoutGracePeriod:|Expires:1740787200|Hash:3VjOyWJu38N4epIzhDzjRR80zQ651wnYkQCd+DIzeAs=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|LimitCollectionCreationDeletion:true|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|SmSeats:5|SmServiceAccounts:8|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePasswordManager:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSecretsManager:true|UseSso:true|UseTotp:true|Version:15" },
|
||||
{ 16, "license:organization|AllowAdminAccessToAllCollectionItems:true|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|ExpirationWithoutGracePeriod:|Expires:1740787200|Hash:Oo5KFBoX8pMcklJ4oJAqgv77/WA8+gDPxq6+/Fjffwc=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|LimitCollectionCreationDeletion:true|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|SmSeats:5|SmServiceAccounts:8|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePasswordManager:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSecretsManager:true|UseSso:true|UseTotp:true|Version:16" }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Regression test that verifies GetDataBytes output for hash data (forHash: true) remains stable across all OrganizationLicense versions.
|
||||
/// This protects against accidental changes to the data format that would break backward compatibility.
|
||||
/// If this test fails, it means the hash data format has changed and existing licenses may no longer validate.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OrganizationLicense_GetDataBytes_HashData_AllVersions()
|
||||
{
|
||||
// Verify each version produces the expected hash data format
|
||||
for (var version = 1; version <= 16; version++)
|
||||
{
|
||||
var license = CreateDeterministicOrganizationLicense(version);
|
||||
var actualHashData = System.Text.Encoding.UTF8.GetString(license.GetDataBytes(forHash: true));
|
||||
Assert.Equal(_knownGoodOrganizationLicenseHashData[version], actualHashData);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression test that verifies GetDataBytes output for signature data (forHash: false) remains stable across all OrganizationLicense versions.
|
||||
/// This protects against accidental changes to the data format that would break backward compatibility.
|
||||
/// If this test fails, it means the signature data format has changed and existing licenses may no longer validate.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OrganizationLicense_GetDataBytes_SignatureData_AllVersions()
|
||||
{
|
||||
// Verify each version produces the expected signature data format
|
||||
for (var version = 1; version <= 16; version++)
|
||||
{
|
||||
var license = CreateDeterministicOrganizationLicense(version);
|
||||
var actualSignatureData = System.Text.Encoding.UTF8.GetString(license.GetDataBytes(forHash: false));
|
||||
Assert.Equal(_knownGoodOrganizationLicenseSignatureData[version], actualSignatureData);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the OrganizationLicense version remains frozen at version 15.
|
||||
/// License versions should no longer be incremented. Use the JWT Token property to add new claims instead.
|
||||
/// If this test fails, it means someone attempted to increment the license version, which is no longer allowed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OrganizationLicense_CurrentVersion_ShouldRemainFrozen()
|
||||
{
|
||||
const int expectedVersion = 15;
|
||||
var actualVersion = OrganizationLicense.CurrentLicenseFileVersion;
|
||||
|
||||
Assert.True(actualVersion == expectedVersion, $@"
|
||||
ERROR: OrganizationLicense.CurrentLicenseFileVersion has been changed from {expectedVersion} to {actualVersion}
|
||||
|
||||
License versions are now frozen and should not be incremented.
|
||||
|
||||
Instead of incrementing the version:
|
||||
- Use the JWT Token property to add new claims
|
||||
- Add your new capabilities as claims in the Token
|
||||
- This allows for more flexible licensing without breaking backward compatibility
|
||||
|
||||
If you believe you need to change the version for a valid reason, please discuss with the team first.
|
||||
");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a deterministic OrganizationLicense for testing hash values.
|
||||
/// All property values are fixed to ensure reproducible hashes.
|
||||
/// </summary>
|
||||
private static OrganizationLicense CreateDeterministicOrganizationLicense(int version)
|
||||
{
|
||||
var organization = CreateDeterministicOrganization();
|
||||
var subscriptionInfo = CreateDeterministicSubscriptionInfo();
|
||||
var installationId = new Guid("78900000-0000-0000-0000-000000000123");
|
||||
var mockLicensingService = CreateMockLicensingService();
|
||||
|
||||
var license = new OrganizationLicense(organization, subscriptionInfo, installationId, mockLicensingService, version);
|
||||
|
||||
// Override timestamps to deterministic values (constructor sets them to DateTime.UtcNow)
|
||||
license.Issued = new DateTime(2025, 9, 26, 12, 0, 1, DateTimeKind.Utc); // Corresponds to 1759501361 Unix timestamp
|
||||
license.Refresh = new DateTime(2025, 10, 26, 12, 0, 1, DateTimeKind.Utc); // Corresponds to 1762093361 Unix timestamp
|
||||
|
||||
// Recalculate hash with the deterministic Issued/Refresh values
|
||||
license.Hash = Convert.ToBase64String(license.ComputeHash());
|
||||
license.Signature = Convert.ToBase64String(mockLicensingService.SignLicense(license));
|
||||
|
||||
return license;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an Organization with deterministic property values for reproducible testing.
|
||||
/// </summary>
|
||||
private static Organization CreateDeterministicOrganization()
|
||||
{
|
||||
return new Organization
|
||||
{
|
||||
Id = new Guid("12300000-0000-0000-0000-000000000456"),
|
||||
Identifier = "myIdentifier",
|
||||
Name = "myOrg",
|
||||
BillingEmail = "myBillingEmail",
|
||||
Plan = "myPlan",
|
||||
PlanType = PlanType.EnterpriseAnnually2020,
|
||||
Seats = 10,
|
||||
MaxCollections = 2,
|
||||
UsePolicies = true,
|
||||
UseSso = true,
|
||||
UseKeyConnector = true,
|
||||
UseScim = true,
|
||||
UseGroups = true,
|
||||
UseEvents = true,
|
||||
UseDirectory = true,
|
||||
UseTotp = true,
|
||||
Use2fa = true,
|
||||
UseApi = true,
|
||||
UseResetPassword = true,
|
||||
MaxStorageGb = 100,
|
||||
SelfHost = true,
|
||||
UsersGetPremium = true,
|
||||
UseCustomPermissions = true,
|
||||
Enabled = true,
|
||||
LicenseKey = "myLicenseKey",
|
||||
UsePasswordManager = true,
|
||||
UseSecretsManager = true,
|
||||
SmSeats = 5,
|
||||
SmServiceAccounts = 8,
|
||||
UseRiskInsights = false,
|
||||
LimitCollectionCreation = true,
|
||||
LimitCollectionDeletion = true,
|
||||
AllowAdminAccessToAllCollectionItems = true,
|
||||
UseOrganizationDomains = true,
|
||||
UseAdminSponsoredFamilies = false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a SubscriptionInfo with deterministic dates for reproducible testing.
|
||||
/// </summary>
|
||||
private static SubscriptionInfo CreateDeterministicSubscriptionInfo()
|
||||
{
|
||||
var stripeSubscription = new Subscription
|
||||
{
|
||||
Status = "active",
|
||||
TrialStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
TrialEnd = new DateTime(2024, 2, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = [
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
CurrentPeriodEnd = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc)
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
return new SubscriptionInfo
|
||||
{
|
||||
UpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice
|
||||
{
|
||||
Date = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc)
|
||||
},
|
||||
Subscription = new SubscriptionInfo.BillingSubscription(stripeSubscription)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock ILicensingService that returns a deterministic signature.
|
||||
/// </summary>
|
||||
private static ILicensingService CreateMockLicensingService()
|
||||
{
|
||||
var mockService = Substitute.For<ILicensingService>();
|
||||
mockService.SignLicense(Arg.Any<ILicense>())
|
||||
.Returns([0x00, 0x01, 0x02, 0x03]); // Dummy signature for hash testing
|
||||
return mockService;
|
||||
}
|
||||
}
|
||||
|
||||
176
test/Core.Test/Billing/Models/Business/UserLicenseTests.cs
Normal file
176
test/Core.Test/Billing/Models/Business/UserLicenseTests.cs
Normal file
@@ -0,0 +1,176 @@
|
||||
using Bit.Core.Billing.Models.Business;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Business;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Models.Business;
|
||||
|
||||
public class UserLicenseTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Known good GetDataBytes output for hash data (forHash: true) for UserLicense version 1.
|
||||
/// This value was verified to be correct on initial implementation and serves as a regression baseline.
|
||||
/// NOTE: License versions are now frozen. Use the JWT Token property to add new claims instead of incrementing the version.
|
||||
/// </summary>
|
||||
private const string _knownGoodUserLicenseHashData = "license:user|Email:test@example.com|Expires:1736208000|Id:12300000-0000-0000-0000-000000000789|LicenseKey:myUserLicenseKey|MaxStorageGb:10|Name:Test User|Premium:true|Trial:false|Version:1";
|
||||
|
||||
/// <summary>
|
||||
/// Known good GetDataBytes output for signature data (forHash: false) for UserLicense version 1.
|
||||
/// This value was verified to be correct on initial implementation and serves as a regression baseline.
|
||||
/// NOTE: License versions are now frozen. Use the JWT Token property to add new claims instead of incrementing the version.
|
||||
/// </summary>
|
||||
private const string _knownGoodUserLicenseSignatureData = "license:user|Email:test@example.com|Expires:1736208000|Hash:oZEopNmWvWQNE3Lnsh/LP2OPo6+IHxjTcpdIse/viQk=|Id:12300000-0000-0000-0000-000000000789|Issued:1758888041|LicenseKey:myUserLicenseKey|MaxStorageGb:10|Name:Test User|Premium:true|Refresh:1735603200|Trial:false|Version:1";
|
||||
|
||||
/// <summary>
|
||||
/// Regression test that verifies GetDataBytes output for hash data (forHash: true) remains stable for UserLicense version 1.
|
||||
/// This protects against accidental changes to the data format that would break backward compatibility.
|
||||
/// If this test fails, it means the hash data format has changed and existing licenses may no longer validate.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UserLicense_GetDataBytes_HashData_Version1()
|
||||
{
|
||||
var license = CreateDeterministicUserLicense();
|
||||
var actualHashData = System.Text.Encoding.UTF8.GetString(license.GetDataBytes(forHash: true));
|
||||
Assert.Equal(_knownGoodUserLicenseHashData, actualHashData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression test that verifies GetDataBytes output for signature data (forHash: false) remains stable for UserLicense version 1.
|
||||
/// This protects against accidental changes to the data format that would break backward compatibility.
|
||||
/// If this test fails, it means the signature data format has changed and existing licenses may no longer validate.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UserLicense_GetDataBytes_SignatureData_Version1()
|
||||
{
|
||||
var license = CreateDeterministicUserLicense();
|
||||
var actualSignatureData = System.Text.Encoding.UTF8.GetString(license.GetDataBytes(forHash: false));
|
||||
Assert.Equal(_knownGoodUserLicenseSignatureData, actualSignatureData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the UserLicense version remains frozen at version 1.
|
||||
/// License versions should no longer be incremented. Use the JWT Token property to add new claims instead.
|
||||
/// If this test fails, it means someone attempted to add version 2 support, which is no longer allowed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UserLicense_CurrentVersion_ShouldRemainFrozen()
|
||||
{
|
||||
const int expectedMaxVersion = 1;
|
||||
|
||||
var user = CreateDeterministicUser();
|
||||
var subscriptionInfo = CreateDeterministicSubscriptionInfo();
|
||||
var mockLicensingService = CreateMockLicensingService();
|
||||
|
||||
// Verify that version 2 is NOT supported (should throw NotSupportedException)
|
||||
var exception = Assert.Throws<NotSupportedException>(() =>
|
||||
new UserLicense(user, subscriptionInfo, mockLicensingService, version: 2));
|
||||
|
||||
// If the exception message changes or we don't get an exception, fail with helpful guidance
|
||||
if (exception == null)
|
||||
{
|
||||
var errorMessage = $@"
|
||||
ERROR: UserLicense now supports version 2 or higher
|
||||
|
||||
License versions are now frozen and should not be incremented.
|
||||
|
||||
Instead of incrementing the version:
|
||||
- Use the JWT Token property to add new claims
|
||||
- Add your new capabilities as claims in the Token
|
||||
- This allows for more flexible licensing without breaking backward compatibility
|
||||
|
||||
If you believe you need to change the version for a valid reason, please discuss with the team first.
|
||||
";
|
||||
Assert.Fail(errorMessage);
|
||||
}
|
||||
|
||||
// Verify we still support version 1
|
||||
var license = new UserLicense(user, subscriptionInfo, mockLicensingService, version: expectedMaxVersion);
|
||||
Assert.NotNull(license);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a deterministic UserLicense for testing hash values.
|
||||
/// All property values are fixed to ensure reproducible hashes.
|
||||
/// </summary>
|
||||
private static UserLicense CreateDeterministicUserLicense()
|
||||
{
|
||||
var user = CreateDeterministicUser();
|
||||
var subscriptionInfo = CreateDeterministicSubscriptionInfo();
|
||||
var mockLicensingService = CreateMockLicensingService();
|
||||
|
||||
var license = new UserLicense(user, subscriptionInfo, mockLicensingService, version: 1);
|
||||
|
||||
// Override timestamps to deterministic values (constructor sets them to DateTime.UtcNow)
|
||||
license.Issued = new DateTime(2025, 9, 26, 12, 0, 41, DateTimeKind.Utc); // Corresponds to 1759502041 Unix timestamp
|
||||
license.Refresh = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc); // Corresponds to 1735603200 Unix timestamp
|
||||
|
||||
// Recalculate hash with the deterministic Issued/Refresh values
|
||||
license.Hash = Convert.ToBase64String(license.ComputeHash());
|
||||
license.Signature = Convert.ToBase64String(mockLicensingService.SignLicense(license));
|
||||
|
||||
return license;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a User with deterministic property values for reproducible testing.
|
||||
/// </summary>
|
||||
private static User CreateDeterministicUser()
|
||||
{
|
||||
return new User
|
||||
{
|
||||
Id = new Guid("12300000-0000-0000-0000-000000000789"),
|
||||
Name = "Test User",
|
||||
Email = "test@example.com",
|
||||
LicenseKey = "myUserLicenseKey",
|
||||
Premium = true,
|
||||
MaxStorageGb = 10,
|
||||
PremiumExpirationDate = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a SubscriptionInfo with deterministic dates for reproducible testing.
|
||||
/// </summary>
|
||||
private static SubscriptionInfo CreateDeterministicSubscriptionInfo()
|
||||
{
|
||||
var stripeSubscription = new Subscription
|
||||
{
|
||||
Status = "active",
|
||||
TrialStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
TrialEnd = new DateTime(2024, 2, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = [
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
CurrentPeriodEnd = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc)
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
return new SubscriptionInfo
|
||||
{
|
||||
UpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice
|
||||
{
|
||||
Date = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc)
|
||||
},
|
||||
Subscription = new SubscriptionInfo.BillingSubscription(stripeSubscription)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock ILicensingService that returns a deterministic signature.
|
||||
/// </summary>
|
||||
private static ILicensingService CreateMockLicensingService()
|
||||
{
|
||||
var mockService = Substitute.For<ILicensingService>();
|
||||
mockService.SignLicense(Arg.Any<ILicense>())
|
||||
.Returns([0x00, 0x01, 0x02, 0x03]); // Dummy signature for hash testing
|
||||
return mockService;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -88,7 +88,7 @@ public class UpdateOrganizationLicenseCommandTests
|
||||
"Hash", "Signature", "SignatureBytes", "InstallationId", "Expires",
|
||||
"ExpirationWithoutGracePeriod", "Token", "LimitCollectionCreationDeletion",
|
||||
"LimitCollectionCreation", "LimitCollectionDeletion", "AllowAdminAccessToAllCollectionItems",
|
||||
"UseOrganizationDomains", "UseAdminSponsoredFamilies") &&
|
||||
"UseOrganizationDomains", "UseAdminSponsoredFamilies", "UseAutomaticUserConfirmation") &&
|
||||
// Same property but different name, use explicit mapping
|
||||
org.ExpirationDate == license.Expires));
|
||||
}
|
||||
|
||||
@@ -27,25 +27,27 @@ public class GetCloudOrganizationLicenseQueryTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetLicenseAsync_InvalidInstallationId_Throws(SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
public async Task GetLicenseAsync_InvalidInstallationId_Throws(
|
||||
SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
Organization organization, Guid installationId, int version)
|
||||
{
|
||||
sutProvider.GetDependency<IInstallationRepository>().GetByIdAsync(installationId).ReturnsNull();
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
async () => await sutProvider.Sut.GetLicenseAsync(organization, installationId, version));
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(async () =>
|
||||
await sutProvider.Sut.GetLicenseAsync(organization, installationId, version));
|
||||
Assert.Contains("Invalid installation id", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetLicenseAsync_DisabledOrganization_Throws(SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
public async Task GetLicenseAsync_DisabledOrganization_Throws(
|
||||
SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
Organization organization, Guid installationId, Installation installation)
|
||||
{
|
||||
installation.Enabled = false;
|
||||
sutProvider.GetDependency<IInstallationRepository>().GetByIdAsync(installationId).Returns(installation);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
async () => await sutProvider.Sut.GetLicenseAsync(organization, installationId));
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(async () =>
|
||||
await sutProvider.Sut.GetLicenseAsync(organization, installationId));
|
||||
Assert.Contains("Invalid installation id", exception.Message);
|
||||
}
|
||||
|
||||
@@ -71,7 +73,8 @@ public class GetCloudOrganizationLicenseQueryTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetLicenseAsync_WhenFeatureFlagEnabled_CreatesToken(SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
public async Task GetLicenseAsync_WhenFeatureFlagEnabled_CreatesToken(
|
||||
SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
Organization organization, Guid installationId, Installation installation, SubscriptionInfo subInfo,
|
||||
byte[] licenseSignature, string token)
|
||||
{
|
||||
@@ -90,7 +93,8 @@ public class GetCloudOrganizationLicenseQueryTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetLicenseAsync_MSPManagedOrganization_UsesProviderSubscription(SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
public async Task GetLicenseAsync_MSPManagedOrganization_UsesProviderSubscription(
|
||||
SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
Organization organization, Guid installationId, Installation installation, SubscriptionInfo subInfo,
|
||||
byte[] licenseSignature, Provider provider)
|
||||
{
|
||||
@@ -99,8 +103,17 @@ public class GetCloudOrganizationLicenseQueryTests
|
||||
|
||||
subInfo.Subscription = new SubscriptionInfo.BillingSubscription(new Subscription
|
||||
{
|
||||
CurrentPeriodStart = DateTime.UtcNow,
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1)
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodStart = DateTime.UtcNow,
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1)
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
installation.Enabled = true;
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Organizations.Queries;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Organizations.Queries;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class GetOrganizationMetadataQueryTests
|
||||
{
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_SelfHosted_ReturnsDefault(
|
||||
Organization organization,
|
||||
SutProvider<GetOrganizationMetadataQuery> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(true);
|
||||
|
||||
var result = await sutProvider.Sut.Run(organization);
|
||||
|
||||
Assert.Equal(OrganizationMetadata.Default, result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_NoGatewaySubscriptionId_ReturnsDefaultWithOccupiedSeats(
|
||||
Organization organization,
|
||||
SutProvider<GetOrganizationMetadataQuery> sutProvider)
|
||||
{
|
||||
organization.GatewaySubscriptionId = null;
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new OrganizationSeatCounts { Users = 10, Sponsored = 0 });
|
||||
|
||||
var result = await sutProvider.Sut.Run(organization);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsOnSecretsManagerStandalone);
|
||||
Assert.Equal(10, result.OrganizationOccupiedSeats);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_NullCustomer_ReturnsDefaultWithOccupiedSeats(
|
||||
Organization organization,
|
||||
SutProvider<GetOrganizationMetadataQuery> sutProvider)
|
||||
{
|
||||
organization.GatewaySubscriptionId = "sub_123";
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new OrganizationSeatCounts { Users = 5, Sponsored = 0 });
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetCustomer(organization)
|
||||
.ReturnsNull();
|
||||
|
||||
var result = await sutProvider.Sut.Run(organization);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsOnSecretsManagerStandalone);
|
||||
Assert.Equal(5, result.OrganizationOccupiedSeats);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_NullSubscription_ReturnsDefaultWithOccupiedSeats(
|
||||
Organization organization,
|
||||
SutProvider<GetOrganizationMetadataQuery> sutProvider)
|
||||
{
|
||||
organization.GatewaySubscriptionId = "sub_123";
|
||||
|
||||
var customer = new Customer();
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new OrganizationSeatCounts { Users = 7, Sponsored = 0 });
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetCustomer(organization)
|
||||
.Returns(customer);
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||
options.Expand.Contains("discounts.coupon.applies_to")))
|
||||
.ReturnsNull();
|
||||
|
||||
var result = await sutProvider.Sut.Run(organization);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsOnSecretsManagerStandalone);
|
||||
Assert.Equal(7, result.OrganizationOccupiedSeats);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_WithSecretsManagerStandaloneCoupon_ReturnsMetadataWithFlag(
|
||||
Organization organization,
|
||||
SutProvider<GetOrganizationMetadataQuery> sutProvider)
|
||||
{
|
||||
organization.GatewaySubscriptionId = "sub_123";
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
|
||||
var productId = "product_123";
|
||||
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 =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
Plan = new Plan
|
||||
{
|
||||
ProductId = productId
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new OrganizationSeatCounts { Users = 15, Sponsored = 0 });
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetCustomer(organization)
|
||||
.Returns(customer);
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||
options.Expand.Contains("discounts.coupon.applies_to")))
|
||||
.Returns(subscription);
|
||||
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(organization.PlanType)
|
||||
.Returns(StaticStore.GetPlan(organization.PlanType));
|
||||
|
||||
var result = await sutProvider.Sut.Run(organization);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.IsOnSecretsManagerStandalone);
|
||||
Assert.Equal(15, result.OrganizationOccupiedSeats);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_WithoutSecretsManagerStandaloneCoupon_ReturnsMetadataWithoutFlag(
|
||||
Organization organization,
|
||||
SutProvider<GetOrganizationMetadataQuery> sutProvider)
|
||||
{
|
||||
organization.GatewaySubscriptionId = "sub_123";
|
||||
organization.PlanType = PlanType.TeamsAnnually;
|
||||
|
||||
var customer = new Customer();
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Discounts = null,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
Plan = new Plan
|
||||
{
|
||||
ProductId = "product_123"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new OrganizationSeatCounts { Users = 20, Sponsored = 0 });
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetCustomer(organization)
|
||||
.Returns(customer);
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||
options.Expand.Contains("discounts.coupon.applies_to")))
|
||||
.Returns(subscription);
|
||||
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(organization.PlanType)
|
||||
.Returns(StaticStore.GetPlan(organization.PlanType));
|
||||
|
||||
var result = await sutProvider.Sut.Run(organization);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsOnSecretsManagerStandalone);
|
||||
Assert.Equal(20, result.OrganizationOccupiedSeats);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_CouponDoesNotApplyToSubscriptionProducts_ReturnsFalseForStandaloneFlag(
|
||||
Organization organization,
|
||||
SutProvider<GetOrganizationMetadataQuery> sutProvider)
|
||||
{
|
||||
organization.GatewaySubscriptionId = "sub_123";
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
|
||||
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 =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
Plan = new Plan
|
||||
{
|
||||
ProductId = "product_123"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new OrganizationSeatCounts { Users = 12, Sponsored = 0 });
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetCustomer(organization)
|
||||
.Returns(customer);
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||
options.Expand.Contains("discounts.coupon.applies_to")))
|
||||
.Returns(subscription);
|
||||
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(organization.PlanType)
|
||||
.Returns(StaticStore.GetPlan(organization.PlanType));
|
||||
|
||||
var result = await sutProvider.Sut.Run(organization);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsOnSecretsManagerStandalone);
|
||||
Assert.Equal(12, result.OrganizationOccupiedSeats);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_PlanDoesNotSupportSecretsManager_ReturnsFalseForStandaloneFlag(
|
||||
Organization organization,
|
||||
SutProvider<GetOrganizationMetadataQuery> sutProvider)
|
||||
{
|
||||
organization.GatewaySubscriptionId = "sub_123";
|
||||
organization.PlanType = PlanType.FamiliesAnnually;
|
||||
|
||||
var productId = "product_123";
|
||||
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 =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
Plan = new Plan
|
||||
{
|
||||
ProductId = productId
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new OrganizationSeatCounts { Users = 8, Sponsored = 0 });
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetCustomer(organization)
|
||||
.Returns(customer);
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||
options.Expand.Contains("discounts.coupon.applies_to")))
|
||||
.Returns(subscription);
|
||||
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(organization.PlanType)
|
||||
.Returns(StaticStore.GetPlan(organization.PlanType));
|
||||
|
||||
var result = await sutProvider.Sut.Run(organization);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsOnSecretsManagerStandalone);
|
||||
Assert.Equal(8, result.OrganizationOccupiedSeats);
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Caches;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Organizations.Queries;
|
||||
using Bit.Core.Billing.Payment.Queries;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Services;
|
||||
@@ -75,7 +75,7 @@ public class GetOrganizationWarningsQueryTests
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().EditSubscription(organization.Id).Returns(true);
|
||||
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(organization.Id).Returns((string?)null);
|
||||
sutProvider.GetDependency<IHasPaymentMethodQuery>().Run(organization).Returns(false);
|
||||
|
||||
var response = await sutProvider.Sut.Run(organization);
|
||||
|
||||
@@ -86,12 +86,11 @@ public class GetOrganizationWarningsQueryTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_Has_FreeTrialWarning_WithUnverifiedBankAccount_NoWarning(
|
||||
public async Task Run_Has_FreeTrialWarning_WithPaymentMethod_NoWarning(
|
||||
Organization organization,
|
||||
SutProvider<GetOrganizationWarningsQuery> sutProvider)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
const string setupIntentId = "setup_intent_id";
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
|
||||
@@ -113,20 +112,7 @@ public class GetOrganizationWarningsQueryTests
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<ICurrentContext>().EditSubscription(organization.Id).Returns(true);
|
||||
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(organization.Id).Returns(setupIntentId);
|
||||
sutProvider.GetDependency<IStripeAdapter>().SetupIntentGet(setupIntentId, Arg.Is<SetupIntentGetOptions>(
|
||||
options => options.Expand.Contains("payment_method"))).Returns(new SetupIntent
|
||||
{
|
||||
Status = "requires_action",
|
||||
NextAction = new SetupIntentNextAction
|
||||
{
|
||||
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
|
||||
},
|
||||
PaymentMethod = new PaymentMethod
|
||||
{
|
||||
UsBankAccount = new PaymentMethodUsBankAccount()
|
||||
}
|
||||
});
|
||||
sutProvider.GetDependency<IHasPaymentMethodQuery>().Run(organization).Returns(true);
|
||||
|
||||
var response = await sutProvider.Sut.Run(organization);
|
||||
|
||||
@@ -286,7 +272,16 @@ public class GetOrganizationWarningsQueryTests
|
||||
CollectionMethod = CollectionMethod.SendInvoice,
|
||||
Customer = new Customer(),
|
||||
Status = SubscriptionStatus.Active,
|
||||
CurrentPeriodEnd = now.AddDays(10),
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = now.AddDays(10)
|
||||
}
|
||||
]
|
||||
},
|
||||
TestClock = new TestClock
|
||||
{
|
||||
FrozenTime = now
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Payment.Clients;
|
||||
using Bit.Core.Billing.Payment.Commands;
|
||||
using Bit.Core.Entities;
|
||||
@@ -11,12 +12,18 @@ using Invoice = BitPayLight.Models.Invoice.Invoice;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Payment.Commands;
|
||||
|
||||
using static BitPayConstants;
|
||||
|
||||
public class CreateBitPayInvoiceForCreditCommandTests
|
||||
{
|
||||
private readonly IBitPayClient _bitPayClient = Substitute.For<IBitPayClient>();
|
||||
private readonly GlobalSettings _globalSettings = new()
|
||||
{
|
||||
BitPay = new GlobalSettings.BitPaySettings { NotificationUrl = "https://example.com/bitpay/notification" }
|
||||
BitPay = new GlobalSettings.BitPaySettings
|
||||
{
|
||||
NotificationUrl = "https://example.com/bitpay/notification",
|
||||
WebhookKey = "test-webhook-key"
|
||||
}
|
||||
};
|
||||
private const string _redirectUrl = "https://bitwarden.com/redirect";
|
||||
private readonly CreateBitPayInvoiceForCreditCommand _command;
|
||||
@@ -37,8 +44,8 @@ public class CreateBitPayInvoiceForCreditCommandTests
|
||||
_bitPayClient.CreateInvoice(Arg.Is<Invoice>(options =>
|
||||
options.Buyer.Email == user.Email &&
|
||||
options.Buyer.Name == user.Email &&
|
||||
options.NotificationUrl == _globalSettings.BitPay.NotificationUrl &&
|
||||
options.PosData == $"userId:{user.Id},accountCredit:1" &&
|
||||
options.NotificationUrl == $"{_globalSettings.BitPay.NotificationUrl}?key={_globalSettings.BitPay.WebhookKey}" &&
|
||||
options.PosData == $"userId:{user.Id},{PosDataKeys.AccountCredit}" &&
|
||||
// ReSharper disable once CompareOfFloatsByEqualityOperator
|
||||
options.Price == Convert.ToDouble(10M) &&
|
||||
options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" });
|
||||
@@ -58,8 +65,8 @@ public class CreateBitPayInvoiceForCreditCommandTests
|
||||
_bitPayClient.CreateInvoice(Arg.Is<Invoice>(options =>
|
||||
options.Buyer.Email == organization.BillingEmail &&
|
||||
options.Buyer.Name == organization.Name &&
|
||||
options.NotificationUrl == _globalSettings.BitPay.NotificationUrl &&
|
||||
options.PosData == $"organizationId:{organization.Id},accountCredit:1" &&
|
||||
options.NotificationUrl == $"{_globalSettings.BitPay.NotificationUrl}?key={_globalSettings.BitPay.WebhookKey}" &&
|
||||
options.PosData == $"organizationId:{organization.Id},{PosDataKeys.AccountCredit}" &&
|
||||
// ReSharper disable once CompareOfFloatsByEqualityOperator
|
||||
options.Price == Convert.ToDouble(10M) &&
|
||||
options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" });
|
||||
@@ -79,8 +86,8 @@ public class CreateBitPayInvoiceForCreditCommandTests
|
||||
_bitPayClient.CreateInvoice(Arg.Is<Invoice>(options =>
|
||||
options.Buyer.Email == provider.BillingEmail &&
|
||||
options.Buyer.Name == provider.Name &&
|
||||
options.NotificationUrl == _globalSettings.BitPay.NotificationUrl &&
|
||||
options.PosData == $"providerId:{provider.Id},accountCredit:1" &&
|
||||
options.NotificationUrl == $"{_globalSettings.BitPay.NotificationUrl}?key={_globalSettings.BitPay.WebhookKey}" &&
|
||||
options.PosData == $"providerId:{provider.Id},{PosDataKeys.AccountCredit}" &&
|
||||
// ReSharper disable once CompareOfFloatsByEqualityOperator
|
||||
options.Price == Convert.ToDouble(10M) &&
|
||||
options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" });
|
||||
|
||||
112
test/Core.Test/Billing/Payment/Models/PaymentMethodTests.cs
Normal file
112
test/Core.Test/Billing/Payment/Models/PaymentMethodTests.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using System.Text.Json;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Payment.Models;
|
||||
|
||||
public class PaymentMethodTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("{\"cardNumber\":\"1234\"}")]
|
||||
[InlineData("{\"type\":\"unknown_type\",\"data\":\"value\"}")]
|
||||
[InlineData("{\"type\":\"invalid\",\"token\":\"test-token\"}")]
|
||||
[InlineData("{\"type\":\"invalid\"}")]
|
||||
public void Read_ShouldThrowJsonException_OnInvalidOrMissingType(string json)
|
||||
{
|
||||
// Arrange
|
||||
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<PaymentMethod>(json, options));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"type\":\"card\"}")]
|
||||
[InlineData("{\"type\":\"card\",\"token\":\"\"}")]
|
||||
[InlineData("{\"type\":\"card\",\"token\":null}")]
|
||||
public void Read_ShouldThrowJsonException_OnInvalidTokenizedPaymentMethodToken(string json)
|
||||
{
|
||||
// Arrange
|
||||
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<PaymentMethod>(json, options));
|
||||
}
|
||||
|
||||
// Tokenized payment method deserialization
|
||||
[Theory]
|
||||
[InlineData("bankAccount", TokenizablePaymentMethodType.BankAccount)]
|
||||
[InlineData("card", TokenizablePaymentMethodType.Card)]
|
||||
[InlineData("payPal", TokenizablePaymentMethodType.PayPal)]
|
||||
public void Read_ShouldDeserializeTokenizedPaymentMethods(string typeString, TokenizablePaymentMethodType expectedType)
|
||||
{
|
||||
// Arrange
|
||||
var json = $"{{\"type\":\"{typeString}\",\"token\":\"test-token\"}}";
|
||||
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
|
||||
|
||||
// Act
|
||||
var result = JsonSerializer.Deserialize<PaymentMethod>(json, options);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsTokenized);
|
||||
Assert.Equal(expectedType, result.AsT0.Type);
|
||||
Assert.Equal("test-token", result.AsT0.Token);
|
||||
}
|
||||
|
||||
// Non-tokenized payment method deserialization
|
||||
[Theory]
|
||||
[InlineData("accountcredit", NonTokenizablePaymentMethodType.AccountCredit)]
|
||||
public void Read_ShouldDeserializeNonTokenizedPaymentMethods(string typeString, NonTokenizablePaymentMethodType expectedType)
|
||||
{
|
||||
// Arrange
|
||||
var json = $"{{\"type\":\"{typeString}\"}}";
|
||||
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
|
||||
|
||||
// Act
|
||||
var result = JsonSerializer.Deserialize<PaymentMethod>(json, options);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsNonTokenized);
|
||||
Assert.Equal(expectedType, result.AsT1.Type);
|
||||
}
|
||||
|
||||
// Tokenized payment method serialization
|
||||
[Theory]
|
||||
[InlineData(TokenizablePaymentMethodType.BankAccount, "bankaccount")]
|
||||
[InlineData(TokenizablePaymentMethodType.Card, "card")]
|
||||
[InlineData(TokenizablePaymentMethodType.PayPal, "paypal")]
|
||||
public void Write_ShouldSerializeTokenizedPaymentMethods(TokenizablePaymentMethodType type, string expectedTypeString)
|
||||
{
|
||||
// Arrange
|
||||
var paymentMethod = new PaymentMethod(new TokenizedPaymentMethod
|
||||
{
|
||||
Type = type,
|
||||
Token = "test-token"
|
||||
});
|
||||
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(paymentMethod, options);
|
||||
|
||||
// Assert
|
||||
Assert.Contains($"\"type\":\"{expectedTypeString}\"", json);
|
||||
Assert.Contains("\"token\":\"test-token\"", json);
|
||||
}
|
||||
|
||||
// Non-tokenized payment method serialization
|
||||
[Theory]
|
||||
[InlineData(NonTokenizablePaymentMethodType.AccountCredit, "accountcredit")]
|
||||
public void Write_ShouldSerializeNonTokenizedPaymentMethods(NonTokenizablePaymentMethodType type, string expectedTypeString)
|
||||
{
|
||||
// Arrange
|
||||
var paymentMethod = new PaymentMethod(new NonTokenizedPaymentMethod { Type = type });
|
||||
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(paymentMethod, options);
|
||||
|
||||
// Assert
|
||||
Assert.Contains($"\"type\":\"{expectedTypeString}\"", json);
|
||||
Assert.DoesNotContain("token", json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Caches;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Payment.Queries;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Test.Billing.Extensions;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Payment.Queries;
|
||||
|
||||
using static StripeConstants;
|
||||
|
||||
public class HasPaymentMethodQueryTests
|
||||
{
|
||||
private readonly ISetupIntentCache _setupIntentCache = Substitute.For<ISetupIntentCache>();
|
||||
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
|
||||
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
|
||||
private readonly HasPaymentMethodQuery _query;
|
||||
|
||||
public HasPaymentMethodQueryTests()
|
||||
{
|
||||
_query = new HasPaymentMethodQuery(
|
||||
_setupIntentCache,
|
||||
_stripeAdapter,
|
||||
_subscriberService);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_NoCustomer_ReturnsFalse()
|
||||
{
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = Guid.NewGuid()
|
||||
};
|
||||
|
||||
_subscriberService.GetCustomer(organization).ReturnsNull();
|
||||
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns((string)null);
|
||||
|
||||
var hasPaymentMethod = await _query.Run(organization);
|
||||
|
||||
Assert.False(hasPaymentMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_NoCustomer_WithUnverifiedBankAccount_ReturnsTrue()
|
||||
{
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = Guid.NewGuid()
|
||||
};
|
||||
|
||||
_subscriberService.GetCustomer(organization).ReturnsNull();
|
||||
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123");
|
||||
|
||||
_stripeAdapter
|
||||
.SetupIntentGet("seti_123",
|
||||
Arg.Is<SetupIntentGetOptions>(options => options.HasExpansions("payment_method")))
|
||||
.Returns(new SetupIntent
|
||||
{
|
||||
Status = "requires_action",
|
||||
NextAction = new SetupIntentNextAction
|
||||
{
|
||||
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
|
||||
},
|
||||
PaymentMethod = new PaymentMethod
|
||||
{
|
||||
UsBankAccount = new PaymentMethodUsBankAccount()
|
||||
}
|
||||
});
|
||||
|
||||
var hasPaymentMethod = await _query.Run(organization);
|
||||
|
||||
Assert.True(hasPaymentMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_NoPaymentMethod_ReturnsFalse()
|
||||
{
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = Guid.NewGuid()
|
||||
};
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
InvoiceSettings = new CustomerInvoiceSettings(),
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
_subscriberService.GetCustomer(organization).Returns(customer);
|
||||
|
||||
var hasPaymentMethod = await _query.Run(organization);
|
||||
|
||||
Assert.False(hasPaymentMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_HasDefaultPaymentMethodId_ReturnsTrue()
|
||||
{
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = Guid.NewGuid()
|
||||
};
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
InvoiceSettings = new CustomerInvoiceSettings
|
||||
{
|
||||
DefaultPaymentMethodId = "pm_123"
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
_subscriberService.GetCustomer(organization).Returns(customer);
|
||||
|
||||
var hasPaymentMethod = await _query.Run(organization);
|
||||
|
||||
Assert.True(hasPaymentMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_HasDefaultSourceId_ReturnsTrue()
|
||||
{
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = Guid.NewGuid()
|
||||
};
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
DefaultSourceId = "card_123",
|
||||
InvoiceSettings = new CustomerInvoiceSettings(),
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
_subscriberService.GetCustomer(organization).Returns(customer);
|
||||
|
||||
var hasPaymentMethod = await _query.Run(organization);
|
||||
|
||||
Assert.True(hasPaymentMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_HasUnverifiedBankAccount_ReturnsTrue()
|
||||
{
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = Guid.NewGuid()
|
||||
};
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
InvoiceSettings = new CustomerInvoiceSettings(),
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
_subscriberService.GetCustomer(organization).Returns(customer);
|
||||
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123");
|
||||
|
||||
_stripeAdapter
|
||||
.SetupIntentGet("seti_123",
|
||||
Arg.Is<SetupIntentGetOptions>(options => options.HasExpansions("payment_method")))
|
||||
.Returns(new SetupIntent
|
||||
{
|
||||
Status = "requires_action",
|
||||
NextAction = new SetupIntentNextAction
|
||||
{
|
||||
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
|
||||
},
|
||||
PaymentMethod = new PaymentMethod
|
||||
{
|
||||
UsBankAccount = new PaymentMethodUsBankAccount()
|
||||
}
|
||||
});
|
||||
|
||||
var hasPaymentMethod = await _query.Run(organization);
|
||||
|
||||
Assert.True(hasPaymentMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_HasBraintreeCustomerId_ReturnsTrue()
|
||||
{
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = Guid.NewGuid()
|
||||
};
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
InvoiceSettings = new CustomerInvoiceSettings(),
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[MetadataKeys.BraintreeCustomerId] = "braintree_customer_id"
|
||||
}
|
||||
};
|
||||
|
||||
_subscriberService.GetCustomer(organization).Returns(customer);
|
||||
|
||||
var hasPaymentMethod = await _query.Run(organization);
|
||||
|
||||
Assert.True(hasPaymentMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_NoSetupIntentId_ReturnsFalse()
|
||||
{
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = Guid.NewGuid()
|
||||
};
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
InvoiceSettings = new CustomerInvoiceSettings(),
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
_subscriberService.GetCustomer(organization).Returns(customer);
|
||||
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns((string)null);
|
||||
|
||||
var hasPaymentMethod = await _query.Run(organization);
|
||||
|
||||
Assert.False(hasPaymentMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_SetupIntentNotBankAccount_ReturnsFalse()
|
||||
{
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = Guid.NewGuid()
|
||||
};
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
InvoiceSettings = new CustomerInvoiceSettings(),
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
_subscriberService.GetCustomer(organization).Returns(customer);
|
||||
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123");
|
||||
|
||||
_stripeAdapter
|
||||
.SetupIntentGet("seti_123",
|
||||
Arg.Is<SetupIntentGetOptions>(options => options.HasExpansions("payment_method")))
|
||||
.Returns(new SetupIntent
|
||||
{
|
||||
PaymentMethod = new PaymentMethod
|
||||
{
|
||||
Type = "card"
|
||||
},
|
||||
Status = "succeeded"
|
||||
});
|
||||
|
||||
var hasPaymentMethod = await _query.Run(organization);
|
||||
|
||||
Assert.False(hasPaymentMethod);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
using Bit.Core.Billing.Caches;
|
||||
using Bit.Core.Billing;
|
||||
using Bit.Core.Billing.Caches;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Payment.Commands;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
using Bit.Core.Billing.Payment.Queries;
|
||||
using Bit.Core.Billing.Premium.Commands;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Platform.Push;
|
||||
@@ -13,6 +19,8 @@ using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
using Address = Stripe.Address;
|
||||
using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan;
|
||||
using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable;
|
||||
using StripeCustomer = Stripe.Customer;
|
||||
using StripeSubscription = Stripe.Subscription;
|
||||
|
||||
@@ -27,6 +35,9 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
|
||||
private readonly IUserService _userService = Substitute.For<IUserService>();
|
||||
private readonly IPushNotificationService _pushNotificationService = Substitute.For<IPushNotificationService>();
|
||||
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
|
||||
private readonly IHasPaymentMethodQuery _hasPaymentMethodQuery = Substitute.For<IHasPaymentMethodQuery>();
|
||||
private readonly IUpdatePaymentMethodCommand _updatePaymentMethodCommand = Substitute.For<IUpdatePaymentMethodCommand>();
|
||||
private readonly CreatePremiumCloudHostedSubscriptionCommand _command;
|
||||
|
||||
public CreatePremiumCloudHostedSubscriptionCommandTests()
|
||||
@@ -35,6 +46,17 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
baseServiceUri.CloudRegion.Returns("US");
|
||||
_globalSettings.BaseServiceUri.Returns(baseServiceUri);
|
||||
|
||||
// Setup default premium plan with standard pricing
|
||||
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 }
|
||||
};
|
||||
_pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan);
|
||||
|
||||
_command = new CreatePremiumCloudHostedSubscriptionCommand(
|
||||
_braintreeGateway,
|
||||
_globalSettings,
|
||||
@@ -43,7 +65,10 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
_subscriberService,
|
||||
_userService,
|
||||
_pushNotificationService,
|
||||
Substitute.For<ILogger<CreatePremiumCloudHostedSubscriptionCommand>>());
|
||||
Substitute.For<ILogger<CreatePremiumCloudHostedSubscriptionCommand>>(),
|
||||
_pricingClient,
|
||||
_hasPaymentMethodQuery,
|
||||
_updatePaymentMethodCommand);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -105,6 +130,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
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)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var mockInvoice = Substitute.For<Invoice>();
|
||||
|
||||
@@ -152,6 +187,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
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)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var mockInvoice = Substitute.For<Invoice>();
|
||||
|
||||
@@ -241,7 +286,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
var mockSubscription = Substitute.For<StripeSubscription>();
|
||||
mockSubscription.Id = "sub_123";
|
||||
mockSubscription.Status = "active";
|
||||
mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30);
|
||||
mockSubscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var mockInvoice = Substitute.For<Invoice>();
|
||||
|
||||
@@ -266,7 +320,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_UserHasExistingGatewayCustomerId_UsesExistingCustomer(
|
||||
public async Task Run_UserHasExistingGatewayCustomerIdAndPaymentMethod_UsesExistingCustomer(
|
||||
User user,
|
||||
TokenizedPaymentMethod paymentMethod,
|
||||
BillingAddress billingAddress)
|
||||
@@ -286,9 +340,21 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
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)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var mockInvoice = Substitute.For<Invoice>();
|
||||
|
||||
// Mock that the user has a payment method (this is the key difference from the credit purchase case)
|
||||
_hasPaymentMethodQuery.Run(Arg.Any<User>()).Returns(true);
|
||||
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
|
||||
_stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(mockSubscription);
|
||||
_stripeAdapter.InvoiceUpdateAsync(Arg.Any<string>(), Arg.Any<InvoiceUpdateOptions>()).Returns(mockInvoice);
|
||||
@@ -300,6 +366,75 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
Assert.True(result.IsT0);
|
||||
await _subscriberService.Received(1).GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>());
|
||||
await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any<CustomerCreateOptions>());
|
||||
await _updatePaymentMethodCommand.DidNotReceive().Run(Arg.Any<User>(), Arg.Any<TokenizedPaymentMethod>(), Arg.Any<BillingAddress>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_UserPreviouslyPurchasedCreditWithoutPaymentMethod_UpdatesPaymentMethodAndCreatesSubscription(
|
||||
User user,
|
||||
TokenizedPaymentMethod paymentMethod,
|
||||
BillingAddress billingAddress)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = false;
|
||||
user.GatewayCustomerId = "existing_customer_123"; // Customer exists from previous credit purchase
|
||||
paymentMethod.Type = TokenizablePaymentMethodType.Card;
|
||||
paymentMethod.Token = "card_token_123";
|
||||
billingAddress.Country = "US";
|
||||
billingAddress.PostalCode = "12345";
|
||||
|
||||
var mockCustomer = Substitute.For<StripeCustomer>();
|
||||
mockCustomer.Id = "existing_customer_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)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var mockInvoice = Substitute.For<Invoice>();
|
||||
MaskedPaymentMethod mockMaskedPaymentMethod = new MaskedCard
|
||||
{
|
||||
Brand = "visa",
|
||||
Last4 = "1234",
|
||||
Expiration = "12/2025"
|
||||
};
|
||||
|
||||
// Mock that the user does NOT have a payment method (simulating credit purchase scenario)
|
||||
_hasPaymentMethodQuery.Run(Arg.Any<User>()).Returns(false);
|
||||
_updatePaymentMethodCommand.Run(Arg.Any<User>(), Arg.Any<TokenizedPaymentMethod>(), Arg.Any<BillingAddress>())
|
||||
.Returns(mockMaskedPaymentMethod);
|
||||
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
|
||||
_stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(mockSubscription);
|
||||
_stripeAdapter.InvoiceUpdateAsync(Arg.Any<string>(), Arg.Any<InvoiceUpdateOptions>()).Returns(mockInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
// Verify that update payment method was called (new behavior for credit purchase case)
|
||||
await _updatePaymentMethodCommand.Received(1).Run(user, paymentMethod, billingAddress);
|
||||
// Verify GetCustomerOrThrow was called after updating payment method
|
||||
await _subscriberService.Received(1).GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>());
|
||||
// Verify no new customer was created
|
||||
await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any<CustomerCreateOptions>());
|
||||
// Verify subscription was created
|
||||
await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
|
||||
// Verify user was updated correctly
|
||||
Assert.True(user.Premium);
|
||||
await _userService.Received(1).SaveUserAsync(user);
|
||||
await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -326,7 +461,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
var mockSubscription = Substitute.For<StripeSubscription>();
|
||||
mockSubscription.Id = "sub_123";
|
||||
mockSubscription.Status = "incomplete";
|
||||
mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30);
|
||||
mockSubscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var mockInvoice = Substitute.For<Invoice>();
|
||||
|
||||
@@ -342,7 +486,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
Assert.True(user.Premium);
|
||||
Assert.Equal(mockSubscription.CurrentPeriodEnd, user.PremiumExpirationDate);
|
||||
Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -368,7 +512,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
var mockSubscription = Substitute.For<StripeSubscription>();
|
||||
mockSubscription.Id = "sub_123";
|
||||
mockSubscription.Status = "active";
|
||||
mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30);
|
||||
mockSubscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var mockInvoice = Substitute.For<Invoice>();
|
||||
|
||||
@@ -384,7 +537,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
Assert.True(user.Premium);
|
||||
Assert.Equal(mockSubscription.CurrentPeriodEnd, user.PremiumExpirationDate);
|
||||
Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -411,7 +564,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
var mockSubscription = Substitute.For<StripeSubscription>();
|
||||
mockSubscription.Id = "sub_123";
|
||||
mockSubscription.Status = "active"; // PayPal + active doesn't match pattern
|
||||
mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30);
|
||||
mockSubscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var mockInvoice = Substitute.For<Invoice>();
|
||||
|
||||
@@ -453,7 +615,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
var mockSubscription = Substitute.For<StripeSubscription>();
|
||||
mockSubscription.Id = "sub_123";
|
||||
mockSubscription.Status = "incomplete";
|
||||
mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30);
|
||||
mockSubscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var mockInvoice = Substitute.For<Invoice>();
|
||||
|
||||
@@ -474,4 +645,79 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
var unhandled = result.AsT3;
|
||||
Assert.Equal("Something went wrong with your request. Please contact support for assistance.", unhandled.Response);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_AccountCredit_WithExistingCustomer_Success(
|
||||
User user,
|
||||
NonTokenizedPaymentMethod paymentMethod,
|
||||
BillingAddress billingAddress)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = false;
|
||||
user.GatewayCustomerId = "existing_customer_123";
|
||||
paymentMethod.Type = NonTokenizablePaymentMethodType.AccountCredit;
|
||||
billingAddress.Country = "US";
|
||||
billingAddress.PostalCode = "12345";
|
||||
|
||||
var mockCustomer = Substitute.For<StripeCustomer>();
|
||||
mockCustomer.Id = "existing_customer_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)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var mockInvoice = Substitute.For<Invoice>();
|
||||
|
||||
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
|
||||
_stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(mockSubscription);
|
||||
_stripeAdapter.InvoiceUpdateAsync(Arg.Any<string>(), Arg.Any<InvoiceUpdateOptions>()).Returns(mockInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
await _subscriberService.Received(1).GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>());
|
||||
await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any<CustomerCreateOptions>());
|
||||
Assert.True(user.Premium);
|
||||
Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_NonTokenizedPaymentWithoutExistingCustomer_ThrowsBillingException(
|
||||
User user,
|
||||
NonTokenizedPaymentMethod paymentMethod,
|
||||
BillingAddress billingAddress)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = false;
|
||||
// No existing gateway customer ID
|
||||
user.GatewayCustomerId = null;
|
||||
paymentMethod.Type = NonTokenizablePaymentMethodType.AccountCredit;
|
||||
billingAddress.Country = "US";
|
||||
billingAddress.PostalCode = "12345";
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
|
||||
|
||||
//Assert
|
||||
Assert.True(result.IsT3); // Assuming T3 is the Unhandled result
|
||||
Assert.IsType<BillingException>(result.AsT3.Exception);
|
||||
// Verify no customer was created or subscription attempted
|
||||
await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any<CustomerCreateOptions>());
|
||||
await _stripeAdapter.DidNotReceive().SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
|
||||
await _userService.DidNotReceive().SaveUserAsync(Arg.Any<User>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
using Bit.Core.Billing.Premium.Commands;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
using static Bit.Core.Billing.Constants.StripeConstants;
|
||||
using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan;
|
||||
using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Premium.Commands;
|
||||
|
||||
public class PreviewPremiumTaxCommandTests
|
||||
{
|
||||
private readonly ILogger<PreviewPremiumTaxCommand> _logger = Substitute.For<ILogger<PreviewPremiumTaxCommand>>();
|
||||
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
|
||||
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
|
||||
private readonly PreviewPremiumTaxCommand _command;
|
||||
|
||||
public PreviewPremiumTaxCommandTests()
|
||||
{
|
||||
// Setup default premium plan with standard pricing
|
||||
var premiumPlan = new PremiumPlan
|
||||
{
|
||||
Name = "Premium",
|
||||
Available = true,
|
||||
LegacyYear = null,
|
||||
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
|
||||
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
|
||||
};
|
||||
_pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan);
|
||||
|
||||
_command = new PreviewPremiumTaxCommand(_logger, _pricingClient, _stripeAdapter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_PremiumWithoutStorage_ReturnsCorrectTaxAmounts()
|
||||
{
|
||||
var billingAddress = new BillingAddress
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345"
|
||||
};
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 300 }],
|
||||
Total = 3300
|
||||
};
|
||||
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>()).Returns(invoice);
|
||||
|
||||
var result = await _command.Run(0, billingAddress);
|
||||
|
||||
Assert.True(result.IsT0);
|
||||
var (tax, total) = result.AsT0;
|
||||
Assert.Equal(3.00m, tax);
|
||||
Assert.Equal(33.00m, total);
|
||||
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true &&
|
||||
options.Currency == "usd" &&
|
||||
options.CustomerDetails.Address.Country == "US" &&
|
||||
options.CustomerDetails.Address.PostalCode == "12345" &&
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_PremiumWithAdditionalStorage_ReturnsCorrectTaxAmounts()
|
||||
{
|
||||
var billingAddress = new BillingAddress
|
||||
{
|
||||
Country = "CA",
|
||||
PostalCode = "K1A 0A6"
|
||||
};
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 500 }],
|
||||
Total = 5500
|
||||
};
|
||||
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>()).Returns(invoice);
|
||||
|
||||
var result = await _command.Run(5, billingAddress);
|
||||
|
||||
Assert.True(result.IsT0);
|
||||
var (tax, total) = result.AsT0;
|
||||
Assert.Equal(5.00m, tax);
|
||||
Assert.Equal(55.00m, total);
|
||||
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true &&
|
||||
options.Currency == "usd" &&
|
||||
options.CustomerDetails.Address.Country == "CA" &&
|
||||
options.CustomerDetails.Address.PostalCode == "K1A 0A6" &&
|
||||
options.SubscriptionDetails.Items.Count == 2 &&
|
||||
options.SubscriptionDetails.Items.Any(item =>
|
||||
item.Price == Prices.PremiumAnnually && item.Quantity == 1) &&
|
||||
options.SubscriptionDetails.Items.Any(item =>
|
||||
item.Price == Prices.StoragePlanPersonal && item.Quantity == 5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_PremiumWithZeroStorage_ExcludesStorageFromItems()
|
||||
{
|
||||
var billingAddress = new BillingAddress
|
||||
{
|
||||
Country = "GB",
|
||||
PostalCode = "SW1A 1AA"
|
||||
};
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 250 }],
|
||||
Total = 2750
|
||||
};
|
||||
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>()).Returns(invoice);
|
||||
|
||||
var result = await _command.Run(0, billingAddress);
|
||||
|
||||
Assert.True(result.IsT0);
|
||||
var (tax, total) = result.AsT0;
|
||||
Assert.Equal(2.50m, tax);
|
||||
Assert.Equal(27.50m, total);
|
||||
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true &&
|
||||
options.Currency == "usd" &&
|
||||
options.CustomerDetails.Address.Country == "GB" &&
|
||||
options.CustomerDetails.Address.PostalCode == "SW1A 1AA" &&
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_PremiumWithLargeStorage_HandlesMultipleStorageUnits()
|
||||
{
|
||||
var billingAddress = new BillingAddress
|
||||
{
|
||||
Country = "DE",
|
||||
PostalCode = "10115"
|
||||
};
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 800 }],
|
||||
Total = 8800
|
||||
};
|
||||
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>()).Returns(invoice);
|
||||
|
||||
var result = await _command.Run(20, billingAddress);
|
||||
|
||||
Assert.True(result.IsT0);
|
||||
var (tax, total) = result.AsT0;
|
||||
Assert.Equal(8.00m, tax);
|
||||
Assert.Equal(88.00m, total);
|
||||
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true &&
|
||||
options.Currency == "usd" &&
|
||||
options.CustomerDetails.Address.Country == "DE" &&
|
||||
options.CustomerDetails.Address.PostalCode == "10115" &&
|
||||
options.SubscriptionDetails.Items.Count == 2 &&
|
||||
options.SubscriptionDetails.Items.Any(item =>
|
||||
item.Price == Prices.PremiumAnnually && item.Quantity == 1) &&
|
||||
options.SubscriptionDetails.Items.Any(item =>
|
||||
item.Price == Prices.StoragePlanPersonal && item.Quantity == 20)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_PremiumInternationalAddress_UsesCorrectAddressInfo()
|
||||
{
|
||||
var billingAddress = new BillingAddress
|
||||
{
|
||||
Country = "AU",
|
||||
PostalCode = "2000"
|
||||
};
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 450 }],
|
||||
Total = 4950
|
||||
};
|
||||
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>()).Returns(invoice);
|
||||
|
||||
var result = await _command.Run(10, billingAddress);
|
||||
|
||||
Assert.True(result.IsT0);
|
||||
var (tax, total) = result.AsT0;
|
||||
Assert.Equal(4.50m, tax);
|
||||
Assert.Equal(49.50m, total);
|
||||
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true &&
|
||||
options.Currency == "usd" &&
|
||||
options.CustomerDetails.Address.Country == "AU" &&
|
||||
options.CustomerDetails.Address.PostalCode == "2000" &&
|
||||
options.SubscriptionDetails.Items.Count == 2 &&
|
||||
options.SubscriptionDetails.Items.Any(item =>
|
||||
item.Price == Prices.PremiumAnnually && item.Quantity == 1) &&
|
||||
options.SubscriptionDetails.Items.Any(item =>
|
||||
item.Price == Prices.StoragePlanPersonal && item.Quantity == 10)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_PremiumNoTax_ReturnsZeroTax()
|
||||
{
|
||||
var billingAddress = new BillingAddress
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "97330" // Example of a tax-free jurisdiction
|
||||
};
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 0 }],
|
||||
Total = 3000
|
||||
};
|
||||
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>()).Returns(invoice);
|
||||
|
||||
var result = await _command.Run(0, billingAddress);
|
||||
|
||||
Assert.True(result.IsT0);
|
||||
var (tax, total) = result.AsT0;
|
||||
Assert.Equal(0.00m, tax);
|
||||
Assert.Equal(30.00m, total);
|
||||
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true &&
|
||||
options.Currency == "usd" &&
|
||||
options.CustomerDetails.Address.Country == "US" &&
|
||||
options.CustomerDetails.Address.PostalCode == "97330" &&
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_NegativeStorage_TreatedAsZero()
|
||||
{
|
||||
var billingAddress = new BillingAddress
|
||||
{
|
||||
Country = "FR",
|
||||
PostalCode = "75001"
|
||||
};
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 600 }],
|
||||
Total = 6600
|
||||
};
|
||||
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>()).Returns(invoice);
|
||||
|
||||
var result = await _command.Run(-5, billingAddress);
|
||||
|
||||
Assert.True(result.IsT0);
|
||||
var (tax, total) = result.AsT0;
|
||||
Assert.Equal(6.00m, tax);
|
||||
Assert.Equal(66.00m, total);
|
||||
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true &&
|
||||
options.Currency == "usd" &&
|
||||
options.CustomerDetails.Address.Country == "FR" &&
|
||||
options.CustomerDetails.Address.PostalCode == "75001" &&
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_AmountConversion_CorrectlyConvertsStripeAmounts()
|
||||
{
|
||||
var billingAddress = new BillingAddress
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345"
|
||||
};
|
||||
|
||||
// Stripe amounts are in cents
|
||||
var invoice = new Invoice
|
||||
{
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 123 }], // $1.23
|
||||
Total = 3123 // $31.23
|
||||
};
|
||||
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>()).Returns(invoice);
|
||||
|
||||
var result = await _command.Run(0, billingAddress);
|
||||
|
||||
Assert.True(result.IsT0);
|
||||
var (tax, total) = result.AsT0;
|
||||
Assert.Equal(1.23m, tax);
|
||||
Assert.Equal(31.23m, total);
|
||||
}
|
||||
}
|
||||
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""
|
||||
}}
|
||||
}}";
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Models.Sales;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Organizations.Services;
|
||||
using Bit.Core.Billing.Payment.Queries;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
@@ -33,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
|
||||
{
|
||||
@@ -67,8 +73,8 @@ public class OrganizationBillingServiceTests
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
|
||||
@@ -96,27 +102,254 @@ public class OrganizationBillingServiceTests
|
||||
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType)
|
||||
.Returns(StaticStore.GetPlan(organization.PlanType));
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new OrganizationSeatCounts { Users = 1, Sponsored = 0 });
|
||||
|
||||
var subscriberService = sutProvider.GetDependency<ISubscriberService>();
|
||||
|
||||
// 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);
|
||||
|
||||
Assert.NotNull(metadata);
|
||||
Assert.False(metadata!.IsOnSecretsManagerStandalone);
|
||||
Assert.False(metadata.HasSubscription);
|
||||
Assert.False(metadata.IsSubscriptionUnpaid);
|
||||
Assert.False(metadata.HasOpenInvoice);
|
||||
Assert.False(metadata.IsSubscriptionCanceled);
|
||||
Assert.Null(metadata.InvoiceDueDate);
|
||||
Assert.Null(metadata.InvoiceCreatedDate);
|
||||
Assert.Null(metadata.SubPeriodEndDate);
|
||||
Assert.Equal(1, metadata.OrganizationOccupiedSeats);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Finalize - Trial Settings
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task NoPaymentMethodAndTrialPeriod_SetsMissingPaymentMethodCancelBehavior(
|
||||
Organization organization,
|
||||
SutProvider<OrganizationBillingService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var plan = StaticStore.GetPlan(PlanType.TeamsAnnually);
|
||||
organization.PlanType = PlanType.TeamsAnnually;
|
||||
organization.GatewayCustomerId = "cus_test123";
|
||||
organization.GatewaySubscriptionId = null;
|
||||
|
||||
var subscriptionSetup = new SubscriptionSetup
|
||||
{
|
||||
PlanType = PlanType.TeamsAnnually,
|
||||
PasswordManagerOptions = new SubscriptionSetup.PasswordManager
|
||||
{
|
||||
Seats = 5,
|
||||
Storage = null,
|
||||
PremiumAccess = false
|
||||
},
|
||||
SecretsManagerOptions = null,
|
||||
SkipTrial = false
|
||||
};
|
||||
|
||||
var sale = new OrganizationSale
|
||||
{
|
||||
Organization = organization,
|
||||
SubscriptionSetup = subscriptionSetup
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(PlanType.TeamsAnnually)
|
||||
.Returns(plan);
|
||||
|
||||
sutProvider.GetDependency<IHasPaymentMethodQuery>()
|
||||
.Run(organization)
|
||||
.Returns(false);
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = "cus_test123",
|
||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetCustomerOrThrow(organization, Arg.Any<CustomerGetOptions>())
|
||||
.Returns(customer);
|
||||
|
||||
SubscriptionCreateOptions capturedOptions = null;
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionCreateAsync(Arg.Do<SubscriptionCreateOptions>(options => capturedOptions = options))
|
||||
.Returns(new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = StripeConstants.SubscriptionStatus.Trialing
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.ReplaceAsync(organization)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.Finalize(sale);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IStripeAdapter>()
|
||||
.Received(1)
|
||||
.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
|
||||
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.Equal(7, capturedOptions.TrialPeriodDays);
|
||||
Assert.NotNull(capturedOptions.TrialSettings);
|
||||
Assert.NotNull(capturedOptions.TrialSettings.EndBehavior);
|
||||
Assert.Equal("cancel", capturedOptions.TrialSettings.EndBehavior.MissingPaymentMethod);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task NoPaymentMethodButNoTrial_DoesNotSetMissingPaymentMethodBehavior(
|
||||
Organization organization,
|
||||
SutProvider<OrganizationBillingService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var plan = StaticStore.GetPlan(PlanType.TeamsAnnually);
|
||||
organization.PlanType = PlanType.TeamsAnnually;
|
||||
organization.GatewayCustomerId = "cus_test123";
|
||||
organization.GatewaySubscriptionId = null;
|
||||
|
||||
var subscriptionSetup = new SubscriptionSetup
|
||||
{
|
||||
PlanType = PlanType.TeamsAnnually,
|
||||
PasswordManagerOptions = new SubscriptionSetup.PasswordManager
|
||||
{
|
||||
Seats = 5,
|
||||
Storage = null,
|
||||
PremiumAccess = false
|
||||
},
|
||||
SecretsManagerOptions = null,
|
||||
SkipTrial = true // This will result in TrialPeriodDays = 0
|
||||
};
|
||||
|
||||
var sale = new OrganizationSale
|
||||
{
|
||||
Organization = organization,
|
||||
SubscriptionSetup = subscriptionSetup
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(PlanType.TeamsAnnually)
|
||||
.Returns(plan);
|
||||
|
||||
sutProvider.GetDependency<IHasPaymentMethodQuery>()
|
||||
.Run(organization)
|
||||
.Returns(false);
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = "cus_test123",
|
||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetCustomerOrThrow(organization, Arg.Any<CustomerGetOptions>())
|
||||
.Returns(customer);
|
||||
|
||||
SubscriptionCreateOptions capturedOptions = null;
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionCreateAsync(Arg.Do<SubscriptionCreateOptions>(options => capturedOptions = options))
|
||||
.Returns(new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = StripeConstants.SubscriptionStatus.Active
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.ReplaceAsync(organization)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.Finalize(sale);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IStripeAdapter>()
|
||||
.Received(1)
|
||||
.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
|
||||
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.Equal(0, capturedOptions.TrialPeriodDays);
|
||||
Assert.Null(capturedOptions.TrialSettings);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task HasPaymentMethodAndTrialPeriod_DoesNotSetMissingPaymentMethodBehavior(
|
||||
Organization organization,
|
||||
SutProvider<OrganizationBillingService> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var plan = StaticStore.GetPlan(PlanType.TeamsAnnually);
|
||||
organization.PlanType = PlanType.TeamsAnnually;
|
||||
organization.GatewayCustomerId = "cus_test123";
|
||||
organization.GatewaySubscriptionId = null;
|
||||
|
||||
var subscriptionSetup = new SubscriptionSetup
|
||||
{
|
||||
PlanType = PlanType.TeamsAnnually,
|
||||
PasswordManagerOptions = new SubscriptionSetup.PasswordManager
|
||||
{
|
||||
Seats = 5,
|
||||
Storage = null,
|
||||
PremiumAccess = false
|
||||
},
|
||||
SecretsManagerOptions = null,
|
||||
SkipTrial = false
|
||||
};
|
||||
|
||||
var sale = new OrganizationSale
|
||||
{
|
||||
Organization = organization,
|
||||
SubscriptionSetup = subscriptionSetup
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
.GetPlanOrThrow(PlanType.TeamsAnnually)
|
||||
.Returns(plan);
|
||||
|
||||
sutProvider.GetDependency<IHasPaymentMethodQuery>()
|
||||
.Run(organization)
|
||||
.Returns(true); // Has payment method
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = "cus_test123",
|
||||
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<ISubscriberService>()
|
||||
.GetCustomerOrThrow(organization, Arg.Any<CustomerGetOptions>())
|
||||
.Returns(customer);
|
||||
|
||||
SubscriptionCreateOptions capturedOptions = null;
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionCreateAsync(Arg.Do<SubscriptionCreateOptions>(options => capturedOptions = options))
|
||||
.Returns(new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = StripeConstants.SubscriptionStatus.Trialing
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.ReplaceAsync(organization)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.Finalize(sale);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IStripeAdapter>()
|
||||
.Received(1)
|
||||
.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
|
||||
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.Equal(7, capturedOptions.TrialPeriodDays);
|
||||
Assert.Null(capturedOptions.TrialSettings);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Subscriptions.Commands;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Subscriptions;
|
||||
|
||||
using static StripeConstants;
|
||||
|
||||
public class RestartSubscriptionCommandTests
|
||||
{
|
||||
private readonly IOrganizationRepository _organizationRepository = Substitute.For<IOrganizationRepository>();
|
||||
private readonly IProviderRepository _providerRepository = Substitute.For<IProviderRepository>();
|
||||
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
|
||||
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
|
||||
private readonly IUserRepository _userRepository = Substitute.For<IUserRepository>();
|
||||
private readonly RestartSubscriptionCommand _command;
|
||||
|
||||
public RestartSubscriptionCommandTests()
|
||||
{
|
||||
_command = new RestartSubscriptionCommand(
|
||||
_organizationRepository,
|
||||
_providerRepository,
|
||||
_stripeAdapter,
|
||||
_subscriberService,
|
||||
_userRepository);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_SubscriptionNotCanceled_ReturnsBadRequest()
|
||||
{
|
||||
var organization = new Organization { Id = Guid.NewGuid() };
|
||||
|
||||
var subscription = new Subscription { Status = SubscriptionStatus.Active };
|
||||
_subscriberService.GetSubscription(organization).Returns(subscription);
|
||||
|
||||
var result = await _command.Run(organization);
|
||||
|
||||
Assert.True(result.IsT1);
|
||||
var badRequest = result.AsT1;
|
||||
Assert.Equal("Cannot restart a subscription that is not canceled.", badRequest.Response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_NoExistingSubscription_ReturnsBadRequest()
|
||||
{
|
||||
var organization = new Organization { Id = Guid.NewGuid() };
|
||||
|
||||
_subscriberService.GetSubscription(organization).Returns((Subscription)null);
|
||||
|
||||
var result = await _command.Run(organization);
|
||||
|
||||
Assert.True(result.IsT1);
|
||||
var badRequest = result.AsT1;
|
||||
Assert.Equal("Cannot restart a subscription that is not canceled.", badRequest.Response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_Organization_Success_ReturnsNone()
|
||||
{
|
||||
var organizationId = Guid.NewGuid();
|
||||
var organization = new Organization { Id = organizationId };
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
|
||||
|
||||
var existingSubscription = new Subscription
|
||||
{
|
||||
Status = SubscriptionStatus.Canceled,
|
||||
CustomerId = "cus_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 },
|
||||
new SubscriptionItem { Price = new Price { Id = "price_2" }, Quantity = 2 }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { ["key"] = "value" }
|
||||
};
|
||||
|
||||
var newSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_new",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
_subscriberService.GetSubscription(organization).Returns(existingSubscription);
|
||||
_stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(newSubscription);
|
||||
|
||||
var result = await _command.Run(organization);
|
||||
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is((SubscriptionCreateOptions options) =>
|
||||
options.AutomaticTax.Enabled == true &&
|
||||
options.CollectionMethod == CollectionMethod.ChargeAutomatically &&
|
||||
options.Customer == "cus_123" &&
|
||||
options.Items.Count == 2 &&
|
||||
options.Items[0].Price == "price_1" &&
|
||||
options.Items[0].Quantity == 1 &&
|
||||
options.Items[1].Price == "price_2" &&
|
||||
options.Items[1].Quantity == 2 &&
|
||||
options.Metadata["key"] == "value" &&
|
||||
options.OffSession == true &&
|
||||
options.TrialPeriodDays == 0));
|
||||
|
||||
await _organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(org =>
|
||||
org.Id == organizationId &&
|
||||
org.GatewaySubscriptionId == "sub_new" &&
|
||||
org.Enabled == true &&
|
||||
org.ExpirationDate == currentPeriodEnd));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_Provider_Success_ReturnsNone()
|
||||
{
|
||||
var providerId = Guid.NewGuid();
|
||||
var provider = new Provider { Id = providerId };
|
||||
|
||||
var existingSubscription = new Subscription
|
||||
{
|
||||
Status = SubscriptionStatus.Canceled,
|
||||
CustomerId = "cus_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = [new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 }]
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var newSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_new",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
_subscriberService.GetSubscription(provider).Returns(existingSubscription);
|
||||
_stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(newSubscription);
|
||||
|
||||
var result = await _command.Run(provider);
|
||||
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
|
||||
|
||||
await _providerRepository.Received(1).ReplaceAsync(Arg.Is<Provider>(prov =>
|
||||
prov.Id == providerId &&
|
||||
prov.GatewaySubscriptionId == "sub_new" &&
|
||||
prov.Enabled == true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_User_Success_ReturnsNone()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var user = new User { Id = userId };
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
|
||||
|
||||
var existingSubscription = new Subscription
|
||||
{
|
||||
Status = SubscriptionStatus.Canceled,
|
||||
CustomerId = "cus_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = [new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 }]
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var newSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_new",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
_subscriberService.GetSubscription(user).Returns(existingSubscription);
|
||||
_stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(newSubscription);
|
||||
|
||||
var result = await _command.Run(user);
|
||||
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
|
||||
|
||||
await _userRepository.Received(1).ReplaceAsync(Arg.Is<User>(u =>
|
||||
u.Id == userId &&
|
||||
u.GatewaySubscriptionId == "sub_new" &&
|
||||
u.Premium == true &&
|
||||
u.PremiumExpirationDate == currentPeriodEnd));
|
||||
}
|
||||
}
|
||||
@@ -1,541 +0,0 @@
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Tax.Commands;
|
||||
using Bit.Core.Billing.Tax.Services;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
using static Bit.Core.Billing.Tax.Commands.OrganizationTrialParameters;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Tax.Commands;
|
||||
|
||||
public class PreviewTaxAmountCommandTests
|
||||
{
|
||||
private readonly ILogger<PreviewTaxAmountCommand> _logger = Substitute.For<ILogger<PreviewTaxAmountCommand>>();
|
||||
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
|
||||
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
|
||||
private readonly ITaxService _taxService = Substitute.For<ITaxService>();
|
||||
|
||||
private readonly PreviewTaxAmountCommand _command;
|
||||
|
||||
public PreviewTaxAmountCommandTests()
|
||||
{
|
||||
_command = new PreviewTaxAmountCommand(_logger, _pricingClient, _stripeAdapter, _taxService);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_WithSeatBasedPasswordManagerPlan_GetsTaxAmount()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new OrganizationTrialParameters
|
||||
{
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
ProductType = ProductType.PasswordManager,
|
||||
TaxInformation = new TaxInformationDTO
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var plan = StaticStore.GetPlan(parameters.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
|
||||
|
||||
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
|
||||
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.Currency == "usd" &&
|
||||
options.CustomerDetails.Address.Country == "US" &&
|
||||
options.CustomerDetails.Address.PostalCode == "12345" &&
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 1 &&
|
||||
options.AutomaticTax.Enabled == true
|
||||
))
|
||||
.Returns(expectedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(parameters);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
var taxAmount = result.AsT0;
|
||||
Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_WithNonSeatBasedPasswordManagerPlan_GetsTaxAmount()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new OrganizationTrialParameters
|
||||
{
|
||||
PlanType = PlanType.FamiliesAnnually,
|
||||
ProductType = ProductType.PasswordManager,
|
||||
TaxInformation = new TaxInformationDTO
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var plan = StaticStore.GetPlan(parameters.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
|
||||
|
||||
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
|
||||
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.Currency == "usd" &&
|
||||
options.CustomerDetails.Address.Country == "US" &&
|
||||
options.CustomerDetails.Address.PostalCode == "12345" &&
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripePlanId &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 1 &&
|
||||
options.AutomaticTax.Enabled == true
|
||||
))
|
||||
.Returns(expectedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(parameters);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
var taxAmount = result.AsT0;
|
||||
Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_WithSecretsManagerPlan_GetsTaxAmount()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new OrganizationTrialParameters
|
||||
{
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
ProductType = ProductType.SecretsManager,
|
||||
TaxInformation = new TaxInformationDTO
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var plan = StaticStore.GetPlan(parameters.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
|
||||
|
||||
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
|
||||
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.Currency == "usd" &&
|
||||
options.CustomerDetails.Address.Country == "US" &&
|
||||
options.CustomerDetails.Address.PostalCode == "12345" &&
|
||||
options.SubscriptionDetails.Items.Count == 2 &&
|
||||
options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 1 &&
|
||||
options.SubscriptionDetails.Items[1].Price == plan.SecretsManager.StripeSeatPlanId &&
|
||||
options.SubscriptionDetails.Items[1].Quantity == 1 &&
|
||||
options.Coupon == StripeConstants.CouponIDs.SecretsManagerStandalone &&
|
||||
options.AutomaticTax.Enabled == true
|
||||
))
|
||||
.Returns(expectedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(parameters);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
var taxAmount = result.AsT0;
|
||||
Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_NonUSWithoutTaxId_GetsTaxAmount()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new OrganizationTrialParameters
|
||||
{
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
ProductType = ProductType.PasswordManager,
|
||||
TaxInformation = new TaxInformationDTO
|
||||
{
|
||||
Country = "CA",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var plan = StaticStore.GetPlan(parameters.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
|
||||
|
||||
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
|
||||
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.Currency == "usd" &&
|
||||
options.CustomerDetails.Address.Country == "CA" &&
|
||||
options.CustomerDetails.Address.PostalCode == "12345" &&
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 1 &&
|
||||
options.AutomaticTax.Enabled == true
|
||||
))
|
||||
.Returns(expectedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(parameters);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
var taxAmount = result.AsT0;
|
||||
Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_NonUSWithTaxId_GetsTaxAmount()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new OrganizationTrialParameters
|
||||
{
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
ProductType = ProductType.PasswordManager,
|
||||
TaxInformation = new TaxInformationDTO
|
||||
{
|
||||
Country = "CA",
|
||||
PostalCode = "12345",
|
||||
TaxId = "123456789"
|
||||
}
|
||||
};
|
||||
|
||||
var plan = StaticStore.GetPlan(parameters.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
|
||||
|
||||
_taxService.GetStripeTaxCode(parameters.TaxInformation.Country, parameters.TaxInformation.TaxId)
|
||||
.Returns("ca_st");
|
||||
|
||||
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
|
||||
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.Currency == "usd" &&
|
||||
options.CustomerDetails.Address.Country == "CA" &&
|
||||
options.CustomerDetails.Address.PostalCode == "12345" &&
|
||||
options.CustomerDetails.TaxIds.Count == 1 &&
|
||||
options.CustomerDetails.TaxIds[0].Type == "ca_st" &&
|
||||
options.CustomerDetails.TaxIds[0].Value == "123456789" &&
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 1 &&
|
||||
options.AutomaticTax.Enabled == true
|
||||
))
|
||||
.Returns(expectedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(parameters);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
var taxAmount = result.AsT0;
|
||||
Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_NonUSWithTaxId_UnknownTaxIdType_BadRequest()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new OrganizationTrialParameters
|
||||
{
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
ProductType = ProductType.PasswordManager,
|
||||
TaxInformation = new TaxInformationDTO
|
||||
{
|
||||
Country = "CA",
|
||||
PostalCode = "12345",
|
||||
TaxId = "123456789"
|
||||
}
|
||||
};
|
||||
|
||||
var plan = StaticStore.GetPlan(parameters.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
|
||||
|
||||
_taxService.GetStripeTaxCode(parameters.TaxInformation.Country, parameters.TaxInformation.TaxId)
|
||||
.Returns((string)null);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(parameters);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT1);
|
||||
var badRequest = result.AsT1;
|
||||
Assert.Equal("We couldn't find a corresponding tax ID type for the tax ID you provided. Please try again or contact support for assistance.", badRequest.Response);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_USBased_PersonalUse_SetsAutomaticTaxEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new OrganizationTrialParameters
|
||||
{
|
||||
PlanType = PlanType.FamiliesAnnually,
|
||||
ProductType = ProductType.PasswordManager,
|
||||
TaxInformation = new TaxInformationDTO
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var plan = StaticStore.GetPlan(parameters.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
|
||||
|
||||
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(expectedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(parameters);
|
||||
|
||||
// Assert
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true
|
||||
));
|
||||
Assert.True(result.IsT0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_USBased_BusinessUse_SetsAutomaticTaxEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new OrganizationTrialParameters
|
||||
{
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
ProductType = ProductType.PasswordManager,
|
||||
TaxInformation = new TaxInformationDTO
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var plan = StaticStore.GetPlan(parameters.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
|
||||
|
||||
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(expectedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(parameters);
|
||||
|
||||
// Assert
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true
|
||||
));
|
||||
Assert.True(result.IsT0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_NonUSBased_PersonalUse_SetsAutomaticTaxEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new OrganizationTrialParameters
|
||||
{
|
||||
PlanType = PlanType.FamiliesAnnually,
|
||||
ProductType = ProductType.PasswordManager,
|
||||
TaxInformation = new TaxInformationDTO
|
||||
{
|
||||
Country = "CA",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var plan = StaticStore.GetPlan(parameters.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
|
||||
|
||||
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(expectedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(parameters);
|
||||
|
||||
// Assert
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true
|
||||
));
|
||||
Assert.True(result.IsT0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_NonUSBased_BusinessUse_SetsAutomaticTaxEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new OrganizationTrialParameters
|
||||
{
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
ProductType = ProductType.PasswordManager,
|
||||
TaxInformation = new TaxInformationDTO
|
||||
{
|
||||
Country = "CA",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var plan = StaticStore.GetPlan(parameters.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
|
||||
|
||||
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(expectedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(parameters);
|
||||
|
||||
// Assert
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.AutomaticTax.Enabled == true
|
||||
));
|
||||
Assert.True(result.IsT0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_USBased_PersonalUse_DoesNotSetTaxExempt()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new OrganizationTrialParameters
|
||||
{
|
||||
PlanType = PlanType.FamiliesAnnually,
|
||||
ProductType = ProductType.PasswordManager,
|
||||
TaxInformation = new TaxInformationDTO
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var plan = StaticStore.GetPlan(parameters.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
|
||||
|
||||
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(expectedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(parameters);
|
||||
|
||||
// Assert
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.CustomerDetails.TaxExempt == null
|
||||
));
|
||||
Assert.True(result.IsT0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_USBased_BusinessUse_DoesNotSetTaxExempt()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new OrganizationTrialParameters
|
||||
{
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
ProductType = ProductType.PasswordManager,
|
||||
TaxInformation = new TaxInformationDTO
|
||||
{
|
||||
Country = "US",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var plan = StaticStore.GetPlan(parameters.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
|
||||
|
||||
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(expectedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(parameters);
|
||||
|
||||
// Assert
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.CustomerDetails.TaxExempt == null
|
||||
));
|
||||
Assert.True(result.IsT0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_NonUSBased_PersonalUse_DoesNotSetTaxExempt()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new OrganizationTrialParameters
|
||||
{
|
||||
PlanType = PlanType.FamiliesAnnually,
|
||||
ProductType = ProductType.PasswordManager,
|
||||
TaxInformation = new TaxInformationDTO
|
||||
{
|
||||
Country = "CA",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var plan = StaticStore.GetPlan(parameters.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
|
||||
|
||||
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(expectedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(parameters);
|
||||
|
||||
// Assert
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.CustomerDetails.TaxExempt == null
|
||||
));
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_NonUSBased_BusinessUse_SetsTaxExemptReverse()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new OrganizationTrialParameters
|
||||
{
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
ProductType = ProductType.PasswordManager,
|
||||
TaxInformation = new TaxInformationDTO
|
||||
{
|
||||
Country = "CA",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
};
|
||||
|
||||
var plan = StaticStore.GetPlan(parameters.PlanType);
|
||||
|
||||
_pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan);
|
||||
|
||||
var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents
|
||||
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(expectedInvoice);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(parameters);
|
||||
|
||||
// Assert
|
||||
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
|
||||
options.CustomerDetails.TaxExempt == StripeConstants.TaxExempt.Reverse
|
||||
));
|
||||
Assert.True(result.IsT0);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,9 @@
|
||||
<None Remove="Utilities\data\embeddedResource.txt" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- Email templates uses .hbs extension, they must be included for emails to work -->
|
||||
<EmbeddedResource Include="**\*.hbs" />
|
||||
|
||||
<EmbeddedResource Include="Utilities\data\embeddedResource.txt" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.KeyManagement.Kdf.Implementations;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
@@ -21,16 +23,12 @@ public class ChangeKdfCommandTests
|
||||
[BitAutoData]
|
||||
public async Task ChangeKdfAsync_ChangesKdfAsync(SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
|
||||
sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(IdentityResult.Success));
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(true));
|
||||
sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(IdentityResult.Success));
|
||||
|
||||
var kdf = new KdfSettings
|
||||
{
|
||||
KdfType = Enums.KdfType.Argon2id,
|
||||
Iterations = 4,
|
||||
Memory = 512,
|
||||
Parallelism = 4
|
||||
};
|
||||
var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 };
|
||||
var authenticationData = new MasterPasswordAuthenticationData
|
||||
{
|
||||
Kdf = kdf,
|
||||
@@ -59,13 +57,7 @@ public class ChangeKdfCommandTests
|
||||
[BitAutoData]
|
||||
public async Task ChangeKdfAsync_UserIsNull_ThrowsArgumentNullException(SutProvider<ChangeKdfCommand> sutProvider)
|
||||
{
|
||||
var kdf = new KdfSettings
|
||||
{
|
||||
KdfType = Enums.KdfType.Argon2id,
|
||||
Iterations = 4,
|
||||
Memory = 512,
|
||||
Parallelism = 4
|
||||
};
|
||||
var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 };
|
||||
var authenticationData = new MasterPasswordAuthenticationData
|
||||
{
|
||||
Kdf = kdf,
|
||||
@@ -85,17 +77,13 @@ public class ChangeKdfCommandTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ChangeKdfAsync_WrongPassword_ReturnsPasswordMismatch(SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
public async Task ChangeKdfAsync_WrongPassword_ReturnsPasswordMismatch(SutProvider<ChangeKdfCommand> sutProvider,
|
||||
User user)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(false));
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(false));
|
||||
|
||||
var kdf = new KdfSettings
|
||||
{
|
||||
KdfType = Enums.KdfType.Argon2id,
|
||||
Iterations = 4,
|
||||
Memory = 512,
|
||||
Parallelism = 4
|
||||
};
|
||||
var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 };
|
||||
var authenticationData = new MasterPasswordAuthenticationData
|
||||
{
|
||||
Kdf = kdf,
|
||||
@@ -116,7 +104,9 @@ public class ChangeKdfCommandTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ChangeKdfAsync_WithAuthenticationAndUnlockData_UpdatesUserCorrectly(SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
public async Task
|
||||
ChangeKdfAsync_WithAuthenticationAndUnlockDataAndNoLogoutOnKdfChangeFeatureFlagOff_UpdatesUserCorrectlyAndLogsOut(
|
||||
SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
{
|
||||
var constantKdf = new KdfSettings
|
||||
{
|
||||
@@ -137,8 +127,12 @@ public class ChangeKdfCommandTests
|
||||
MasterKeyWrappedUserKey = "new-wrapped-key",
|
||||
Salt = user.GetMasterPasswordSalt()
|
||||
};
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
|
||||
sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(IdentityResult.Success));
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(true));
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<bool>())
|
||||
.Returns(Task.FromResult(IdentityResult.Success));
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(Arg.Any<string>()).Returns(false);
|
||||
|
||||
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData);
|
||||
|
||||
@@ -150,17 +144,79 @@ public class ChangeKdfCommandTests
|
||||
&& u.KdfParallelism == constantKdf.Parallelism
|
||||
&& u.Key == "new-wrapped-key"
|
||||
));
|
||||
await sutProvider.GetDependency<IUserService>().Received(1).UpdatePasswordHash(user,
|
||||
authenticationData.MasterPasswordAuthenticationHash, validatePassword: true, refreshStamp: true);
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushLogOutAsync(user.Id);
|
||||
sutProvider.GetDependency<IFeatureService>().Received(1).IsEnabled(FeatureFlagKeys.NoLogoutOnKdfChange);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ChangeKdfAsync_KdfNotEqualBetweenAuthAndUnlock_ThrowsBadRequestException(SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
public async Task
|
||||
ChangeKdfAsync_WithAuthenticationAndUnlockDataAndNoLogoutOnKdfChangeFeatureFlagOn_UpdatesUserCorrectlyAndDoesNotLogOut(
|
||||
SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
|
||||
var constantKdf = new KdfSettings
|
||||
{
|
||||
KdfType = Enums.KdfType.Argon2id,
|
||||
Iterations = 5,
|
||||
Memory = 1024,
|
||||
Parallelism = 4
|
||||
};
|
||||
var authenticationData = new MasterPasswordAuthenticationData
|
||||
{
|
||||
Kdf = constantKdf,
|
||||
MasterPasswordAuthenticationHash = "new-auth-hash",
|
||||
Salt = user.GetMasterPasswordSalt()
|
||||
};
|
||||
var unlockData = new MasterPasswordUnlockData
|
||||
{
|
||||
Kdf = constantKdf,
|
||||
MasterKeyWrappedUserKey = "new-wrapped-key",
|
||||
Salt = user.GetMasterPasswordSalt()
|
||||
};
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(true));
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<bool>())
|
||||
.Returns(Task.FromResult(IdentityResult.Success));
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(Arg.Any<string>()).Returns(true);
|
||||
|
||||
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData);
|
||||
|
||||
await sutProvider.GetDependency<IUserRepository>().Received(1).ReplaceAsync(Arg.Is<User>(u =>
|
||||
u.Id == user.Id
|
||||
&& u.Kdf == constantKdf.KdfType
|
||||
&& u.KdfIterations == constantKdf.Iterations
|
||||
&& u.KdfMemory == constantKdf.Memory
|
||||
&& u.KdfParallelism == constantKdf.Parallelism
|
||||
&& u.Key == "new-wrapped-key"
|
||||
));
|
||||
await sutProvider.GetDependency<IUserService>().Received(1).UpdatePasswordHash(user,
|
||||
authenticationData.MasterPasswordAuthenticationHash, validatePassword: true, refreshStamp: false);
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1)
|
||||
.PushLogOutAsync(user.Id, false, PushNotificationLogOutReason.KdfChange);
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncSettingsAsync(user.Id);
|
||||
sutProvider.GetDependency<IFeatureService>().Received(1).IsEnabled(FeatureFlagKeys.NoLogoutOnKdfChange);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ChangeKdfAsync_KdfNotEqualBetweenAuthAndUnlock_ThrowsBadRequestException(
|
||||
SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
var authenticationData = new MasterPasswordAuthenticationData
|
||||
{
|
||||
Kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 },
|
||||
Kdf = new KdfSettings
|
||||
{
|
||||
KdfType = Enums.KdfType.Argon2id,
|
||||
Iterations = 4,
|
||||
Memory = 512,
|
||||
Parallelism = 4
|
||||
},
|
||||
MasterPasswordAuthenticationHash = "new-auth-hash",
|
||||
Salt = user.GetMasterPasswordSalt()
|
||||
};
|
||||
@@ -176,9 +232,11 @@ public class ChangeKdfCommandTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ChangeKdfAsync_AuthDataSaltMismatch_Throws(SutProvider<ChangeKdfCommand> sutProvider, User user, KdfSettings kdf)
|
||||
public async Task ChangeKdfAsync_AuthDataSaltMismatch_Throws(SutProvider<ChangeKdfCommand> sutProvider, User user,
|
||||
KdfSettings kdf)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
var authenticationData = new MasterPasswordAuthenticationData
|
||||
{
|
||||
@@ -192,15 +250,17 @@ public class ChangeKdfCommandTests
|
||||
MasterKeyWrappedUserKey = "new-wrapped-key",
|
||||
Salt = user.GetMasterPasswordSalt()
|
||||
};
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
await Assert.ThrowsAsync<BadRequestException>(async () =>
|
||||
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ChangeKdfAsync_UnlockDataSaltMismatch_Throws(SutProvider<ChangeKdfCommand> sutProvider, User user, KdfSettings kdf)
|
||||
public async Task ChangeKdfAsync_UnlockDataSaltMismatch_Throws(SutProvider<ChangeKdfCommand> sutProvider, User user,
|
||||
KdfSettings kdf)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
var authenticationData = new MasterPasswordAuthenticationData
|
||||
{
|
||||
@@ -214,25 +274,22 @@ public class ChangeKdfCommandTests
|
||||
MasterKeyWrappedUserKey = "new-wrapped-key",
|
||||
Salt = "different-salt"
|
||||
};
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
await Assert.ThrowsAsync<BadRequestException>(async () =>
|
||||
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ChangeKdfAsync_UpdatePasswordHashFails_ReturnsFailure(SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
public async Task ChangeKdfAsync_UpdatePasswordHashFails_ReturnsFailure(SutProvider<ChangeKdfCommand> sutProvider,
|
||||
User user)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(true));
|
||||
var failedResult = IdentityResult.Failed(new IdentityError { Code = "TestFail", Description = "Test fail" });
|
||||
sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(failedResult));
|
||||
sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(failedResult));
|
||||
|
||||
var kdf = new KdfSettings
|
||||
{
|
||||
KdfType = Enums.KdfType.Argon2id,
|
||||
Iterations = 4,
|
||||
Memory = 512,
|
||||
Parallelism = 4
|
||||
};
|
||||
var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 };
|
||||
var authenticationData = new MasterPasswordAuthenticationData
|
||||
{
|
||||
Kdf = kdf,
|
||||
@@ -253,9 +310,11 @@ public class ChangeKdfCommandTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ChangeKdfAsync_InvalidKdfSettings_ThrowsBadRequestException(SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
public async Task ChangeKdfAsync_InvalidKdfSettings_ThrowsBadRequestException(
|
||||
SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// Create invalid KDF settings (iterations too low for PBKDF2)
|
||||
var invalidKdf = new KdfSettings
|
||||
@@ -287,9 +346,11 @@ public class ChangeKdfCommandTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ChangeKdfAsync_InvalidArgon2Settings_ThrowsBadRequestException(SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
public async Task ChangeKdfAsync_InvalidArgon2Settings_ThrowsBadRequestException(
|
||||
SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// Create invalid Argon2 KDF settings (memory too high)
|
||||
var invalidKdf = new KdfSettings
|
||||
@@ -318,5 +379,4 @@ public class ChangeKdfCommandTests
|
||||
|
||||
Assert.Equal("KDF settings are invalid.", exception.Message);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
43
test/Core.Test/KeyManagement/Queries/UserAccountKeysQuery.cs
Normal file
43
test/Core.Test/KeyManagement/Queries/UserAccountKeysQuery.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.KeyManagement.Queries;
|
||||
using Bit.Core.KeyManagement.Repositories;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.KeyManagement.Queries;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class UserAccountKeysQueryTests
|
||||
{
|
||||
[Theory, BitAutoData]
|
||||
public async Task V1User_Success(SutProvider<UserAccountKeysQuery> sutProvider, User user)
|
||||
{
|
||||
var result = await sutProvider.Sut.Run(user);
|
||||
Assert.Equal(user.GetPublicKeyEncryptionKeyPair().PublicKey, result.PublicKeyEncryptionKeyPairData.PublicKey);
|
||||
Assert.Equal(user.GetPublicKeyEncryptionKeyPair().WrappedPrivateKey, result.PublicKeyEncryptionKeyPairData.WrappedPrivateKey);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task V2User_Success(SutProvider<UserAccountKeysQuery> sutProvider, User user)
|
||||
{
|
||||
user.SecurityState = "v2";
|
||||
user.SecurityVersion = 2;
|
||||
var signatureKeyPairRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
signatureKeyPairRepository.GetByUserIdAsync(user.Id).Returns(new SignatureKeyPairData(Core.KeyManagement.Enums.SignatureAlgorithm.Ed25519, "wrappedSigningKey", "verifyingKey"));
|
||||
var result = await sutProvider.Sut.Run(user);
|
||||
Assert.Equal(user.GetPublicKeyEncryptionKeyPair().PublicKey, result.PublicKeyEncryptionKeyPairData.PublicKey);
|
||||
Assert.Equal(user.GetPublicKeyEncryptionKeyPair().WrappedPrivateKey, result.PublicKeyEncryptionKeyPairData.WrappedPrivateKey);
|
||||
Assert.Equal(user.GetPublicKeyEncryptionKeyPair().SignedPublicKey, result.PublicKeyEncryptionKeyPairData.SignedPublicKey);
|
||||
|
||||
Assert.NotNull(result.SignatureKeyPairData);
|
||||
Assert.Equal("wrappedSigningKey", result.SignatureKeyPairData.WrappedSigningKey);
|
||||
Assert.Equal("verifyingKey", result.SignatureKeyPairData.VerifyingKey);
|
||||
|
||||
Assert.Equal(user.SecurityState, result.SecurityStateData.SecurityState);
|
||||
Assert.Equal(user.GetSecurityVersion(), result.SecurityStateData.SecurityVersion);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.KeyManagement.Enums;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.KeyManagement.Repositories;
|
||||
using Bit.Core.KeyManagement.UserKey.Implementations;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.Repositories;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Core.Vault.Repositories;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.KeyManagement.UserKey;
|
||||
@@ -14,7 +21,7 @@ namespace Bit.Core.Test.KeyManagement.UserKey;
|
||||
public class RotateUserAccountKeysCommandTests
|
||||
{
|
||||
[Theory, BitAutoData]
|
||||
public async Task RejectsWrongOldMasterPassword(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
|
||||
public async Task RotateUserAccountKeysAsync_WrongOldMasterPassword_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
|
||||
RotateUserAccountKeysData model)
|
||||
{
|
||||
user.Email = model.MasterPasswordUnlockData.Email;
|
||||
@@ -25,41 +32,38 @@ public class RotateUserAccountKeysCommandTests
|
||||
|
||||
Assert.NotEqual(IdentityResult.Success, result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ThrowsWhenUserIsNull(SutProvider<RotateUserAccountKeysCommand> sutProvider,
|
||||
public async Task RotateUserAccountKeysAsync_UserIsNull_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider,
|
||||
RotateUserAccountKeysData model)
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.RotateUserAccountKeysAsync(null, model));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RejectsEmailChange(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
|
||||
public async Task RotateUserAccountKeysAsync_EmailChange_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
|
||||
RotateUserAccountKeysData model)
|
||||
{
|
||||
user.Kdf = Enums.KdfType.Argon2id;
|
||||
user.KdfIterations = 3;
|
||||
user.KdfMemory = 64;
|
||||
user.KdfParallelism = 4;
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV1ModelUser(model);
|
||||
|
||||
model.MasterPasswordUnlockData.Email = user.Email + ".different-domain";
|
||||
model.MasterPasswordUnlockData.KdfType = Enums.KdfType.Argon2id;
|
||||
model.MasterPasswordUnlockData.KdfIterations = 3;
|
||||
model.MasterPasswordUnlockData.KdfMemory = 64;
|
||||
model.MasterPasswordUnlockData.KdfParallelism = 4;
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)
|
||||
.Returns(true);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.RotateUserAccountKeysAsync(user, model));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RejectsKdfChange(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
|
||||
public async Task RotateUserAccountKeysAsync_KdfChange_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
|
||||
RotateUserAccountKeysData model)
|
||||
{
|
||||
user.Kdf = Enums.KdfType.Argon2id;
|
||||
user.KdfIterations = 3;
|
||||
user.KdfMemory = 64;
|
||||
user.KdfParallelism = 4;
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV1ModelUser(model);
|
||||
|
||||
model.MasterPasswordUnlockData.Email = user.Email;
|
||||
model.MasterPasswordUnlockData.KdfType = Enums.KdfType.PBKDF2_SHA256;
|
||||
model.MasterPasswordUnlockData.KdfIterations = 600000;
|
||||
model.MasterPasswordUnlockData.KdfMemory = null;
|
||||
@@ -71,22 +75,15 @@ public class RotateUserAccountKeysCommandTests
|
||||
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RejectsPublicKeyChange(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
|
||||
public async Task RotateUserAccountKeysAsync_PublicKeyChange_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
|
||||
RotateUserAccountKeysData model)
|
||||
{
|
||||
user.PublicKey = "old-public";
|
||||
user.Kdf = Enums.KdfType.Argon2id;
|
||||
user.KdfIterations = 3;
|
||||
user.KdfMemory = 64;
|
||||
user.KdfParallelism = 4;
|
||||
|
||||
model.AccountPublicKey = "new-public";
|
||||
model.MasterPasswordUnlockData.Email = user.Email;
|
||||
model.MasterPasswordUnlockData.KdfType = Enums.KdfType.Argon2id;
|
||||
model.MasterPasswordUnlockData.KdfIterations = 3;
|
||||
model.MasterPasswordUnlockData.KdfMemory = 64;
|
||||
model.MasterPasswordUnlockData.KdfParallelism = 4;
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV1ModelUser(model);
|
||||
|
||||
model.AccountKeys.PublicKeyEncryptionKeyPairData.PublicKey = "new-public";
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)
|
||||
.Returns(true);
|
||||
|
||||
@@ -94,27 +91,350 @@ public class RotateUserAccountKeysCommandTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RotatesCorrectly(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
|
||||
public async Task RotateUserAccountKeysAsync_V1_Success(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
|
||||
RotateUserAccountKeysData model)
|
||||
{
|
||||
user.Kdf = Enums.KdfType.Argon2id;
|
||||
user.KdfIterations = 3;
|
||||
user.KdfMemory = 64;
|
||||
user.KdfParallelism = 4;
|
||||
|
||||
model.MasterPasswordUnlockData.Email = user.Email;
|
||||
model.MasterPasswordUnlockData.KdfType = Enums.KdfType.Argon2id;
|
||||
model.MasterPasswordUnlockData.KdfIterations = 3;
|
||||
model.MasterPasswordUnlockData.KdfMemory = 64;
|
||||
model.MasterPasswordUnlockData.KdfParallelism = 4;
|
||||
|
||||
model.AccountPublicKey = user.PublicKey;
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV1ModelUser(model);
|
||||
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)
|
||||
.Returns(true);
|
||||
|
||||
var result = await sutProvider.Sut.RotateUserAccountKeysAsync(user, model);
|
||||
|
||||
Assert.Equal(IdentityResult.Success, result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RotateUserAccountKeysAsync_UpgradeV1ToV2_Success(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
|
||||
RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV2ModelUser(model);
|
||||
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)
|
||||
.Returns(true);
|
||||
|
||||
var result = await sutProvider.Sut.RotateUserAccountKeysAsync(user, model);
|
||||
Assert.Equal(IdentityResult.Success, result);
|
||||
Assert.Equal(user.SecurityState, model.AccountKeys.SecurityStateData!.SecurityState);
|
||||
}
|
||||
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_PublicKeyChange_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV1ModelUser(model);
|
||||
|
||||
model.AccountKeys.PublicKeyEncryptionKeyPairData.PublicKey = "new-public";
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_V2User_PrivateKeyNotXChaCha20_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV2ExistingUser(user, signatureRepository);
|
||||
SetV2ModelUser(model);
|
||||
model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey = "2.xxx";
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_V1User_PrivateKeyNotAesCbcHmac_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV1ModelUser(model);
|
||||
model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey = "7.xxx";
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
Assert.Equal("The provided account private key was not wrapped with AES-256-CBC-HMAC", ex.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_V1_Success(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV1ModelUser(model);
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions);
|
||||
Assert.Empty(saveEncryptedDataActions);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_V2_Success(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV2ExistingUser(user, signatureRepository);
|
||||
SetV2ModelUser(model);
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions);
|
||||
Assert.NotEmpty(saveEncryptedDataActions);
|
||||
Assert.Equal(user.SecurityState, model.AccountKeys.SecurityStateData!.SecurityState);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_V2User_VerifyingKeyMismatch_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV2ExistingUser(user, signatureRepository);
|
||||
SetV2ModelUser(model);
|
||||
model.AccountKeys.SignatureKeyPairData.VerifyingKey = "different-verifying-key";
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
Assert.Equal("The provided verifying key does not match the user's current verifying key.", ex.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_V2User_SignedPublicKeyNullOrEmpty_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV2ExistingUser(user, signatureRepository);
|
||||
SetV2ModelUser(model);
|
||||
model.AccountKeys.PublicKeyEncryptionKeyPairData.SignedPublicKey = null;
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
Assert.Equal("No signed public key provided, but the user already has a signature key pair.", ex.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_V2User_WrappedSigningKeyNotXChaCha20_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV2ExistingUser(user, signatureRepository);
|
||||
SetV2ModelUser(model);
|
||||
model.AccountKeys.SignatureKeyPairData.WrappedSigningKey = "2.xxx";
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
Assert.Equal("The provided signing key data is not wrapped with XChaCha20-Poly1305.", ex.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeys_UpgradeToV2_InvalidVerifyingKey_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV2ModelUser(model);
|
||||
model.AccountKeys.SignatureKeyPairData.VerifyingKey = "";
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
Assert.Equal("The provided signature key pair data does not contain a valid verifying key.", ex.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_UpgradeToV2_IncorrectlyWrappedPrivateKey_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV2ModelUser(model);
|
||||
model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey = "2.abc";
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
Assert.Equal("The provided private key encryption key is not wrapped with XChaCha20-Poly1305.", ex.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_UpgradeToV2_NoSignedPublicKey_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV2ModelUser(model);
|
||||
model.AccountKeys.PublicKeyEncryptionKeyPairData.SignedPublicKey = null;
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
Assert.Equal("No signed public key provided, but the user already has a signature key pair.", ex.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_UpgradeToV2_NoSecurityState_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV2ModelUser(model);
|
||||
model.AccountKeys.SecurityStateData = null;
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
Assert.Equal("No signed security state provider for V2 user", ex.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_RotateV2_NoSignatureKeyPair_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV2ExistingUser(user, signatureRepository);
|
||||
SetV2ModelUser(model);
|
||||
model.AccountKeys.SignatureKeyPairData = null;
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
Assert.Equal("Signature key pair data is required for V2 encryption.", ex.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_GetEncryptionType_EmptyString_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV1ModelUser(model);
|
||||
model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey = "";
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
var ex = await Assert.ThrowsAsync<ArgumentException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
Assert.Equal("Invalid encryption type string.", ex.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_GetEncryptionType_InvalidString_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV1ModelUser(model);
|
||||
model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey = "9.xxx";
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
var ex = await Assert.ThrowsAsync<ArgumentException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
Assert.Equal("Invalid encryption type string.", ex.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateUserData_RevisionDateChanged_Success(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
var oldDate = new DateTime(2017, 1, 1);
|
||||
|
||||
var cipher = Substitute.For<Cipher>();
|
||||
cipher.RevisionDate = oldDate;
|
||||
model.Ciphers = [cipher];
|
||||
|
||||
var folder = Substitute.For<Folder>();
|
||||
folder.RevisionDate = oldDate;
|
||||
model.Folders = [folder];
|
||||
|
||||
var send = Substitute.For<Send>();
|
||||
send.RevisionDate = oldDate;
|
||||
model.Sends = [send];
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
|
||||
sutProvider.Sut.UpdateUserData(model, user, saveEncryptedDataActions);
|
||||
foreach (var dataAction in saveEncryptedDataActions)
|
||||
{
|
||||
await dataAction.Invoke();
|
||||
}
|
||||
|
||||
var updatedCiphers = sutProvider.GetDependency<ICipherRepository>()
|
||||
.ReceivedCalls()
|
||||
.FirstOrDefault(call => call.GetMethodInfo().Name == "UpdateForKeyRotation")?
|
||||
.GetArguments()[1] as IEnumerable<Cipher>;
|
||||
foreach (var updatedCipher in updatedCiphers!)
|
||||
{
|
||||
var oldCipher = model.Ciphers.FirstOrDefault(c => c.Id == updatedCipher.Id);
|
||||
Assert.NotEqual(oldDate, updatedCipher.RevisionDate);
|
||||
}
|
||||
|
||||
var updatedFolders = sutProvider.GetDependency<IFolderRepository>()
|
||||
.ReceivedCalls()
|
||||
.FirstOrDefault(call => call.GetMethodInfo().Name == "UpdateForKeyRotation")?
|
||||
.GetArguments()[1] as IEnumerable<Folder>;
|
||||
foreach (var updatedFolder in updatedFolders!)
|
||||
{
|
||||
var oldFolder = model.Folders.FirstOrDefault(f => f.Id == updatedFolder.Id);
|
||||
Assert.NotEqual(oldDate, updatedFolder.RevisionDate);
|
||||
}
|
||||
|
||||
var updatedSends = sutProvider.GetDependency<ISendRepository>()
|
||||
.ReceivedCalls()
|
||||
.FirstOrDefault(call => call.GetMethodInfo().Name == "UpdateForKeyRotation")?
|
||||
.GetArguments()[1] as IEnumerable<Send>;
|
||||
foreach (var updatedSend in updatedSends!)
|
||||
{
|
||||
var oldSend = model.Sends.FirstOrDefault(s => s.Id == updatedSend.Id);
|
||||
Assert.NotEqual(oldDate, updatedSend.RevisionDate);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions to set valid test parameters that match each other to the model and user.
|
||||
private static void SetTestKdfAndSaltForUserAndModel(User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
user.Kdf = Enums.KdfType.Argon2id;
|
||||
user.KdfIterations = 3;
|
||||
user.KdfMemory = 64;
|
||||
user.KdfParallelism = 4;
|
||||
model.MasterPasswordUnlockData.KdfType = Enums.KdfType.Argon2id;
|
||||
model.MasterPasswordUnlockData.KdfIterations = 3;
|
||||
model.MasterPasswordUnlockData.KdfMemory = 64;
|
||||
model.MasterPasswordUnlockData.KdfParallelism = 4;
|
||||
// The email is the salt for the KDF and is validated currently.
|
||||
user.Email = model.MasterPasswordUnlockData.Email;
|
||||
}
|
||||
|
||||
private static void SetV1ExistingUser(User user, IUserSignatureKeyPairRepository userSignatureKeyPairRepository)
|
||||
{
|
||||
user.PrivateKey = "2.abc";
|
||||
user.PublicKey = "public";
|
||||
user.SignedPublicKey = null;
|
||||
userSignatureKeyPairRepository.GetByUserIdAsync(user.Id).ReturnsNull();
|
||||
}
|
||||
|
||||
private static void SetV2ExistingUser(User user, IUserSignatureKeyPairRepository userSignatureKeyPairRepository)
|
||||
{
|
||||
user.PrivateKey = "7.abc";
|
||||
user.PublicKey = "public";
|
||||
user.SignedPublicKey = "signed-public";
|
||||
userSignatureKeyPairRepository.GetByUserIdAsync(user.Id).Returns(new SignatureKeyPairData(SignatureAlgorithm.Ed25519, "7.abc", "verifying-key"));
|
||||
}
|
||||
|
||||
private static void SetV1ModelUser(RotateUserAccountKeysData model)
|
||||
{
|
||||
model.AccountKeys.PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData("2.abc", "public", null);
|
||||
model.AccountKeys.SignatureKeyPairData = null;
|
||||
model.AccountKeys.SecurityStateData = null;
|
||||
}
|
||||
|
||||
private static void SetV2ModelUser(RotateUserAccountKeysData model)
|
||||
{
|
||||
model.AccountKeys.PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData("7.abc", "public", "signed-public");
|
||||
model.AccountKeys.SignatureKeyPairData = new SignatureKeyPairData(SignatureAlgorithm.Ed25519, "7.abc", "verifying-key");
|
||||
model.AccountKeys.SecurityStateData = new SecurityStateData
|
||||
{
|
||||
SecurityState = "abc",
|
||||
SecurityVersion = 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,21 +278,27 @@ public class UpdateSecretsManagerSubscriptionCommandTests
|
||||
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
const int seatCount = 10;
|
||||
var existingSeatCount = 9;
|
||||
|
||||
// Make sure Password Manager seats is greater or equal to Secrets Manager seats
|
||||
organization.Seats = seatCount;
|
||||
const int initialSeatCount = 9;
|
||||
const int maxSeatCount = 20;
|
||||
// This represents the total number of users allowed in the organization.
|
||||
organization.Seats = maxSeatCount;
|
||||
// This represents the number of Secrets Manager users allowed in the organization.
|
||||
organization.SmSeats = initialSeatCount;
|
||||
// This represents the upper limit of Secrets Manager seats that can be automatically scaled.
|
||||
organization.MaxAutoscaleSmSeats = maxSeatCount;
|
||||
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
var plan = StaticStore.GetPlan(organization.PlanType);
|
||||
|
||||
var update = new SecretsManagerSubscriptionUpdate(organization, plan, false)
|
||||
{
|
||||
SmSeats = seatCount,
|
||||
MaxAutoscaleSmSeats = seatCount
|
||||
SmSeats = 8,
|
||||
MaxAutoscaleSmSeats = maxSeatCount
|
||||
};
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(existingSeatCount);
|
||||
.Returns(5);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.UpdateSubscriptionAsync(update);
|
||||
@@ -316,21 +322,29 @@ public class UpdateSecretsManagerSubscriptionCommandTests
|
||||
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
const int seatCount = 10;
|
||||
const int existingSeatCount = 10;
|
||||
var ownerDetailsList = new List<OrganizationUserUserDetails> { new() { Email = "owner@example.com" } };
|
||||
const int initialSeatCount = 5;
|
||||
const int maxSeatCount = 10;
|
||||
|
||||
// The amount of seats for users in an organization
|
||||
// This represents the total number of users allowed in the organization.
|
||||
organization.Seats = maxSeatCount;
|
||||
// This represents the number of Secrets Manager users allowed in the organization.
|
||||
organization.SmSeats = initialSeatCount;
|
||||
// This represents the upper limit of Secrets Manager seats that can be automatically scaled.
|
||||
organization.MaxAutoscaleSmSeats = maxSeatCount;
|
||||
|
||||
var ownerDetailsList = new List<OrganizationUserUserDetails> { new() { Email = "owner@example.com" } };
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
var plan = StaticStore.GetPlan(organization.PlanType);
|
||||
|
||||
var update = new SecretsManagerSubscriptionUpdate(organization, plan, false)
|
||||
{
|
||||
SmSeats = seatCount,
|
||||
MaxAutoscaleSmSeats = seatCount
|
||||
SmSeats = maxSeatCount,
|
||||
MaxAutoscaleSmSeats = maxSeatCount
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
|
||||
.Returns(existingSeatCount);
|
||||
.Returns(maxSeatCount);
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByMinimumRoleAsync(organization.Id, OrganizationUserType.Owner)
|
||||
.Returns(ownerDetailsList);
|
||||
@@ -340,15 +354,14 @@ public class UpdateSecretsManagerSubscriptionCommandTests
|
||||
|
||||
// Assert
|
||||
|
||||
// Currently being called once each for different validation methods
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.Received(2)
|
||||
.Received(1)
|
||||
.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id);
|
||||
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.Received(1)
|
||||
.SendSecretsManagerMaxSeatLimitReachedEmailAsync(Arg.Is(organization),
|
||||
Arg.Is(seatCount),
|
||||
Arg.Is(maxSeatCount),
|
||||
Arg.Is<IEnumerable<string>>(emails => emails.Contains(ownerDetailsList[0].Email)));
|
||||
}
|
||||
|
||||
|
||||
172
test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs
Normal file
172
test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs
Normal file
@@ -0,0 +1,172 @@
|
||||
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 HandlebarMailRendererTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RenderAsync_ReturnsExpectedHtmlAndTxt()
|
||||
{
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
test/Core.Test/Platform/Mailer/MailerTest.cs
Normal file
42
test/Core.Test/Platform/Mailer/MailerTest.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
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(logger, globalSettings), deliveryService);
|
||||
|
||||
var mail = new TestMail.TestMail()
|
||||
{
|
||||
ToEmails = ["test@bw.com"],
|
||||
View = new TestMailView() { Name = "John Smith" }
|
||||
};
|
||||
|
||||
MailMessage? sentMessage = null;
|
||||
await deliveryService.SendEmailAsync(Arg.Do<MailMessage>(message =>
|
||||
sentMessage = message
|
||||
));
|
||||
|
||||
await mailer.SendEmail(mail);
|
||||
|
||||
Assert.NotNull(sentMessage);
|
||||
Assert.Contains("test@bw.com", sentMessage.ToEmails);
|
||||
Assert.Equal("Test Email", sentMessage.Subject);
|
||||
Assert.Equivalent("Hello John Smith", sentMessage.TextContent.Trim());
|
||||
Assert.Equivalent("Hello <b>John Smith</b>", sentMessage.HtmlContent.Trim());
|
||||
}
|
||||
}
|
||||
13
test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs
Normal file
13
test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using Bit.Core.Platform.Mail.Mailer;
|
||||
|
||||
namespace Bit.Core.Test.Platform.Mailer.TestMail;
|
||||
|
||||
public class TestMailView : BaseMailView
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
}
|
||||
|
||||
public class TestMail : BaseMail<TestMailView>
|
||||
{
|
||||
public override string Subject { get; } = "Test Email";
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Hello <b>{{ Name }}</b>
|
||||
@@ -0,0 +1 @@
|
||||
Hello {{ Name }}
|
||||
@@ -358,20 +358,28 @@ public class AzureQueuePushEngineTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task PushLogOutAsync_SendsExpectedResponse(bool excludeCurrentContext)
|
||||
[InlineData(true, null)]
|
||||
[InlineData(true, PushNotificationLogOutReason.KdfChange)]
|
||||
[InlineData(false, null)]
|
||||
[InlineData(false, PushNotificationLogOutReason.KdfChange)]
|
||||
public async Task PushLogOutAsync_SendsExpectedResponse(bool excludeCurrentContext,
|
||||
PushNotificationLogOutReason? reason)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["UserId"] = userId
|
||||
};
|
||||
if (reason != null)
|
||||
{
|
||||
payload["Reason"] = (int)reason;
|
||||
}
|
||||
|
||||
var expectedPayload = new JsonObject
|
||||
{
|
||||
["Type"] = 11,
|
||||
["Payload"] = new JsonObject
|
||||
{
|
||||
["UserId"] = userId,
|
||||
["Date"] = _fakeTimeProvider.GetUtcNow().UtcDateTime,
|
||||
},
|
||||
["Payload"] = payload,
|
||||
};
|
||||
|
||||
if (excludeCurrentContext)
|
||||
@@ -380,7 +388,7 @@ public class AzureQueuePushEngineTests
|
||||
}
|
||||
|
||||
await VerifyNotificationAsync(
|
||||
async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext),
|
||||
async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext, reason),
|
||||
expectedPayload
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.NotificationCenter.Entities;
|
||||
using Bit.Core.Platform.Push.Internal;
|
||||
using Bit.Core.Tools.Entities;
|
||||
@@ -193,7 +194,8 @@ public class NotificationsApiPushEngineTests : PushTestBase
|
||||
};
|
||||
}
|
||||
|
||||
protected override JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext)
|
||||
protected override JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext,
|
||||
PushNotificationLogOutReason? reason)
|
||||
{
|
||||
JsonNode? contextId = excludeCurrentContext ? DeviceIdentifier : null;
|
||||
|
||||
@@ -203,7 +205,7 @@ public class NotificationsApiPushEngineTests : PushTestBase
|
||||
["Payload"] = new JsonObject
|
||||
{
|
||||
["UserId"] = userId,
|
||||
["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime,
|
||||
["Reason"] = reason != null ? (int)reason : null
|
||||
},
|
||||
["ContextId"] = contextId,
|
||||
};
|
||||
|
||||
@@ -86,7 +86,8 @@ public abstract class PushTestBase
|
||||
protected abstract JsonNode GetPushSyncOrganizationsPayload(Guid userId);
|
||||
protected abstract JsonNode GetPushSyncOrgKeysPayload(Guid userId);
|
||||
protected abstract JsonNode GetPushSyncSettingsPayload(Guid userId);
|
||||
protected abstract JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext);
|
||||
protected abstract JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext,
|
||||
PushNotificationLogOutReason? reason);
|
||||
protected abstract JsonNode GetPushSendCreatePayload(Send send);
|
||||
protected abstract JsonNode GetPushSendUpdatePayload(Send send);
|
||||
protected abstract JsonNode GetPushSendDeletePayload(Send send);
|
||||
@@ -263,15 +264,18 @@ public abstract class PushTestBase
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task PushLogOutAsync_SendsExpectedResponse(bool excludeCurrentContext)
|
||||
[InlineData(true, null)]
|
||||
[InlineData(true, PushNotificationLogOutReason.KdfChange)]
|
||||
[InlineData(false, null)]
|
||||
[InlineData(false, PushNotificationLogOutReason.KdfChange)]
|
||||
public async Task PushLogOutAsync_SendsExpectedResponse(bool excludeCurrentContext,
|
||||
PushNotificationLogOutReason? reason)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
await VerifyNotificationAsync(
|
||||
async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext),
|
||||
GetPushLogOutPayload(userId, excludeCurrentContext)
|
||||
async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext, reason),
|
||||
GetPushLogOutPayload(userId, excludeCurrentContext, reason)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Text.Json.Nodes;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.NotificationCenter.Entities;
|
||||
using Bit.Core.Platform.Push.Internal;
|
||||
using Bit.Core.Repositories;
|
||||
@@ -64,7 +65,7 @@ public class RelayPushNotificationServiceTests : PushTestBase
|
||||
["UserId"] = cipher.UserId,
|
||||
["OrganizationId"] = null,
|
||||
// Currently CollectionIds are not passed along from the method signature
|
||||
// to the request body.
|
||||
// to the request body.
|
||||
["CollectionIds"] = null,
|
||||
["RevisionDate"] = cipher.RevisionDate,
|
||||
},
|
||||
@@ -88,7 +89,7 @@ public class RelayPushNotificationServiceTests : PushTestBase
|
||||
["UserId"] = cipher.UserId,
|
||||
["OrganizationId"] = null,
|
||||
// Currently CollectionIds are not passed along from the method signature
|
||||
// to the request body.
|
||||
// to the request body.
|
||||
["CollectionIds"] = null,
|
||||
["RevisionDate"] = cipher.RevisionDate,
|
||||
},
|
||||
@@ -274,7 +275,8 @@ public class RelayPushNotificationServiceTests : PushTestBase
|
||||
};
|
||||
}
|
||||
|
||||
protected override JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext)
|
||||
protected override JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext,
|
||||
PushNotificationLogOutReason? reason)
|
||||
{
|
||||
JsonNode? identifier = excludeCurrentContext ? DeviceIdentifier : null;
|
||||
|
||||
@@ -288,7 +290,7 @@ public class RelayPushNotificationServiceTests : PushTestBase
|
||||
["Payload"] = new JsonObject
|
||||
{
|
||||
["UserId"] = userId,
|
||||
["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime,
|
||||
["Reason"] = reason != null ? (int)reason : null
|
||||
},
|
||||
["ClientType"] = null,
|
||||
["InstallationId"] = null,
|
||||
|
||||
@@ -404,16 +404,18 @@ public class NotificationHubPushNotificationServiceTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task PushLogOutAsync_SendExpectedData(bool excludeCurrentContext)
|
||||
[InlineData(true, null)]
|
||||
[InlineData(true, PushNotificationLogOutReason.KdfChange)]
|
||||
[InlineData(false, null)]
|
||||
[InlineData(false, PushNotificationLogOutReason.KdfChange)]
|
||||
public async Task PushLogOutAsync_SendExpectedData(bool excludeCurrentContext, PushNotificationLogOutReason? reason)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var expectedPayload = new JsonObject
|
||||
{
|
||||
["UserId"] = userId,
|
||||
["Date"] = _now,
|
||||
["Reason"] = reason != null ? (int)reason : null,
|
||||
};
|
||||
|
||||
var expectedTag = excludeCurrentContext
|
||||
@@ -421,7 +423,7 @@ public class NotificationHubPushNotificationServiceTests
|
||||
: $"(template:payload_userId:{userId})";
|
||||
|
||||
await VerifyNotificationAsync(
|
||||
async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext),
|
||||
async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext, reason),
|
||||
PushType.LogOut,
|
||||
expectedPayload,
|
||||
expectedTag
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using Amazon.SimpleEmail;
|
||||
using Amazon.SimpleEmail.Model;
|
||||
using Bit.Core.Models.Mail;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Platform.Mail.Delivery;
|
||||
using Bit.Core.Settings;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -6,7 +6,10 @@ using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Business;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Mail;
|
||||
using Bit.Core.Platform.Mail.Delivery;
|
||||
using Bit.Core.Platform.Mail.Enqueuing;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Services.Mail;
|
||||
using Bit.Core.Settings;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -23,6 +26,7 @@ public class HandlebarsMailServiceTests
|
||||
private readonly IMailDeliveryService _mailDeliveryService;
|
||||
private readonly IMailEnqueuingService _mailEnqueuingService;
|
||||
private readonly IDistributedCache _distributedCache;
|
||||
private readonly ILogger<HandlebarsMailService> _logger;
|
||||
|
||||
public HandlebarsMailServiceTests()
|
||||
{
|
||||
@@ -30,12 +34,14 @@ public class HandlebarsMailServiceTests
|
||||
_mailDeliveryService = Substitute.For<IMailDeliveryService>();
|
||||
_mailEnqueuingService = Substitute.For<IMailEnqueuingService>();
|
||||
_distributedCache = Substitute.For<IDistributedCache>();
|
||||
_logger = Substitute.For<ILogger<HandlebarsMailService>>();
|
||||
|
||||
_sut = new HandlebarsMailService(
|
||||
_globalSettings,
|
||||
_mailDeliveryService,
|
||||
_mailEnqueuingService,
|
||||
_distributedCache
|
||||
_distributedCache,
|
||||
_logger
|
||||
);
|
||||
}
|
||||
|
||||
@@ -217,8 +223,9 @@ public class HandlebarsMailServiceTests
|
||||
|
||||
var mailDeliveryService = new MailKitSmtpMailDeliveryService(globalSettings, Substitute.For<ILogger<MailKitSmtpMailDeliveryService>>());
|
||||
var distributedCache = Substitute.For<IDistributedCache>();
|
||||
var logger = Substitute.For<ILogger<HandlebarsMailService>>();
|
||||
|
||||
var handlebarsService = new HandlebarsMailService(globalSettings, mailDeliveryService, new BlockingMailEnqueuingService(), distributedCache);
|
||||
var handlebarsService = new HandlebarsMailService(globalSettings, mailDeliveryService, new BlockingMailEnqueuingService(), distributedCache, logger);
|
||||
|
||||
var sendMethods = typeof(IMailService).GetMethods(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(m => m.Name.StartsWith("Send") && m.Name != "SendEnqueuedMailMessageAsync");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Platform.Mail.Delivery;
|
||||
using Bit.Core.Settings;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Bit.Core.Models.Mail;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Platform.Mail.Delivery;
|
||||
using Bit.Core.Settings;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -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;
|
||||
@@ -18,8 +19,9 @@ public class StripePaymentServiceTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithoutAdditionalStorage(
|
||||
SutProvider<StripePaymentService> sutProvider)
|
||||
public async Task
|
||||
PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithoutAdditionalStorage(
|
||||
SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
@@ -28,16 +30,13 @@ public class StripePaymentServiceTests
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager = new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.FamiliesAnnually,
|
||||
AdditionalStorage = 0
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "FR",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
PasswordManager =
|
||||
new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.FamiliesAnnually,
|
||||
AdditionalStorage = 0
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel { Country = "FR", PostalCode = "12345" }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
@@ -52,7 +51,7 @@ public class StripePaymentServiceTests
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 4000,
|
||||
Tax = 800,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 800 }],
|
||||
Total = 4800
|
||||
});
|
||||
|
||||
@@ -75,16 +74,13 @@ public class StripePaymentServiceTests
|
||||
|
||||
var parameters = new PreviewOrganizationInvoiceRequestBody
|
||||
{
|
||||
PasswordManager = new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.FamiliesAnnually,
|
||||
AdditionalStorage = 1
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "FR",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
PasswordManager =
|
||||
new OrganizationPasswordManagerRequestModel
|
||||
{
|
||||
Plan = PlanType.FamiliesAnnually,
|
||||
AdditionalStorage = 1
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel { Country = "FR", PostalCode = "12345" }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
@@ -96,12 +92,7 @@ public class StripePaymentServiceTests
|
||||
p.SubscriptionDetails.Items.Any(x =>
|
||||
x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId &&
|
||||
x.Quantity == 1)))
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 4000,
|
||||
Tax = 800,
|
||||
Total = 4800
|
||||
});
|
||||
.Returns(new Invoice { TotalExcludingTax = 4000, TotalTaxes = [new InvoiceTotalTax { Amount = 800 }], Total = 4800 });
|
||||
|
||||
var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
@@ -112,8 +103,9 @@ public class StripePaymentServiceTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithoutAdditionalStorage(
|
||||
SutProvider<StripePaymentService> sutProvider)
|
||||
public async Task
|
||||
PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithoutAdditionalStorage(
|
||||
SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
@@ -128,11 +120,7 @@ public class StripePaymentServiceTests
|
||||
SponsoredPlan = PlanSponsorshipType.FamiliesForEnterprise,
|
||||
AdditionalStorage = 0
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "FR",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
TaxInformation = new TaxInformationRequestModel { Country = "FR", PostalCode = "12345" }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
@@ -144,12 +132,7 @@ public class StripePaymentServiceTests
|
||||
p.SubscriptionDetails.Items.Any(x =>
|
||||
x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId &&
|
||||
x.Quantity == 0)))
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 0,
|
||||
Tax = 0,
|
||||
Total = 0
|
||||
});
|
||||
.Returns(new Invoice { TotalExcludingTax = 0, TotalTaxes = [new InvoiceTotalTax { Amount = 0 }], Total = 0 });
|
||||
|
||||
var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
@@ -160,8 +143,9 @@ public class StripePaymentServiceTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithAdditionalStorage(
|
||||
SutProvider<StripePaymentService> sutProvider)
|
||||
public async Task
|
||||
PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithAdditionalStorage(
|
||||
SutProvider<StripePaymentService> sutProvider)
|
||||
{
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
sutProvider.GetDependency<IPricingClient>()
|
||||
@@ -176,11 +160,7 @@ public class StripePaymentServiceTests
|
||||
SponsoredPlan = PlanSponsorshipType.FamiliesForEnterprise,
|
||||
AdditionalStorage = 1
|
||||
},
|
||||
TaxInformation = new TaxInformationRequestModel
|
||||
{
|
||||
Country = "FR",
|
||||
PostalCode = "12345"
|
||||
}
|
||||
TaxInformation = new TaxInformationRequestModel { Country = "FR", PostalCode = "12345" }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
@@ -192,12 +172,7 @@ public class StripePaymentServiceTests
|
||||
p.SubscriptionDetails.Items.Any(x =>
|
||||
x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId &&
|
||||
x.Quantity == 1)))
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
Total = 408
|
||||
});
|
||||
.Returns(new Invoice { TotalExcludingTax = 400, TotalTaxes = [new InvoiceTotalTax { Amount = 8 }], Total = 408 });
|
||||
|
||||
var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
|
||||
|
||||
@@ -235,7 +210,7 @@ public class StripePaymentServiceTests
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
@@ -277,7 +252,7 @@ public class StripePaymentServiceTests
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
@@ -319,7 +294,7 @@ public class StripePaymentServiceTests
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
@@ -361,7 +336,7 @@ public class StripePaymentServiceTests
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
@@ -403,7 +378,7 @@ public class StripePaymentServiceTests
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
@@ -445,7 +420,7 @@ public class StripePaymentServiceTests
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
@@ -487,7 +462,7 @@ public class StripePaymentServiceTests
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
@@ -529,7 +504,7 @@ public class StripePaymentServiceTests
|
||||
.Returns(new Invoice
|
||||
{
|
||||
TotalExcludingTax = 400,
|
||||
Tax = 8,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
|
||||
Total = 408
|
||||
});
|
||||
|
||||
@@ -541,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>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,38 +53,6 @@ public class ImportCiphersAsyncCommandTests
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ImportIntoIndividualVaultAsync_WithBulkResourceCreationServiceEnabled_Success(
|
||||
Guid importingUserId,
|
||||
List<CipherDetails> ciphers,
|
||||
SutProvider<ImportCiphersCommand> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IPolicyService>()
|
||||
.AnyPoliciesApplicableToUserAsync(importingUserId, PolicyType.OrganizationDataOwnership)
|
||||
.Returns(false);
|
||||
|
||||
sutProvider.GetDependency<IFolderRepository>()
|
||||
.GetManyByUserIdAsync(importingUserId)
|
||||
.Returns(new List<Folder>());
|
||||
|
||||
var folders = new List<Folder> { new Folder { UserId = importingUserId } };
|
||||
|
||||
var folderRelationships = new List<KeyValuePair<int, int>>();
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<ICipherRepository>()
|
||||
.Received(1)
|
||||
.CreateAsync_vNext(importingUserId, ciphers, Arg.Any<List<Folder>>());
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ImportIntoIndividualVaultAsync_WithPolicyRequirementsEnabled_WithOrganizationDataOwnershipPolicyDisabled_Success(
|
||||
Guid importingUserId,
|
||||
@@ -117,42 +85,6 @@ public class ImportCiphersAsyncCommandTests
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ImportIntoIndividualVaultAsync_WithBulkResourceCreationServiceEnabled_WithPolicyRequirementsEnabled_WithOrganizationDataOwnershipPolicyDisabled_Success(
|
||||
Guid importingUserId,
|
||||
List<CipherDetails> ciphers,
|
||||
SutProvider<ImportCiphersCommand> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.PolicyRequirements)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
||||
.GetAsync<OrganizationDataOwnershipPolicyRequirement>(importingUserId)
|
||||
.Returns(new OrganizationDataOwnershipPolicyRequirement(
|
||||
OrganizationDataOwnershipState.Disabled,
|
||||
[]));
|
||||
|
||||
sutProvider.GetDependency<IFolderRepository>()
|
||||
.GetManyByUserIdAsync(importingUserId)
|
||||
.Returns(new List<Folder>());
|
||||
|
||||
var folders = new List<Folder> { new Folder { UserId = importingUserId } };
|
||||
|
||||
var folderRelationships = new List<KeyValuePair<int, int>>();
|
||||
|
||||
await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId);
|
||||
|
||||
await sutProvider.GetDependency<ICipherRepository>()
|
||||
.Received(1)
|
||||
.CreateAsync_vNext(importingUserId, ciphers, Arg.Any<List<Folder>>());
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ImportIntoIndividualVaultAsync_ThrowsBadRequestException(
|
||||
List<Folder> folders,
|
||||
@@ -259,66 +191,6 @@ public class ImportCiphersAsyncCommandTests
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ImportIntoOrganizationalVaultAsync_WithBulkResourceCreationServiceEnabled_Success(
|
||||
Organization organization,
|
||||
Guid importingUserId,
|
||||
OrganizationUser importingOrganizationUser,
|
||||
List<Collection> collections,
|
||||
List<CipherDetails> ciphers,
|
||||
SutProvider<ImportCiphersCommand> sutProvider)
|
||||
{
|
||||
organization.MaxCollections = null;
|
||||
importingOrganizationUser.OrganizationId = organization.Id;
|
||||
|
||||
foreach (var collection in collections)
|
||||
{
|
||||
collection.OrganizationId = organization.Id;
|
||||
}
|
||||
|
||||
foreach (var cipher in ciphers)
|
||||
{
|
||||
cipher.OrganizationId = organization.Id;
|
||||
}
|
||||
|
||||
KeyValuePair<int, int>[] collectionRelationships = {
|
||||
new(0, 0),
|
||||
new(1, 1),
|
||||
new(2, 2)
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(organization.Id)
|
||||
.Returns(organization);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(organization.Id, importingUserId)
|
||||
.Returns(importingOrganizationUser);
|
||||
|
||||
// Set up a collection that already exists in the organization
|
||||
sutProvider.GetDependency<ICollectionRepository>()
|
||||
.GetManyByOrganizationIdAsync(organization.Id)
|
||||
.Returns(new List<Collection> { collections[0] });
|
||||
|
||||
await sutProvider.Sut.ImportIntoOrganizationalVaultAsync(collections, ciphers, collectionRelationships, importingUserId);
|
||||
|
||||
await sutProvider.GetDependency<ICipherRepository>().Received(1).CreateAsync_vNext(
|
||||
ciphers,
|
||||
Arg.Is<IEnumerable<Collection>>(cols => cols.Count() == collections.Count - 1 &&
|
||||
!cols.Any(c => c.Id == collections[0].Id) && // Check that the collection that already existed in the organization was not added
|
||||
cols.All(c => collections.Any(x => c.Name == x.Name))),
|
||||
Arg.Is<IEnumerable<CollectionCipher>>(c => c.Count() == ciphers.Count),
|
||||
Arg.Is<IEnumerable<CollectionUser>>(cus =>
|
||||
cus.Count() == collections.Count - 1 &&
|
||||
!cus.Any(cu => cu.CollectionId == collections[0].Id) && // Check that access was not added for the collection that already existed in the organization
|
||||
cus.All(cu => cu.OrganizationUserId == importingOrganizationUser.Id && cu.Manage == true)));
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ImportIntoOrganizationalVaultAsync_ThrowsBadRequestException(
|
||||
Organization organization,
|
||||
|
||||
18
test/Core.Test/Utilities/AssemblyHelpersTests.cs
Normal file
18
test/Core.Test/Utilities/AssemblyHelpersTests.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using Bit.Core.Utilities;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Utilities;
|
||||
|
||||
public class AssemblyHelpersTests
|
||||
{
|
||||
[Fact]
|
||||
public void ReturnsValidVersionAndGitHash()
|
||||
{
|
||||
var version = AssemblyHelpers.GetVersion();
|
||||
_ = Version.Parse(version);
|
||||
|
||||
var gitHash = AssemblyHelpers.GetGitHash();
|
||||
Assert.NotNull(gitHash);
|
||||
Assert.Equal(8, gitHash.Length);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -113,6 +113,242 @@ public class CipherServiceTests
|
||||
await sutProvider.GetDependency<ICipherRepository>().Received(1).ReplaceAsync(cipherDetails);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateAttachmentAsync_WrongRevisionDate_Throws(SutProvider<CipherService> sutProvider, Cipher cipher, Guid savingUserId)
|
||||
{
|
||||
var lastKnownRevisionDate = cipher.RevisionDate.AddDays(-1);
|
||||
var stream = new MemoryStream();
|
||||
var fileName = "test.txt";
|
||||
var key = "test-key";
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.CreateAttachmentAsync(cipher, stream, fileName, key, 100, savingUserId, false, lastKnownRevisionDate));
|
||||
Assert.Contains("out of date", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData("")]
|
||||
[BitAutoData("Correct Time")]
|
||||
public async Task CreateAttachmentAsync_CorrectRevisionDate_DoesNotThrow(string revisionDateString,
|
||||
SutProvider<CipherService> sutProvider, CipherDetails cipher, Guid savingUserId)
|
||||
{
|
||||
var lastKnownRevisionDate = string.IsNullOrEmpty(revisionDateString) ? (DateTime?)null : cipher.RevisionDate;
|
||||
var stream = new MemoryStream(new byte[100]);
|
||||
var fileName = "test.txt";
|
||||
var key = "test-key";
|
||||
|
||||
// Setup cipher with user ownership
|
||||
cipher.UserId = savingUserId;
|
||||
cipher.OrganizationId = null;
|
||||
|
||||
// Mock user storage and premium access
|
||||
var user = new User { Id = savingUserId, MaxStorageGb = 1 };
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetByIdAsync(savingUserId)
|
||||
.Returns(user);
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.CanAccessPremium(user)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IAttachmentStorageService>()
|
||||
.UploadNewAttachmentAsync(Arg.Any<Stream>(), cipher, Arg.Any<CipherAttachment.MetaData>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
sutProvider.GetDependency<IAttachmentStorageService>()
|
||||
.ValidateFileAsync(cipher, Arg.Any<CipherAttachment.MetaData>(), Arg.Any<long>())
|
||||
.Returns((true, 100L));
|
||||
|
||||
sutProvider.GetDependency<ICipherRepository>()
|
||||
.UpdateAttachmentAsync(Arg.Any<CipherAttachment>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
sutProvider.GetDependency<ICipherRepository>()
|
||||
.ReplaceAsync(Arg.Any<CipherDetails>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await sutProvider.Sut.CreateAttachmentAsync(cipher, stream, fileName, key, 100, savingUserId, false, lastKnownRevisionDate);
|
||||
|
||||
await sutProvider.GetDependency<IAttachmentStorageService>().Received(1)
|
||||
.UploadNewAttachmentAsync(Arg.Any<Stream>(), cipher, Arg.Any<CipherAttachment.MetaData>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateAttachmentForDelayedUploadAsync_WrongRevisionDate_Throws(SutProvider<CipherService> sutProvider, Cipher cipher, Guid savingUserId)
|
||||
{
|
||||
var lastKnownRevisionDate = cipher.RevisionDate.AddDays(-1);
|
||||
var key = "test-key";
|
||||
var fileName = "test.txt";
|
||||
var fileSize = 100L;
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.CreateAttachmentForDelayedUploadAsync(cipher, key, fileName, fileSize, false, savingUserId, lastKnownRevisionDate));
|
||||
Assert.Contains("out of date", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData("")]
|
||||
[BitAutoData("Correct Time")]
|
||||
public async Task CreateAttachmentForDelayedUploadAsync_CorrectRevisionDate_DoesNotThrow(string revisionDateString,
|
||||
SutProvider<CipherService> sutProvider, CipherDetails cipher, Guid savingUserId)
|
||||
{
|
||||
var lastKnownRevisionDate = string.IsNullOrEmpty(revisionDateString) ? (DateTime?)null : cipher.RevisionDate;
|
||||
var key = "test-key";
|
||||
var fileName = "test.txt";
|
||||
var fileSize = 100L;
|
||||
|
||||
// Setup cipher with user ownership
|
||||
cipher.UserId = savingUserId;
|
||||
cipher.OrganizationId = null;
|
||||
|
||||
// Mock user storage and premium access
|
||||
var user = new User { Id = savingUserId, MaxStorageGb = 1 };
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.GetByIdAsync(savingUserId)
|
||||
.Returns(user);
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.CanAccessPremium(user)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IAttachmentStorageService>()
|
||||
.GetAttachmentUploadUrlAsync(cipher, Arg.Any<CipherAttachment.MetaData>())
|
||||
.Returns("https://example.com/upload");
|
||||
|
||||
sutProvider.GetDependency<ICipherRepository>()
|
||||
.UpdateAttachmentAsync(Arg.Any<CipherAttachment>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var result = await sutProvider.Sut.CreateAttachmentForDelayedUploadAsync(cipher, key, fileName, fileSize, false, savingUserId, lastKnownRevisionDate);
|
||||
|
||||
Assert.NotNull(result.attachmentId);
|
||||
Assert.NotNull(result.uploadUrl);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UploadFileForExistingAttachmentAsync_WrongRevisionDate_Throws(SutProvider<CipherService> sutProvider,
|
||||
Cipher cipher)
|
||||
{
|
||||
var lastKnownRevisionDate = cipher.RevisionDate.AddDays(-1);
|
||||
var stream = new MemoryStream();
|
||||
var attachment = new CipherAttachment.MetaData
|
||||
{
|
||||
AttachmentId = "test-attachment-id",
|
||||
Size = 100,
|
||||
FileName = "test.txt",
|
||||
Key = "test-key"
|
||||
};
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.UploadFileForExistingAttachmentAsync(stream, cipher, attachment, lastKnownRevisionDate));
|
||||
Assert.Contains("out of date", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData("")]
|
||||
[BitAutoData("Correct Time")]
|
||||
public async Task UploadFileForExistingAttachmentAsync_CorrectRevisionDate_DoesNotThrow(string revisionDateString,
|
||||
SutProvider<CipherService> sutProvider, CipherDetails cipher)
|
||||
{
|
||||
var lastKnownRevisionDate = string.IsNullOrEmpty(revisionDateString) ? (DateTime?)null : cipher.RevisionDate;
|
||||
var stream = new MemoryStream(new byte[100]);
|
||||
var attachmentId = "test-attachment-id";
|
||||
var attachment = new CipherAttachment.MetaData
|
||||
{
|
||||
AttachmentId = attachmentId,
|
||||
Size = 100,
|
||||
FileName = "test.txt",
|
||||
Key = "test-key"
|
||||
};
|
||||
|
||||
// Set the attachment on the cipher so ValidateCipherAttachmentFile can find it
|
||||
cipher.SetAttachments(new Dictionary<string, CipherAttachment.MetaData>
|
||||
{
|
||||
[attachmentId] = attachment
|
||||
});
|
||||
|
||||
sutProvider.GetDependency<IAttachmentStorageService>()
|
||||
.UploadNewAttachmentAsync(stream, cipher, attachment)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
sutProvider.GetDependency<IAttachmentStorageService>()
|
||||
.ValidateFileAsync(cipher, attachment, Arg.Any<long>())
|
||||
.Returns((true, 100L));
|
||||
|
||||
sutProvider.GetDependency<ICipherRepository>()
|
||||
.UpdateAttachmentAsync(Arg.Any<CipherAttachment>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await sutProvider.Sut.UploadFileForExistingAttachmentAsync(stream, cipher, attachment, lastKnownRevisionDate);
|
||||
|
||||
await sutProvider.GetDependency<IAttachmentStorageService>().Received(1)
|
||||
.UploadNewAttachmentAsync(stream, cipher, attachment);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task CreateAttachmentShareAsync_WrongRevisionDate_Throws(SutProvider<CipherService> sutProvider,
|
||||
Cipher cipher, Guid organizationId)
|
||||
{
|
||||
var lastKnownRevisionDate = cipher.RevisionDate.AddDays(-1);
|
||||
var stream = new MemoryStream();
|
||||
var fileName = "test.txt";
|
||||
var key = "test-key";
|
||||
var attachmentId = "attachment-id";
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
() => sutProvider.Sut.CreateAttachmentShareAsync(cipher, stream, fileName, key, 100, attachmentId, organizationId, lastKnownRevisionDate));
|
||||
Assert.Contains("out of date", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData("")]
|
||||
[BitAutoData("Correct Time")]
|
||||
public async Task CreateAttachmentShareAsync_CorrectRevisionDate_DoesNotThrow(string revisionDateString,
|
||||
SutProvider<CipherService> sutProvider, CipherDetails cipher, Guid organizationId)
|
||||
{
|
||||
var lastKnownRevisionDate = string.IsNullOrEmpty(revisionDateString) ? (DateTime?)null : cipher.RevisionDate;
|
||||
var stream = new MemoryStream(new byte[100]);
|
||||
var fileName = "test.txt";
|
||||
var key = "test-key";
|
||||
var attachmentId = "attachment-id";
|
||||
|
||||
// Setup cipher with existing attachment (no TempMetadata)
|
||||
cipher.OrganizationId = null;
|
||||
cipher.SetAttachments(new Dictionary<string, CipherAttachment.MetaData>
|
||||
{
|
||||
[attachmentId] = new CipherAttachment.MetaData
|
||||
{
|
||||
AttachmentId = attachmentId,
|
||||
Size = 100,
|
||||
FileName = "existing.txt",
|
||||
Key = "existing-key"
|
||||
}
|
||||
});
|
||||
|
||||
// Mock organization
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
MaxStorageGb = 1
|
||||
};
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(organizationId)
|
||||
.Returns(organization);
|
||||
|
||||
sutProvider.GetDependency<IAttachmentStorageService>()
|
||||
.UploadShareAttachmentAsync(stream, cipher.Id, organizationId, Arg.Any<CipherAttachment.MetaData>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
sutProvider.GetDependency<ICipherRepository>()
|
||||
.UpdateAttachmentAsync(Arg.Any<CipherAttachment>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await sutProvider.Sut.CreateAttachmentShareAsync(cipher, stream, fileName, key, 100, attachmentId, organizationId, lastKnownRevisionDate);
|
||||
|
||||
await sutProvider.GetDependency<IAttachmentStorageService>().Received(1)
|
||||
.UploadShareAttachmentAsync(stream, cipher.Id, organizationId, Arg.Any<CipherAttachment.MetaData>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SaveDetailsAsync_PersonalVault_WithOrganizationDataOwnershipPolicyEnabled_Throws(
|
||||
@@ -674,32 +910,6 @@ public class CipherServiceTests
|
||||
Arg.Is<IEnumerable<Cipher>>(arg => !arg.Except(ciphers).Any()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData("")]
|
||||
[BitAutoData("Correct Time")]
|
||||
public async Task ShareManyAsync_CorrectRevisionDate_WithBulkResourceCreationServiceEnabled_Passes(string revisionDateString,
|
||||
SutProvider<CipherService> sutProvider, IEnumerable<CipherDetails> ciphers, Organization organization, List<Guid> collectionIds)
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id)
|
||||
.Returns(new Organization
|
||||
{
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
MaxStorageGb = 100
|
||||
});
|
||||
|
||||
var cipherInfos = ciphers.Select(c => (c,
|
||||
string.IsNullOrEmpty(revisionDateString) ? null : (DateTime?)c.RevisionDate));
|
||||
var sharingUserId = ciphers.First().UserId.Value;
|
||||
|
||||
await sutProvider.Sut.ShareManyAsync(cipherInfos, organization.Id, collectionIds, sharingUserId);
|
||||
await sutProvider.GetDependency<ICipherRepository>().Received(1).UpdateCiphersAsync_vNext(sharingUserId,
|
||||
Arg.Is<IEnumerable<Cipher>>(arg => !arg.Except(ciphers).Any()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task RestoreAsync_UpdatesUserCipher(Guid restoringUserId, CipherDetails cipher, SutProvider<CipherService> sutProvider)
|
||||
@@ -1120,33 +1330,6 @@ public class CipherServiceTests
|
||||
Arg.Is<IEnumerable<Cipher>>(arg => !arg.Except(ciphers).Any()));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ShareManyAsync_PaidOrgWithAttachment_WithBulkResourceCreationServiceEnabled_Passes(SutProvider<CipherService> sutProvider,
|
||||
IEnumerable<CipherDetails> ciphers, Guid organizationId, List<Guid> collectionIds)
|
||||
{
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation)
|
||||
.Returns(true);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
|
||||
.Returns(new Organization
|
||||
{
|
||||
PlanType = PlanType.EnterpriseAnnually,
|
||||
MaxStorageGb = 100
|
||||
});
|
||||
ciphers.FirstOrDefault().Attachments =
|
||||
"{\"attachment1\":{\"Size\":\"250\",\"FileName\":\"superCoolFile\","
|
||||
+ "\"Key\":\"superCoolFile\",\"ContainerName\":\"testContainer\",\"Validated\":false}}";
|
||||
|
||||
var cipherInfos = ciphers.Select(c => (c,
|
||||
(DateTime?)c.RevisionDate));
|
||||
var sharingUserId = ciphers.First().UserId.Value;
|
||||
|
||||
await sutProvider.Sut.ShareManyAsync(cipherInfos, organizationId, collectionIds, sharingUserId);
|
||||
await sutProvider.GetDependency<ICipherRepository>().Received(1).UpdateCiphersAsync_vNext(sharingUserId,
|
||||
Arg.Is<IEnumerable<Cipher>>(arg => !arg.Except(ciphers).Any()));
|
||||
}
|
||||
|
||||
private class SaveDetailsAsyncDependencies
|
||||
{
|
||||
public CipherDetails CipherDetails { get; set; }
|
||||
@@ -2103,6 +2286,63 @@ public class CipherServiceTests
|
||||
.PushSyncCiphersAsync(deletingUserId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SoftDeleteAsync_CallsMarkAsCompleteByCipherIds(
|
||||
Guid deletingUserId, CipherDetails cipherDetails, SutProvider<CipherService> sutProvider)
|
||||
{
|
||||
cipherDetails.UserId = deletingUserId;
|
||||
cipherDetails.OrganizationId = null;
|
||||
cipherDetails.DeletedDate = null;
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetUserByIdAsync(deletingUserId)
|
||||
.Returns(new User
|
||||
{
|
||||
Id = deletingUserId,
|
||||
});
|
||||
|
||||
await sutProvider.Sut.SoftDeleteAsync(cipherDetails, deletingUserId);
|
||||
|
||||
await sutProvider.GetDependency<ISecurityTaskRepository>()
|
||||
.Received(1)
|
||||
.MarkAsCompleteByCipherIds(Arg.Is<IEnumerable<Guid>>(ids =>
|
||||
ids.Count() == 1 && ids.First() == cipherDetails.Id));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SoftDeleteManyAsync_CallsMarkAsCompleteByCipherIds(
|
||||
Guid deletingUserId, List<CipherDetails> ciphers, SutProvider<CipherService> sutProvider)
|
||||
{
|
||||
var cipherIds = ciphers.Select(c => c.Id).ToArray();
|
||||
|
||||
foreach (var cipher in ciphers)
|
||||
{
|
||||
cipher.UserId = deletingUserId;
|
||||
cipher.OrganizationId = null;
|
||||
cipher.Edit = true;
|
||||
cipher.DeletedDate = null;
|
||||
}
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.GetUserByIdAsync(deletingUserId)
|
||||
.Returns(new User
|
||||
{
|
||||
Id = deletingUserId,
|
||||
});
|
||||
sutProvider.GetDependency<ICipherRepository>()
|
||||
.GetManyByUserIdAsync(deletingUserId)
|
||||
.Returns(ciphers);
|
||||
|
||||
await sutProvider.Sut.SoftDeleteManyAsync(cipherIds, deletingUserId, null, false);
|
||||
|
||||
await sutProvider.GetDependency<ISecurityTaskRepository>()
|
||||
.Received(1)
|
||||
.MarkAsCompleteByCipherIds(Arg.Is<IEnumerable<Guid>>(ids =>
|
||||
ids.Count() == cipherIds.Length && ids.All(id => cipherIds.Contains(id))));
|
||||
}
|
||||
|
||||
private async Task AssertNoActionsAsync(SutProvider<CipherService> sutProvider)
|
||||
{
|
||||
await sutProvider.GetDependency<ICipherRepository>().DidNotReceiveWithAnyArgs().GetManyOrganizationDetailsByOrganizationIdAsync(default);
|
||||
|
||||
Reference in New Issue
Block a user