mirror of
https://github.com/bitwarden/server
synced 2026-03-02 03:11:20 +00:00
Merge remote-tracking branch 'origin' into auth/pm-27084/register-accepts-new-data-types
This commit is contained in:
@@ -5,7 +5,6 @@ using Bit.Api.IntegrationTest.Factories;
|
||||
using Bit.Api.IntegrationTest.Helpers;
|
||||
using Bit.Api.Models.Request;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
@@ -14,8 +13,6 @@ using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;
|
||||
@@ -28,12 +25,6 @@ public class OrganizationUserControllerTests : IClassFixture<ApiApplicationFacto
|
||||
public OrganizationUserControllerTests(ApiApplicationFactory apiFactory)
|
||||
{
|
||||
_factory = apiFactory;
|
||||
_factory.SubstituteService<IFeatureService>(featureService =>
|
||||
{
|
||||
featureService
|
||||
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
|
||||
.Returns(true);
|
||||
});
|
||||
_client = _factory.CreateClient();
|
||||
_loginHelper = new LoginHelper(_factory, _client);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Net;
|
||||
using Bit.Api.IntegrationTest.Factories;
|
||||
using Bit.Api.IntegrationTest.Helpers;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;
|
||||
|
||||
public class OrganizationUsersControllerSelfRevokeTests : IClassFixture<ApiApplicationFactory>, IAsyncLifetime
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly ApiApplicationFactory _factory;
|
||||
private readonly LoginHelper _loginHelper;
|
||||
|
||||
private string _ownerEmail = null!;
|
||||
|
||||
public OrganizationUsersControllerSelfRevokeTests(ApiApplicationFactory apiFactory)
|
||||
{
|
||||
_factory = apiFactory;
|
||||
_client = _factory.CreateClient();
|
||||
_loginHelper = new LoginHelper(_factory, _client);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_ownerEmail = $"{Guid.NewGuid()}@example.com";
|
||||
await _factory.LoginWithNewAccount(_ownerEmail);
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
_client.Dispose();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelfRevoke_WhenPolicyEnabledAndUserIsEligible_ReturnsOk()
|
||||
{
|
||||
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually,
|
||||
ownerEmail: _ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card);
|
||||
|
||||
var policy = new Policy
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
Type = PolicyType.OrganizationDataOwnership,
|
||||
Enabled = true,
|
||||
Data = null
|
||||
};
|
||||
await _factory.GetService<IPolicyRepository>().CreateAsync(policy);
|
||||
|
||||
var (userEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
|
||||
_factory,
|
||||
organization.Id,
|
||||
OrganizationUserType.User);
|
||||
await _loginHelper.LoginAsync(userEmail);
|
||||
|
||||
var result = await _client.PutAsync($"organizations/{organization.Id}/users/revoke-self", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, result.StatusCode);
|
||||
|
||||
var organizationUserRepository = _factory.GetService<IOrganizationUserRepository>();
|
||||
var organizationUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organization.Id);
|
||||
var revokedUser = organizationUsers.FirstOrDefault(u => u.Email == userEmail);
|
||||
|
||||
Assert.NotNull(revokedUser);
|
||||
Assert.Equal(OrganizationUserStatusType.Revoked, revokedUser.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelfRevoke_WhenUserNotMemberOfOrganization_ReturnsForbidden()
|
||||
{
|
||||
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually,
|
||||
ownerEmail: _ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card);
|
||||
|
||||
var policy = new Policy
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
Type = PolicyType.OrganizationDataOwnership,
|
||||
Enabled = true,
|
||||
Data = null
|
||||
};
|
||||
await _factory.GetService<IPolicyRepository>().CreateAsync(policy);
|
||||
|
||||
var nonMemberEmail = $"{Guid.NewGuid()}@example.com";
|
||||
await _factory.LoginWithNewAccount(nonMemberEmail);
|
||||
await _loginHelper.LoginAsync(nonMemberEmail);
|
||||
|
||||
var result = await _client.PutAsync($"organizations/{organization.Id}/users/revoke-self", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(OrganizationUserType.Owner)]
|
||||
[InlineData(OrganizationUserType.Admin)]
|
||||
public async Task SelfRevoke_WhenUserIsOwnerOrAdmin_ReturnsBadRequest(OrganizationUserType userType)
|
||||
{
|
||||
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually,
|
||||
ownerEmail: _ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card);
|
||||
|
||||
var policy = new Policy
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
Type = PolicyType.OrganizationDataOwnership,
|
||||
Enabled = true,
|
||||
Data = null
|
||||
};
|
||||
await _factory.GetService<IPolicyRepository>().CreateAsync(policy);
|
||||
|
||||
string userEmail;
|
||||
if (userType == OrganizationUserType.Owner)
|
||||
{
|
||||
userEmail = _ownerEmail;
|
||||
}
|
||||
else
|
||||
{
|
||||
(userEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
|
||||
_factory,
|
||||
organization.Id,
|
||||
userType);
|
||||
}
|
||||
|
||||
await _loginHelper.LoginAsync(userEmail);
|
||||
|
||||
var result = await _client.PutAsync($"organizations/{organization.Id}/users/revoke-self", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelfRevoke_WhenUserIsProviderButNotMember_ReturnsForbidden()
|
||||
{
|
||||
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually,
|
||||
ownerEmail: _ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card);
|
||||
|
||||
var policy = new Policy
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
Type = PolicyType.OrganizationDataOwnership,
|
||||
Enabled = true,
|
||||
Data = null
|
||||
};
|
||||
await _factory.GetService<IPolicyRepository>().CreateAsync(policy);
|
||||
|
||||
var provider = await ProviderTestHelpers.CreateProviderAndLinkToOrganizationAsync(
|
||||
_factory,
|
||||
organization.Id,
|
||||
ProviderType.Msp,
|
||||
ProviderStatusType.Billable);
|
||||
|
||||
var providerUserEmail = $"{Guid.NewGuid()}@example.com";
|
||||
await _factory.LoginWithNewAccount(providerUserEmail);
|
||||
await ProviderTestHelpers.CreateProviderUserAsync(
|
||||
_factory,
|
||||
provider.Id,
|
||||
providerUserEmail,
|
||||
ProviderUserType.ProviderAdmin);
|
||||
|
||||
await _loginHelper.LoginAsync(providerUserEmail);
|
||||
|
||||
var result = await _client.PutAsync($"organizations/{organization.Id}/users/revoke-self", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,28 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Bit.Api.Auth.Models.Request.Accounts;
|
||||
using Bit.Api.IntegrationTest.Factories;
|
||||
using Bit.Api.IntegrationTest.Helpers;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Repositories;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.KeyManagement.Models.Api.Request;
|
||||
using Bit.Core.KeyManagement.Repositories;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using static Bit.Core.KeyManagement.Enums.SignatureAlgorithm;
|
||||
|
||||
namespace Bit.Api.IntegrationTest.Controllers;
|
||||
|
||||
@@ -21,6 +30,8 @@ public class AccountsControllerTest : IClassFixture<ApiApplicationFactory>, IAsy
|
||||
{
|
||||
private static readonly string _masterKeyWrappedUserKey =
|
||||
"2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=";
|
||||
private static readonly string _mockEncryptedType7String = "7.AOs41Hd8OQiCPXjyJKCiDA==";
|
||||
private static readonly string _mockEncryptedType7WrappedSigningKey = "7.DRv74Kg1RSlFSam1MNFlGD==";
|
||||
|
||||
private static readonly string _masterPasswordHash = "master_password_hash";
|
||||
private static readonly string _newMasterPasswordHash = "new_master_password_hash";
|
||||
@@ -35,6 +46,11 @@ public class AccountsControllerTest : IClassFixture<ApiApplicationFactory>, IAsy
|
||||
private readonly IPushNotificationService _pushNotificationService;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IPasswordHasher<User> _passwordHasher;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly ISsoConfigRepository _ssoConfigRepository;
|
||||
private readonly IUserSignatureKeyPairRepository _userSignatureKeyPairRepository;
|
||||
private readonly IEventRepository _eventRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
|
||||
private string _ownerEmail = null!;
|
||||
|
||||
@@ -49,6 +65,11 @@ public class AccountsControllerTest : IClassFixture<ApiApplicationFactory>, IAsy
|
||||
_pushNotificationService = _factory.GetService<IPushNotificationService>();
|
||||
_featureService = _factory.GetService<IFeatureService>();
|
||||
_passwordHasher = _factory.GetService<IPasswordHasher<User>>();
|
||||
_organizationRepository = _factory.GetService<IOrganizationRepository>();
|
||||
_ssoConfigRepository = _factory.GetService<ISsoConfigRepository>();
|
||||
_userSignatureKeyPairRepository = _factory.GetService<IUserSignatureKeyPairRepository>();
|
||||
_eventRepository = _factory.GetService<IEventRepository>();
|
||||
_organizationUserRepository = _factory.GetService<IOrganizationUserRepository>();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
@@ -435,4 +456,531 @@ public class AccountsControllerTest : IClassFixture<ApiApplicationFactory>, IAsy
|
||||
message.Content = JsonContent.Create(requestModel);
|
||||
return await _client.SendAsync(message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostSetPasswordAsync_V1_MasterPasswordDecryption_Success(string organizationSsoIdentifier)
|
||||
{
|
||||
// Arrange - Create organization and user
|
||||
var ownerEmail = $"owner-{Guid.NewGuid()}@bitwarden.com";
|
||||
await _factory.LoginWithNewAccount(ownerEmail);
|
||||
|
||||
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory,
|
||||
ownerEmail: ownerEmail,
|
||||
name: "Test Org V1");
|
||||
organization.UseSso = true;
|
||||
organization.Identifier = organizationSsoIdentifier;
|
||||
await _organizationRepository.ReplaceAsync(organization);
|
||||
|
||||
await _ssoConfigRepository.CreateAsync(new SsoConfig
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
Enabled = true,
|
||||
Data = JsonSerializer.Serialize(new SsoConfigurationData
|
||||
{
|
||||
MemberDecryptionType = MemberDecryptionType.MasterPassword,
|
||||
}, JsonHelpers.CamelCase),
|
||||
});
|
||||
|
||||
// Create user with password initially, so we can login
|
||||
var userEmail = $"user-{Guid.NewGuid()}@bitwarden.com";
|
||||
await _factory.LoginWithNewAccount(userEmail);
|
||||
|
||||
// Add user to organization
|
||||
var user = await _userRepository.GetByEmailAsync(userEmail);
|
||||
Assert.NotNull(user);
|
||||
await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, userEmail,
|
||||
OrganizationUserType.User, userStatusType: OrganizationUserStatusType.Invited);
|
||||
|
||||
// Login as the user
|
||||
await _loginHelper.LoginAsync(userEmail);
|
||||
|
||||
// Remove the master password and keys to simulate newly registered SSO user
|
||||
user.MasterPassword = null;
|
||||
user.Key = null;
|
||||
user.PrivateKey = null;
|
||||
user.PublicKey = null;
|
||||
await _userRepository.ReplaceAsync(user);
|
||||
|
||||
// V1 (Obsolete) request format - to be removed with PM-27327
|
||||
var request = new
|
||||
{
|
||||
masterPasswordHash = _newMasterPasswordHash,
|
||||
key = _masterKeyWrappedUserKey,
|
||||
keys = new
|
||||
{
|
||||
publicKey = "v1-publicKey",
|
||||
encryptedPrivateKey = "v1-encryptedPrivateKey"
|
||||
},
|
||||
kdf = 0, // PBKDF2_SHA256
|
||||
kdfIterations = 600000,
|
||||
kdfMemory = (int?)null,
|
||||
kdfParallelism = (int?)null,
|
||||
masterPasswordHint = "v1-integration-test-hint",
|
||||
orgIdentifier = organization.Identifier
|
||||
};
|
||||
|
||||
var jsonRequest = JsonSerializer.Serialize(request, JsonHelpers.CamelCase);
|
||||
|
||||
// Act
|
||||
using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password");
|
||||
message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await _client.SendAsync(message);
|
||||
|
||||
// Assert
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
Assert.Fail($"Expected success but got {response.StatusCode}. Error: {errorContent}");
|
||||
}
|
||||
|
||||
// Verify user in database
|
||||
var updatedUser = await _userRepository.GetByEmailAsync(userEmail);
|
||||
Assert.NotNull(updatedUser);
|
||||
Assert.Equal("v1-integration-test-hint", updatedUser.MasterPasswordHint);
|
||||
|
||||
// Verify the master password is hashed and stored
|
||||
Assert.NotNull(updatedUser.MasterPassword);
|
||||
var verificationResult = _passwordHasher.VerifyHashedPassword(updatedUser, updatedUser.MasterPassword, _newMasterPasswordHash);
|
||||
Assert.Equal(PasswordVerificationResult.Success, verificationResult);
|
||||
|
||||
// Verify KDF settings
|
||||
Assert.Equal(KdfType.PBKDF2_SHA256, updatedUser.Kdf);
|
||||
Assert.Equal(600_000, updatedUser.KdfIterations);
|
||||
Assert.Null(updatedUser.KdfMemory);
|
||||
Assert.Null(updatedUser.KdfParallelism);
|
||||
|
||||
// Verify timestamps are updated
|
||||
Assert.Equal(DateTime.UtcNow, updatedUser.RevisionDate, TimeSpan.FromMinutes(1));
|
||||
Assert.Equal(DateTime.UtcNow, updatedUser.AccountRevisionDate, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Verify keys are set (V1 uses Keys property)
|
||||
Assert.Equal(_masterKeyWrappedUserKey, updatedUser.Key);
|
||||
Assert.Equal("v1-publicKey", updatedUser.PublicKey);
|
||||
Assert.Equal("v1-encryptedPrivateKey", updatedUser.PrivateKey);
|
||||
|
||||
// Verify User_ChangedPassword event was logged
|
||||
var events = await _eventRepository.GetManyByUserAsync(updatedUser.Id, DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow.AddMinutes(1), new PageOptions { PageSize = 100 });
|
||||
Assert.NotNull(events);
|
||||
Assert.Contains(events.Data, e => e.Type == EventType.User_ChangedPassword && e.UserId == updatedUser.Id);
|
||||
|
||||
// Verify user was accepted into the organization
|
||||
var orgUsers = await _organizationUserRepository.GetManyByUserAsync(updatedUser.Id);
|
||||
var orgUser = orgUsers.FirstOrDefault(ou => ou.OrganizationId == organization.Id);
|
||||
Assert.NotNull(orgUser);
|
||||
Assert.Equal(OrganizationUserStatusType.Accepted, orgUser.Status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostSetPasswordAsync_V2_MasterPasswordDecryption_Success(string organizationSsoIdentifier)
|
||||
{
|
||||
// Arrange - Create organization and user
|
||||
var ownerEmail = $"owner-{Guid.NewGuid()}@bitwarden.com";
|
||||
await _factory.LoginWithNewAccount(ownerEmail);
|
||||
|
||||
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory,
|
||||
ownerEmail: ownerEmail,
|
||||
name: "Test Org");
|
||||
organization.UseSso = true;
|
||||
organization.Identifier = organizationSsoIdentifier;
|
||||
await _organizationRepository.ReplaceAsync(organization);
|
||||
|
||||
await _ssoConfigRepository.CreateAsync(new SsoConfig
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
Enabled = true,
|
||||
Data = JsonSerializer.Serialize(new SsoConfigurationData
|
||||
{
|
||||
MemberDecryptionType = MemberDecryptionType.MasterPassword,
|
||||
}, JsonHelpers.CamelCase),
|
||||
});
|
||||
|
||||
// Create user with password initially, so we can login
|
||||
var userEmail = $"user-{Guid.NewGuid()}@bitwarden.com";
|
||||
await _factory.LoginWithNewAccount(userEmail);
|
||||
|
||||
// Add user to organization
|
||||
var user = await _userRepository.GetByEmailAsync(userEmail);
|
||||
Assert.NotNull(user);
|
||||
await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, userEmail,
|
||||
OrganizationUserType.User, userStatusType: OrganizationUserStatusType.Invited);
|
||||
|
||||
// Login as the user
|
||||
await _loginHelper.LoginAsync(userEmail);
|
||||
|
||||
// Remove the master password and keys to simulate newly registered SSO user
|
||||
user.MasterPassword = null;
|
||||
user.Key = null;
|
||||
user.PrivateKey = null;
|
||||
user.PublicKey = null;
|
||||
user.SignedPublicKey = null;
|
||||
await _userRepository.ReplaceAsync(user);
|
||||
|
||||
var jsonRequest = CreateV2SetPasswordRequestJson(
|
||||
userEmail,
|
||||
organization.Identifier,
|
||||
"integration-test-hint",
|
||||
includeAccountKeys: true);
|
||||
|
||||
// Act
|
||||
using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password");
|
||||
message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await _client.SendAsync(message);
|
||||
|
||||
// Assert
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
Assert.Fail($"Expected success but got {response.StatusCode}. Error: {errorContent}");
|
||||
}
|
||||
|
||||
// Verify user in database
|
||||
var updatedUser = await _userRepository.GetByEmailAsync(userEmail);
|
||||
Assert.NotNull(updatedUser);
|
||||
Assert.Equal("integration-test-hint", updatedUser.MasterPasswordHint);
|
||||
|
||||
// Verify the master password is hashed and stored
|
||||
Assert.NotNull(updatedUser.MasterPassword);
|
||||
var verificationResult = _passwordHasher.VerifyHashedPassword(updatedUser, updatedUser.MasterPassword, _newMasterPasswordHash);
|
||||
Assert.Equal(PasswordVerificationResult.Success, verificationResult);
|
||||
|
||||
// Verify KDF settings
|
||||
Assert.Equal(KdfType.PBKDF2_SHA256, updatedUser.Kdf);
|
||||
Assert.Equal(600_000, updatedUser.KdfIterations);
|
||||
Assert.Null(updatedUser.KdfMemory);
|
||||
Assert.Null(updatedUser.KdfParallelism);
|
||||
|
||||
// Verify timestamps are updated
|
||||
Assert.Equal(DateTime.UtcNow, updatedUser.RevisionDate, TimeSpan.FromMinutes(1));
|
||||
Assert.Equal(DateTime.UtcNow, updatedUser.AccountRevisionDate, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Verify keys are set
|
||||
Assert.Equal(_masterKeyWrappedUserKey, updatedUser.Key);
|
||||
Assert.Equal("publicKey", updatedUser.PublicKey);
|
||||
Assert.Equal(_mockEncryptedType7String, updatedUser.PrivateKey);
|
||||
Assert.Equal("signedPublicKey", updatedUser.SignedPublicKey);
|
||||
|
||||
// Verify security state
|
||||
Assert.Equal(2, updatedUser.SecurityVersion);
|
||||
Assert.Equal("v2", updatedUser.SecurityState);
|
||||
|
||||
// Verify signature key pair data
|
||||
var signatureKeyPair = await _userSignatureKeyPairRepository.GetByUserIdAsync(updatedUser.Id);
|
||||
Assert.NotNull(signatureKeyPair);
|
||||
Assert.Equal(Ed25519, signatureKeyPair.SignatureAlgorithm);
|
||||
Assert.Equal(_mockEncryptedType7WrappedSigningKey, signatureKeyPair.WrappedSigningKey);
|
||||
Assert.Equal("verifyingKey", signatureKeyPair.VerifyingKey);
|
||||
|
||||
// Verify User_ChangedPassword event was logged
|
||||
var events = await _eventRepository.GetManyByUserAsync(updatedUser.Id, DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow.AddMinutes(1), new PageOptions { PageSize = 100 });
|
||||
Assert.NotNull(events);
|
||||
Assert.Contains(events.Data, e => e.Type == EventType.User_ChangedPassword && e.UserId == updatedUser.Id);
|
||||
|
||||
// Verify user was accepted into the organization
|
||||
var orgUsers = await _organizationUserRepository.GetManyByUserAsync(updatedUser.Id);
|
||||
var orgUser = orgUsers.FirstOrDefault(ou => ou.OrganizationId == organization.Id);
|
||||
Assert.NotNull(orgUser);
|
||||
Assert.Equal(OrganizationUserStatusType.Accepted, orgUser.Status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostSetPasswordAsync_V2_TDEDecryption_Success(string organizationSsoIdentifier)
|
||||
{
|
||||
// Arrange - Create organization with TDE
|
||||
var ownerEmail = $"owner-{Guid.NewGuid()}@bitwarden.com";
|
||||
await _factory.LoginWithNewAccount(ownerEmail);
|
||||
|
||||
var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory,
|
||||
ownerEmail: ownerEmail,
|
||||
name: "Test Org TDE");
|
||||
organization.UseSso = true;
|
||||
organization.Identifier = organizationSsoIdentifier;
|
||||
await _organizationRepository.ReplaceAsync(organization);
|
||||
|
||||
// Configure SSO for TDE (TrustedDeviceEncryption)
|
||||
await _ssoConfigRepository.CreateAsync(new SsoConfig
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
Enabled = true,
|
||||
Data = JsonSerializer.Serialize(new SsoConfigurationData
|
||||
{
|
||||
MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption,
|
||||
}, JsonHelpers.CamelCase),
|
||||
});
|
||||
|
||||
// Create user with password initially, so we can login
|
||||
var userEmail = $"user-{Guid.NewGuid()}@bitwarden.com";
|
||||
await _factory.LoginWithNewAccount(userEmail);
|
||||
|
||||
var user = await _userRepository.GetByEmailAsync(userEmail);
|
||||
Assert.NotNull(user);
|
||||
|
||||
// Add user to organization and confirm them (TDE users are confirmed, not invited)
|
||||
await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, userEmail,
|
||||
OrganizationUserType.User, userStatusType: OrganizationUserStatusType.Confirmed);
|
||||
|
||||
// Login as the user
|
||||
await _loginHelper.LoginAsync(userEmail);
|
||||
|
||||
// Set up TDE user with V2 account keys but no master password
|
||||
// TDE users already have their account keys from device provisioning
|
||||
user.MasterPassword = null;
|
||||
user.Key = null;
|
||||
user.PublicKey = "tde-publicKey";
|
||||
user.PrivateKey = _mockEncryptedType7String;
|
||||
user.SignedPublicKey = "tde-signedPublicKey";
|
||||
user.SecurityVersion = 2;
|
||||
user.SecurityState = "v2-tde";
|
||||
await _userRepository.ReplaceAsync(user);
|
||||
|
||||
// Create signature key pair for TDE user
|
||||
var signatureKeyPairData = new Core.KeyManagement.Models.Data.SignatureKeyPairData(
|
||||
Ed25519,
|
||||
_mockEncryptedType7WrappedSigningKey,
|
||||
"tde-verifyingKey");
|
||||
var setSignatureKeyPair = await _userSignatureKeyPairRepository.GetByUserIdAsync(user.Id);
|
||||
if (setSignatureKeyPair == null)
|
||||
{
|
||||
var newKeyPair = new Core.KeyManagement.Entities.UserSignatureKeyPair
|
||||
{
|
||||
UserId = user.Id,
|
||||
SignatureAlgorithm = signatureKeyPairData.SignatureAlgorithm,
|
||||
SigningKey = signatureKeyPairData.WrappedSigningKey,
|
||||
VerifyingKey = signatureKeyPairData.VerifyingKey,
|
||||
CreationDate = DateTime.UtcNow,
|
||||
RevisionDate = DateTime.UtcNow
|
||||
};
|
||||
newKeyPair.SetNewId();
|
||||
await _userSignatureKeyPairRepository.CreateAsync(newKeyPair);
|
||||
}
|
||||
|
||||
var jsonRequest = CreateV2SetPasswordRequestJson(
|
||||
userEmail,
|
||||
organization.Identifier,
|
||||
"tde-test-hint",
|
||||
includeAccountKeys: false);
|
||||
|
||||
// Act
|
||||
using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password");
|
||||
message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await _client.SendAsync(message);
|
||||
|
||||
// Assert
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
Assert.Fail($"Expected success but got {response.StatusCode}. Error: {errorContent}");
|
||||
}
|
||||
|
||||
// Verify user in database
|
||||
var updatedUser = await _userRepository.GetByEmailAsync(userEmail);
|
||||
Assert.NotNull(updatedUser);
|
||||
Assert.Equal("tde-test-hint", updatedUser.MasterPasswordHint);
|
||||
|
||||
// Verify the master password is hashed and stored
|
||||
Assert.NotNull(updatedUser.MasterPassword);
|
||||
var verificationResult = _passwordHasher.VerifyHashedPassword(updatedUser, updatedUser.MasterPassword, _newMasterPasswordHash);
|
||||
Assert.Equal(PasswordVerificationResult.Success, verificationResult);
|
||||
|
||||
// Verify KDF settings
|
||||
Assert.Equal(KdfType.PBKDF2_SHA256, updatedUser.Kdf);
|
||||
Assert.Equal(600_000, updatedUser.KdfIterations);
|
||||
Assert.Null(updatedUser.KdfMemory);
|
||||
Assert.Null(updatedUser.KdfParallelism);
|
||||
|
||||
// Verify timestamps are updated
|
||||
Assert.Equal(DateTime.UtcNow, updatedUser.RevisionDate, TimeSpan.FromMinutes(1));
|
||||
Assert.Equal(DateTime.UtcNow, updatedUser.AccountRevisionDate, TimeSpan.FromMinutes(1));
|
||||
|
||||
// Verify key is set
|
||||
Assert.Equal(_masterKeyWrappedUserKey, updatedUser.Key);
|
||||
|
||||
// Verify AccountKeys are preserved (TDE users already had V2 keys)
|
||||
Assert.Equal("tde-publicKey", updatedUser.PublicKey);
|
||||
Assert.Equal(_mockEncryptedType7String, updatedUser.PrivateKey);
|
||||
Assert.Equal("tde-signedPublicKey", updatedUser.SignedPublicKey);
|
||||
Assert.Equal(2, updatedUser.SecurityVersion);
|
||||
Assert.Equal("v2-tde", updatedUser.SecurityState);
|
||||
|
||||
// Verify signature key pair is preserved (TDE users already had signature keys)
|
||||
var signatureKeyPair = await _userSignatureKeyPairRepository.GetByUserIdAsync(updatedUser.Id);
|
||||
Assert.NotNull(signatureKeyPair);
|
||||
Assert.Equal(Ed25519, signatureKeyPair.SignatureAlgorithm);
|
||||
Assert.Equal(_mockEncryptedType7WrappedSigningKey, signatureKeyPair.WrappedSigningKey);
|
||||
Assert.Equal("tde-verifyingKey", signatureKeyPair.VerifyingKey);
|
||||
|
||||
// Verify User_ChangedPassword event was logged
|
||||
var events = await _eventRepository.GetManyByUserAsync(updatedUser.Id, DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow.AddMinutes(1), new PageOptions { PageSize = 100 });
|
||||
Assert.NotNull(events);
|
||||
Assert.Contains(events.Data, e => e.Type == EventType.User_ChangedPassword && e.UserId == updatedUser.Id);
|
||||
|
||||
// Verify user remains confirmed in the organization
|
||||
var orgUsers = await _organizationUserRepository.GetManyByUserAsync(updatedUser.Id);
|
||||
var orgUser = orgUsers.FirstOrDefault(ou => ou.OrganizationId == organization.Id);
|
||||
Assert.NotNull(orgUser);
|
||||
Assert.Equal(OrganizationUserStatusType.Confirmed, orgUser.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSetPasswordAsync_V2_Unauthorized_ReturnsUnauthorized()
|
||||
{
|
||||
// Arrange - Don't login
|
||||
var jsonRequest = CreateV2SetPasswordRequestJson(
|
||||
"test@bitwarden.com",
|
||||
"test-org-identifier",
|
||||
"test-hint",
|
||||
includeAccountKeys: true);
|
||||
|
||||
// Act
|
||||
using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password");
|
||||
message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await _client.SendAsync(message);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostSetPasswordAsync_V2_MismatchedKdfSettings_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
var email = $"kdf-mismatch-test-{Guid.NewGuid()}@bitwarden.com";
|
||||
await _factory.LoginWithNewAccount(email);
|
||||
await _loginHelper.LoginAsync(email);
|
||||
|
||||
// Test mismatched KDF settings (600000 vs 650000 iterations)
|
||||
var request = new
|
||||
{
|
||||
masterPasswordAuthentication = new
|
||||
{
|
||||
kdf = new
|
||||
{
|
||||
kdfType = 0,
|
||||
iterations = 600000
|
||||
},
|
||||
masterPasswordAuthenticationHash = _newMasterPasswordHash,
|
||||
salt = email
|
||||
},
|
||||
masterPasswordUnlock = new
|
||||
{
|
||||
kdf = new
|
||||
{
|
||||
kdfType = 0,
|
||||
iterations = 650000 // Different from authentication KDF
|
||||
},
|
||||
masterKeyWrappedUserKey = _masterKeyWrappedUserKey,
|
||||
salt = email
|
||||
},
|
||||
accountKeys = new
|
||||
{
|
||||
userKeyEncryptedAccountPrivateKey = "7.AOs41Hd8OQiCPXjyJKCiDA==",
|
||||
accountPublicKey = "public-key"
|
||||
},
|
||||
orgIdentifier = "test-org-identifier"
|
||||
};
|
||||
|
||||
var jsonRequest = JsonSerializer.Serialize(request, JsonHelpers.CamelCase);
|
||||
|
||||
// Act
|
||||
using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password");
|
||||
message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await _client.SendAsync(message);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(KdfType.PBKDF2_SHA256, 1, null, null)]
|
||||
[InlineData(KdfType.Argon2id, 4, null, 5)]
|
||||
[InlineData(KdfType.Argon2id, 4, 65, null)]
|
||||
public async Task PostSetPasswordAsync_V2_InvalidKdfSettings_ReturnsBadRequest(
|
||||
KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism)
|
||||
{
|
||||
// Arrange
|
||||
var email = $"invalid-kdf-test-{Guid.NewGuid()}@bitwarden.com";
|
||||
await _factory.LoginWithNewAccount(email);
|
||||
await _loginHelper.LoginAsync(email);
|
||||
|
||||
var jsonRequest = CreateV2SetPasswordRequestJson(
|
||||
email,
|
||||
"test-org-identifier",
|
||||
"test-hint",
|
||||
includeAccountKeys: true,
|
||||
kdfType: kdf,
|
||||
kdfIterations: kdfIterations,
|
||||
kdfMemory: kdfMemory,
|
||||
kdfParallelism: kdfParallelism);
|
||||
|
||||
// Act
|
||||
using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password");
|
||||
message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await _client.SendAsync(message);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
private static string CreateV2SetPasswordRequestJson(
|
||||
string userEmail,
|
||||
string orgIdentifier,
|
||||
string hint,
|
||||
bool includeAccountKeys = true,
|
||||
KdfType? kdfType = null,
|
||||
int? kdfIterations = null,
|
||||
int? kdfMemory = null,
|
||||
int? kdfParallelism = null)
|
||||
{
|
||||
var kdf = new
|
||||
{
|
||||
kdfType = (int)(kdfType ?? KdfType.PBKDF2_SHA256),
|
||||
iterations = kdfIterations ?? 600000,
|
||||
memory = kdfMemory,
|
||||
parallelism = kdfParallelism
|
||||
};
|
||||
|
||||
var request = new
|
||||
{
|
||||
masterPasswordAuthentication = new
|
||||
{
|
||||
kdf,
|
||||
masterPasswordAuthenticationHash = _newMasterPasswordHash,
|
||||
salt = userEmail
|
||||
},
|
||||
masterPasswordUnlock = new
|
||||
{
|
||||
kdf,
|
||||
masterKeyWrappedUserKey = _masterKeyWrappedUserKey,
|
||||
salt = userEmail
|
||||
},
|
||||
accountKeys = includeAccountKeys ? new
|
||||
{
|
||||
accountPublicKey = "publicKey",
|
||||
userKeyEncryptedAccountPrivateKey = _mockEncryptedType7String,
|
||||
publicKeyEncryptionKeyPair = new
|
||||
{
|
||||
publicKey = "publicKey",
|
||||
wrappedPrivateKey = _mockEncryptedType7String,
|
||||
signedPublicKey = "signedPublicKey"
|
||||
},
|
||||
signatureKeyPair = new
|
||||
{
|
||||
signatureAlgorithm = "ed25519",
|
||||
wrappedSigningKey = _mockEncryptedType7WrappedSigningKey,
|
||||
verifyingKey = "verifyingKey"
|
||||
},
|
||||
securityState = new
|
||||
{
|
||||
securityVersion = 2,
|
||||
securityState = "v2"
|
||||
}
|
||||
} : null,
|
||||
masterPasswordHint = hint,
|
||||
orgIdentifier
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(request, JsonHelpers.CamelCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using Bit.Api.AdminConsole.Authorization.Requirements;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Test.AdminConsole.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Api.Test.AdminConsole.Authorization.Requirements;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class MemberRequirementTests
|
||||
{
|
||||
[Theory]
|
||||
[CurrentContextOrganizationCustomize]
|
||||
[BitAutoData(OrganizationUserType.Owner)]
|
||||
[BitAutoData(OrganizationUserType.Admin)]
|
||||
[BitAutoData(OrganizationUserType.User)]
|
||||
[BitAutoData(OrganizationUserType.Custom)]
|
||||
public async Task AuthorizeAsync_WhenUserIsOrganizationMember_ThenRequestShouldBeAuthorized(
|
||||
OrganizationUserType type,
|
||||
CurrentContextOrganization organization,
|
||||
SutProvider<MemberRequirement> sutProvider)
|
||||
{
|
||||
organization.Type = type;
|
||||
|
||||
var actual = await sutProvider.Sut.AuthorizeAsync(organization, () => Task.FromResult(false));
|
||||
|
||||
Assert.True(actual);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task AuthorizeAsync_WhenUserIsNotOrganizationMember_ThenRequestShouldBeDenied(
|
||||
SutProvider<MemberRequirement> sutProvider)
|
||||
{
|
||||
var actual = await sutProvider.Sut.AuthorizeAsync(null, () => Task.FromResult(false));
|
||||
|
||||
Assert.False(actual);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task AuthorizeAsync_WhenUserIsProviderButNotMember_ThenRequestShouldBeDenied(
|
||||
SutProvider<MemberRequirement> sutProvider)
|
||||
{
|
||||
var actual = await sutProvider.Sut.AuthorizeAsync(null, () => Task.FromResult(true));
|
||||
|
||||
Assert.False(actual);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ public class ProfileOrganizationResponseModelTests
|
||||
UseCustomPermissions = organization.UseCustomPermissions,
|
||||
UseRiskInsights = organization.UseRiskInsights,
|
||||
UsePhishingBlocker = organization.UsePhishingBlocker,
|
||||
UseDisableSMAdsForUsers = organization.UseDisableSmAdsForUsers,
|
||||
UseOrganizationDomains = organization.UseOrganizationDomains,
|
||||
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies,
|
||||
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation,
|
||||
|
||||
@@ -46,6 +46,7 @@ public class ProfileProviderOrganizationResponseModelTests
|
||||
UseCustomPermissions = organization.UseCustomPermissions,
|
||||
UseRiskInsights = organization.UseRiskInsights,
|
||||
UsePhishingBlocker = organization.UsePhishingBlocker,
|
||||
UseDisableSMAdsForUsers = organization.UseDisableSmAdsForUsers,
|
||||
UseOrganizationDomains = organization.UseOrganizationDomains,
|
||||
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies,
|
||||
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation,
|
||||
|
||||
@@ -4,11 +4,13 @@ using Bit.Api.Auth.Models.Request.Accounts;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.Services;
|
||||
using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.KeyManagement.Kdf;
|
||||
using Bit.Core.KeyManagement.Models.Api.Request;
|
||||
@@ -33,7 +35,9 @@ public class AccountsControllerTests : IDisposable
|
||||
private readonly IProviderUserRepository _providerUserRepository;
|
||||
private readonly IPolicyService _policyService;
|
||||
private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand;
|
||||
private readonly ISetInitialMasterPasswordCommandV1 _setInitialMasterPasswordCommandV1;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
private readonly ITdeSetPasswordCommand _tdeSetPasswordCommand;
|
||||
private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
|
||||
@@ -49,7 +53,9 @@ public class AccountsControllerTests : IDisposable
|
||||
_providerUserRepository = Substitute.For<IProviderUserRepository>();
|
||||
_policyService = Substitute.For<IPolicyService>();
|
||||
_setInitialMasterPasswordCommand = Substitute.For<ISetInitialMasterPasswordCommand>();
|
||||
_setInitialMasterPasswordCommandV1 = Substitute.For<ISetInitialMasterPasswordCommandV1>();
|
||||
_twoFactorIsEnabledQuery = Substitute.For<ITwoFactorIsEnabledQuery>();
|
||||
_tdeSetPasswordCommand = Substitute.For<ITdeSetPasswordCommand>();
|
||||
_tdeOffboardingPasswordCommand = Substitute.For<ITdeOffboardingPasswordCommand>();
|
||||
_featureService = Substitute.For<IFeatureService>();
|
||||
_userAccountKeysQuery = Substitute.For<IUserAccountKeysQuery>();
|
||||
@@ -64,6 +70,8 @@ public class AccountsControllerTests : IDisposable
|
||||
_userService,
|
||||
_policyService,
|
||||
_setInitialMasterPasswordCommand,
|
||||
_setInitialMasterPasswordCommandV1,
|
||||
_tdeSetPasswordCommand,
|
||||
_tdeOffboardingPasswordCommand,
|
||||
_twoFactorIsEnabledQuery,
|
||||
_featureService,
|
||||
@@ -379,13 +387,13 @@ public class AccountsControllerTests : IDisposable
|
||||
[BitAutoData(true, null, "newPublicKey", false)]
|
||||
// reject overwriting existing keys
|
||||
[BitAutoData(true, "newPrivateKey", "newPublicKey", false)]
|
||||
public async Task PostSetPasswordAsync_WhenUserExistsAndSettingPasswordSucceeds_ShouldHandleKeysCorrectlyAndReturn(
|
||||
public async Task PostSetPasswordAsync_V1_WhenUserExistsAndSettingPasswordSucceeds_ShouldHandleKeysCorrectlyAndReturn(
|
||||
bool hasExistingKeys,
|
||||
string requestPrivateKey,
|
||||
string requestPublicKey,
|
||||
bool shouldSucceed,
|
||||
User user,
|
||||
SetPasswordRequestModel setPasswordRequestModel)
|
||||
SetInitialPasswordRequestModel setInitialPasswordRequestModel)
|
||||
{
|
||||
// Arrange
|
||||
const string existingPublicKey = "existingPublicKey";
|
||||
@@ -402,13 +410,15 @@ public class AccountsControllerTests : IDisposable
|
||||
user.PrivateKey = null;
|
||||
}
|
||||
|
||||
UpdateSetInitialPasswordRequestModelToV1(setInitialPasswordRequestModel);
|
||||
|
||||
if (requestPrivateKey == null && requestPublicKey == null)
|
||||
{
|
||||
setPasswordRequestModel.Keys = null;
|
||||
setInitialPasswordRequestModel.Keys = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
setPasswordRequestModel.Keys = new KeysRequestModel
|
||||
setInitialPasswordRequestModel.Keys = new KeysRequestModel
|
||||
{
|
||||
EncryptedPrivateKey = requestPrivateKey,
|
||||
PublicKey = requestPublicKey
|
||||
@@ -416,44 +426,44 @@ public class AccountsControllerTests : IDisposable
|
||||
}
|
||||
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult(user));
|
||||
_setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(
|
||||
_setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync(
|
||||
user,
|
||||
setPasswordRequestModel.MasterPasswordHash,
|
||||
setPasswordRequestModel.Key,
|
||||
setPasswordRequestModel.OrgIdentifier)
|
||||
setInitialPasswordRequestModel.MasterPasswordHash,
|
||||
setInitialPasswordRequestModel.Key,
|
||||
setInitialPasswordRequestModel.OrgIdentifier)
|
||||
.Returns(Task.FromResult(IdentityResult.Success));
|
||||
|
||||
// Act
|
||||
if (shouldSucceed)
|
||||
{
|
||||
await _sut.PostSetPasswordAsync(setPasswordRequestModel);
|
||||
await _sut.PostSetPasswordAsync(setInitialPasswordRequestModel);
|
||||
// Assert
|
||||
await _setInitialMasterPasswordCommand.Received(1)
|
||||
await _setInitialMasterPasswordCommandV1.Received(1)
|
||||
.SetInitialMasterPasswordAsync(
|
||||
Arg.Is<User>(u => u == user),
|
||||
Arg.Is<string>(s => s == setPasswordRequestModel.MasterPasswordHash),
|
||||
Arg.Is<string>(s => s == setPasswordRequestModel.Key),
|
||||
Arg.Is<string>(s => s == setPasswordRequestModel.OrgIdentifier));
|
||||
Arg.Is<string>(s => s == setInitialPasswordRequestModel.MasterPasswordHash),
|
||||
Arg.Is<string>(s => s == setInitialPasswordRequestModel.Key),
|
||||
Arg.Is<string>(s => s == setInitialPasswordRequestModel.OrgIdentifier));
|
||||
|
||||
// Additional Assertions for User object modifications
|
||||
Assert.Equal(setPasswordRequestModel.MasterPasswordHint, user.MasterPasswordHint);
|
||||
Assert.Equal(setPasswordRequestModel.Kdf, user.Kdf);
|
||||
Assert.Equal(setPasswordRequestModel.KdfIterations, user.KdfIterations);
|
||||
Assert.Equal(setPasswordRequestModel.KdfMemory, user.KdfMemory);
|
||||
Assert.Equal(setPasswordRequestModel.KdfParallelism, user.KdfParallelism);
|
||||
Assert.Equal(setPasswordRequestModel.Key, user.Key);
|
||||
Assert.Equal(setInitialPasswordRequestModel.MasterPasswordHint, user.MasterPasswordHint);
|
||||
Assert.Equal(setInitialPasswordRequestModel.Kdf, user.Kdf);
|
||||
Assert.Equal(setInitialPasswordRequestModel.KdfIterations, user.KdfIterations);
|
||||
Assert.Equal(setInitialPasswordRequestModel.KdfMemory, user.KdfMemory);
|
||||
Assert.Equal(setInitialPasswordRequestModel.KdfParallelism, user.KdfParallelism);
|
||||
Assert.Equal(setInitialPasswordRequestModel.Key, user.Key);
|
||||
}
|
||||
else
|
||||
{
|
||||
await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostSetPasswordAsync(setPasswordRequestModel));
|
||||
await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostSetPasswordAsync(setInitialPasswordRequestModel));
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostSetPasswordAsync_WhenUserExistsAndHasKeysAndKeysAreUpdated_ShouldThrowAsync(
|
||||
public async Task PostSetPasswordAsync_V1_WhenUserExistsAndHasKeysAndKeysAreUpdated_ShouldThrowAsync(
|
||||
User user,
|
||||
SetPasswordRequestModel setPasswordRequestModel)
|
||||
SetInitialPasswordRequestModel setInitialPasswordRequestModel)
|
||||
{
|
||||
// Arrange
|
||||
const string existingPublicKey = "existingPublicKey";
|
||||
@@ -465,47 +475,52 @@ public class AccountsControllerTests : IDisposable
|
||||
user.PublicKey = existingPublicKey;
|
||||
user.PrivateKey = existingEncryptedPrivateKey;
|
||||
|
||||
setPasswordRequestModel.Keys = new KeysRequestModel()
|
||||
UpdateSetInitialPasswordRequestModelToV1(setInitialPasswordRequestModel);
|
||||
|
||||
setInitialPasswordRequestModel.Keys = new KeysRequestModel()
|
||||
{
|
||||
PublicKey = newPublicKey,
|
||||
EncryptedPrivateKey = newEncryptedPrivateKey
|
||||
};
|
||||
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult(user));
|
||||
_setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(
|
||||
_setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync(
|
||||
user,
|
||||
setPasswordRequestModel.MasterPasswordHash,
|
||||
setPasswordRequestModel.Key,
|
||||
setPasswordRequestModel.OrgIdentifier)
|
||||
setInitialPasswordRequestModel.MasterPasswordHash,
|
||||
setInitialPasswordRequestModel.Key,
|
||||
setInitialPasswordRequestModel.OrgIdentifier)
|
||||
.Returns(Task.FromResult(IdentityResult.Success));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostSetPasswordAsync(setPasswordRequestModel));
|
||||
await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostSetPasswordAsync(setInitialPasswordRequestModel));
|
||||
}
|
||||
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostSetPasswordAsync_WhenUserDoesNotExist_ShouldThrowUnauthorizedAccessException(
|
||||
SetPasswordRequestModel setPasswordRequestModel)
|
||||
public async Task PostSetPasswordAsync_V1_WhenUserDoesNotExist_ShouldThrowUnauthorizedAccessException(
|
||||
SetInitialPasswordRequestModel setInitialPasswordRequestModel)
|
||||
{
|
||||
UpdateSetInitialPasswordRequestModelToV1(setInitialPasswordRequestModel);
|
||||
|
||||
// Arrange
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult((User)null));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => _sut.PostSetPasswordAsync(setPasswordRequestModel));
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => _sut.PostSetPasswordAsync(setInitialPasswordRequestModel));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostSetPasswordAsync_WhenSettingPasswordFails_ShouldThrowBadRequestException(
|
||||
public async Task PostSetPasswordAsync_V1_WhenSettingPasswordFails_ShouldThrowBadRequestException(
|
||||
User user,
|
||||
SetPasswordRequestModel model)
|
||||
SetInitialPasswordRequestModel model)
|
||||
{
|
||||
UpdateSetInitialPasswordRequestModelToV1(model);
|
||||
model.Keys = null;
|
||||
// Arrange
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult(user));
|
||||
_setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(Arg.Any<User>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
||||
_setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync(Arg.Any<User>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(IdentityResult.Failed(new IdentityError { Description = "Some Error" })));
|
||||
|
||||
// Act & Assert
|
||||
@@ -845,5 +860,139 @@ public class AccountsControllerTests : IDisposable
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("keys", result.Object);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostSetPasswordAsync_V2_WhenUserExistsAndSettingPasswordSucceeds_ShouldSetInitialMasterPassword(
|
||||
User user,
|
||||
SetInitialPasswordRequestModel setInitialPasswordRequestModel)
|
||||
{
|
||||
// Arrange
|
||||
UpdateSetInitialPasswordRequestModelToV2(setInitialPasswordRequestModel);
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult(user));
|
||||
_setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(user, Arg.Any<SetInitialMasterPasswordDataModel>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _sut.PostSetPasswordAsync(setInitialPasswordRequestModel);
|
||||
|
||||
// Assert
|
||||
await _setInitialMasterPasswordCommand.Received(1)
|
||||
.SetInitialMasterPasswordAsync(
|
||||
Arg.Is<User>(u => u == user),
|
||||
Arg.Is<SetInitialMasterPasswordDataModel>(d =>
|
||||
d.MasterPasswordAuthentication != null &&
|
||||
d.MasterPasswordUnlock != null &&
|
||||
d.AccountKeys != null &&
|
||||
d.OrgSsoIdentifier == setInitialPasswordRequestModel.OrgIdentifier));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostSetPasswordAsync_V2_WithTdeSetPassword_ShouldCallTdeSetPasswordCommand(
|
||||
User user,
|
||||
SetInitialPasswordRequestModel setInitialPasswordRequestModel)
|
||||
{
|
||||
// Arrange
|
||||
UpdateSetInitialPasswordRequestModelToV2(setInitialPasswordRequestModel, includeTdeSetPassword: true);
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult(user));
|
||||
_tdeSetPasswordCommand.SetMasterPasswordAsync(user, Arg.Any<SetInitialMasterPasswordDataModel>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
await _sut.PostSetPasswordAsync(setInitialPasswordRequestModel);
|
||||
|
||||
// Assert
|
||||
await _tdeSetPasswordCommand.Received(1)
|
||||
.SetMasterPasswordAsync(
|
||||
Arg.Is<User>(u => u == user),
|
||||
Arg.Is<SetInitialMasterPasswordDataModel>(d =>
|
||||
d.MasterPasswordAuthentication != null &&
|
||||
d.MasterPasswordUnlock != null &&
|
||||
d.AccountKeys == null &&
|
||||
d.OrgSsoIdentifier == setInitialPasswordRequestModel.OrgIdentifier));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostSetPasswordAsync_V2_WhenUserDoesNotExist_ShouldThrowUnauthorizedAccessException(
|
||||
SetInitialPasswordRequestModel setInitialPasswordRequestModel)
|
||||
{
|
||||
// Arrange
|
||||
UpdateSetInitialPasswordRequestModelToV2(setInitialPasswordRequestModel);
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult((User)null));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => _sut.PostSetPasswordAsync(setInitialPasswordRequestModel));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task PostSetPasswordAsync_V2_WhenSettingPasswordFails_ShouldThrowException(
|
||||
User user,
|
||||
SetInitialPasswordRequestModel setInitialPasswordRequestModel)
|
||||
{
|
||||
// Arrange
|
||||
UpdateSetInitialPasswordRequestModelToV2(setInitialPasswordRequestModel);
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult(user));
|
||||
_setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(user, Arg.Any<SetInitialMasterPasswordDataModel>())
|
||||
.Returns(Task.FromException(new Exception("Setting password failed")));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<Exception>(() => _sut.PostSetPasswordAsync(setInitialPasswordRequestModel));
|
||||
}
|
||||
|
||||
private void UpdateSetInitialPasswordRequestModelToV1(SetInitialPasswordRequestModel model)
|
||||
{
|
||||
model.MasterPasswordAuthentication = null;
|
||||
model.MasterPasswordUnlock = null;
|
||||
model.AccountKeys = null;
|
||||
}
|
||||
|
||||
private void UpdateSetInitialPasswordRequestModelToV2(SetInitialPasswordRequestModel model, bool includeTdeSetPassword = false)
|
||||
{
|
||||
var kdf = new KdfRequestModel
|
||||
{
|
||||
KdfType = KdfType.PBKDF2_SHA256,
|
||||
Iterations = 600000
|
||||
};
|
||||
|
||||
model.MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel
|
||||
{
|
||||
Kdf = kdf,
|
||||
MasterPasswordAuthenticationHash = "authHash",
|
||||
Salt = "salt"
|
||||
};
|
||||
|
||||
model.MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel
|
||||
{
|
||||
Kdf = kdf,
|
||||
MasterKeyWrappedUserKey = "wrappedKey",
|
||||
Salt = "salt"
|
||||
};
|
||||
|
||||
if (includeTdeSetPassword)
|
||||
{
|
||||
// TDE set password does not include AccountKeys
|
||||
model.AccountKeys = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
model.AccountKeys = new AccountKeysRequestModel
|
||||
{
|
||||
UserKeyEncryptedAccountPrivateKey = "privateKey",
|
||||
AccountPublicKey = "publicKey"
|
||||
};
|
||||
}
|
||||
|
||||
// Clear V1 properties
|
||||
model.MasterPasswordHash = null;
|
||||
model.Key = null;
|
||||
model.Keys = null;
|
||||
model.Kdf = null;
|
||||
model.KdfIterations = null;
|
||||
model.KdfMemory = null;
|
||||
model.KdfParallelism = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Api.Auth.Models.Request.Accounts;
|
||||
using Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.KeyManagement.Models.Api.Request;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Api.Test.Auth.Models.Request.Accounts;
|
||||
|
||||
public class SetInitialPasswordRequestModelTests
|
||||
{
|
||||
#region V2 Validation Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(KdfType.PBKDF2_SHA256, 600000, null, null)]
|
||||
[InlineData(KdfType.Argon2id, 3, 64, 4)]
|
||||
public void Validate_V2Request_WithMatchingKdf_ReturnsNoErrors(KdfType kdfType, int iterations, int? memory, int? parallelism)
|
||||
{
|
||||
// Arrange
|
||||
var kdf = new KdfRequestModel
|
||||
{
|
||||
KdfType = kdfType,
|
||||
Iterations = iterations,
|
||||
Memory = memory,
|
||||
Parallelism = parallelism
|
||||
};
|
||||
|
||||
var model = new SetInitialPasswordRequestModel
|
||||
{
|
||||
OrgIdentifier = "orgIdentifier",
|
||||
MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel
|
||||
{
|
||||
Kdf = kdf,
|
||||
MasterPasswordAuthenticationHash = "authHash",
|
||||
Salt = "salt"
|
||||
},
|
||||
MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel
|
||||
{
|
||||
Kdf = kdf,
|
||||
MasterKeyWrappedUserKey = "wrappedKey",
|
||||
Salt = "salt"
|
||||
},
|
||||
AccountKeys = new AccountKeysRequestModel
|
||||
{
|
||||
UserKeyEncryptedAccountPrivateKey = "privateKey",
|
||||
AccountPublicKey = "publicKey"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = model.Validate(new ValidationContext(model));
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Validate_V2Request_WithMismatchedKdfSettings_ReturnsValidationError(string orgIdentifier)
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetInitialPasswordRequestModel
|
||||
{
|
||||
OrgIdentifier = orgIdentifier,
|
||||
MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel
|
||||
{
|
||||
Kdf = new KdfRequestModel
|
||||
{
|
||||
KdfType = KdfType.PBKDF2_SHA256,
|
||||
Iterations = 600000
|
||||
},
|
||||
MasterPasswordAuthenticationHash = "authHash",
|
||||
Salt = "salt"
|
||||
},
|
||||
MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel
|
||||
{
|
||||
Kdf = new KdfRequestModel
|
||||
{
|
||||
KdfType = KdfType.PBKDF2_SHA256,
|
||||
Iterations = 650000 // Different iterations
|
||||
},
|
||||
MasterKeyWrappedUserKey = "wrappedKey",
|
||||
Salt = "salt"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = model.Validate(new ValidationContext(model)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Contains("KDF settings must be equal", result[0].ErrorMessage);
|
||||
var memberNames = result[0].MemberNames.ToList();
|
||||
Assert.Equal(2, memberNames.Count);
|
||||
Assert.Contains("MasterPasswordAuthentication.Kdf", memberNames);
|
||||
Assert.Contains("MasterPasswordUnlock.Kdf", memberNames);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Validate_V2Request_WithInvalidAuthenticationKdf_ReturnsValidationError(string orgIdentifier)
|
||||
{
|
||||
// Arrange
|
||||
var kdf = new KdfRequestModel
|
||||
{
|
||||
KdfType = KdfType.PBKDF2_SHA256,
|
||||
Iterations = 1 // Too low
|
||||
};
|
||||
|
||||
var model = new SetInitialPasswordRequestModel
|
||||
{
|
||||
OrgIdentifier = orgIdentifier,
|
||||
MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel
|
||||
{
|
||||
Kdf = kdf,
|
||||
MasterPasswordAuthenticationHash = "authHash",
|
||||
Salt = "salt"
|
||||
},
|
||||
MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel
|
||||
{
|
||||
Kdf = kdf,
|
||||
MasterKeyWrappedUserKey = "wrappedKey",
|
||||
Salt = "salt"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = model.Validate(new ValidationContext(model)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(result);
|
||||
Assert.Contains(result, r => r.ErrorMessage != null && r.ErrorMessage.Contains("KDF iterations must be between"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region V1 Validation Tests (Obsolete)
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Validate_V1Request_WithMissingMasterPasswordHash_ReturnsValidationError(string orgIdentifier)
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetInitialPasswordRequestModel
|
||||
{
|
||||
OrgIdentifier = orgIdentifier,
|
||||
Key = "key",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 600000
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = model.Validate(new ValidationContext(model)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Contains(result, r => r.ErrorMessage.Contains("MasterPasswordHash must be supplied"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Validate_V1Request_WithMissingKey_ReturnsValidationError(string orgIdentifier)
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetInitialPasswordRequestModel
|
||||
{
|
||||
OrgIdentifier = orgIdentifier,
|
||||
MasterPasswordHash = "hash",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 600000
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = model.Validate(new ValidationContext(model)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Contains(result, r => r.ErrorMessage.Contains("Key must be supplied"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Validate_V1Request_WithMissingKdf_ReturnsValidationError(string orgIdentifier)
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetInitialPasswordRequestModel
|
||||
{
|
||||
OrgIdentifier = orgIdentifier,
|
||||
MasterPasswordHash = "hash",
|
||||
Key = "key",
|
||||
KdfIterations = 600000
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = model.Validate(new ValidationContext(model)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Contains(result, r => r.ErrorMessage != null && r.ErrorMessage.Contains("Kdf must be supplied"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Validate_V1Request_WithMissingKdfIterations_ReturnsValidationError(string orgIdentifier)
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetInitialPasswordRequestModel
|
||||
{
|
||||
OrgIdentifier = orgIdentifier,
|
||||
MasterPasswordHash = "hash",
|
||||
Key = "key",
|
||||
Kdf = KdfType.PBKDF2_SHA256
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = model.Validate(new ValidationContext(model)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Contains(result, r => r.ErrorMessage != null && r.ErrorMessage.Contains("KdfIterations must be supplied"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Validate_V1Request_WithArgon2idAndMissingMemory_ReturnsValidationError(string orgIdentifier)
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetInitialPasswordRequestModel
|
||||
{
|
||||
OrgIdentifier = orgIdentifier,
|
||||
MasterPasswordHash = "hash",
|
||||
Key = "key",
|
||||
Kdf = KdfType.Argon2id,
|
||||
KdfIterations = 3,
|
||||
KdfParallelism = 4
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = model.Validate(new ValidationContext(model)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Contains(result, r => r.ErrorMessage.Contains("KdfMemory must be supplied when Kdf is Argon2id"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Validate_V1Request_WithArgon2idAndMissingParallelism_ReturnsValidationError(string orgIdentifier)
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetInitialPasswordRequestModel
|
||||
{
|
||||
OrgIdentifier = orgIdentifier,
|
||||
MasterPasswordHash = "hash",
|
||||
Key = "key",
|
||||
Kdf = KdfType.Argon2id,
|
||||
KdfIterations = 3,
|
||||
KdfMemory = 64
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = model.Validate(new ValidationContext(model)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.Contains(result, r => r.ErrorMessage.Contains("KdfParallelism must be supplied when Kdf is Argon2id"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Validate_V1Request_WithInvalidKdfSettings_ReturnsValidationError(string orgIdentifier)
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetInitialPasswordRequestModel
|
||||
{
|
||||
OrgIdentifier = orgIdentifier,
|
||||
MasterPasswordHash = "hash",
|
||||
Key = "key",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 5000 // Too low
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = model.Validate(new ValidationContext(model)).ToList();
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(result);
|
||||
Assert.Contains(result, r => r.ErrorMessage != null && r.ErrorMessage.Contains("KDF iterations must be between"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(KdfType.PBKDF2_SHA256, 600000, null, null)]
|
||||
[InlineData(KdfType.Argon2id, 3, 64, 4)]
|
||||
public void Validate_V1Request_WithValidSettings_ReturnsNoErrors(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism)
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetInitialPasswordRequestModel
|
||||
{
|
||||
OrgIdentifier = "orgIdentifier",
|
||||
MasterPasswordHash = "hash",
|
||||
Key = "key",
|
||||
Kdf = kdfType,
|
||||
KdfIterations = kdfIterations,
|
||||
KdfMemory = kdfMemory,
|
||||
KdfParallelism = kdfParallelism
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = model.Validate(new ValidationContext(model));
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsV2Request Tests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void IsV2Request_WithV2Properties_ReturnsTrue(string orgIdentifier)
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetInitialPasswordRequestModel
|
||||
{
|
||||
OrgIdentifier = orgIdentifier,
|
||||
MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel
|
||||
{
|
||||
Kdf = new KdfRequestModel
|
||||
{
|
||||
KdfType = KdfType.PBKDF2_SHA256,
|
||||
Iterations = 600000
|
||||
},
|
||||
MasterPasswordAuthenticationHash = "authHash",
|
||||
Salt = "salt"
|
||||
},
|
||||
MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel
|
||||
{
|
||||
Kdf = new KdfRequestModel
|
||||
{
|
||||
KdfType = KdfType.PBKDF2_SHA256,
|
||||
Iterations = 600000
|
||||
},
|
||||
MasterKeyWrappedUserKey = "wrappedKey",
|
||||
Salt = "salt"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = model.IsV2Request();
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void IsV2Request_WithoutMasterPasswordAuthentication_ReturnsFalse(string orgIdentifier)
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetInitialPasswordRequestModel
|
||||
{
|
||||
OrgIdentifier = orgIdentifier,
|
||||
MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel
|
||||
{
|
||||
Kdf = new KdfRequestModel
|
||||
{
|
||||
KdfType = KdfType.PBKDF2_SHA256,
|
||||
Iterations = 600000
|
||||
},
|
||||
MasterKeyWrappedUserKey = "wrappedKey",
|
||||
Salt = "salt"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = model.IsV2Request();
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void IsV2Request_WithoutMasterPasswordUnlock_ReturnsFalse(string orgIdentifier)
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetInitialPasswordRequestModel
|
||||
{
|
||||
OrgIdentifier = orgIdentifier,
|
||||
MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel
|
||||
{
|
||||
Kdf = new KdfRequestModel
|
||||
{
|
||||
KdfType = KdfType.PBKDF2_SHA256,
|
||||
Iterations = 600000
|
||||
},
|
||||
MasterPasswordAuthenticationHash = "authHash",
|
||||
Salt = "salt"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = model.IsV2Request();
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void IsV2Request_WithV1Properties_ReturnsFalse(string orgIdentifier)
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetInitialPasswordRequestModel
|
||||
{
|
||||
OrgIdentifier = orgIdentifier,
|
||||
MasterPasswordHash = "hash",
|
||||
Key = "key",
|
||||
Kdf = KdfType.PBKDF2_SHA256,
|
||||
KdfIterations = 600000
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = model.IsV2Request();
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsTdeSetPasswordRequest Tests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void IsTdeSetPasswordRequest_WithNullAccountKeys_ReturnsTrue(string orgIdentifier)
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetInitialPasswordRequestModel
|
||||
{
|
||||
OrgIdentifier = orgIdentifier,
|
||||
MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel
|
||||
{
|
||||
Kdf = new KdfRequestModel
|
||||
{
|
||||
KdfType = KdfType.PBKDF2_SHA256,
|
||||
Iterations = 600000
|
||||
},
|
||||
MasterPasswordAuthenticationHash = "authHash",
|
||||
Salt = "salt"
|
||||
},
|
||||
MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel
|
||||
{
|
||||
Kdf = new KdfRequestModel
|
||||
{
|
||||
KdfType = KdfType.PBKDF2_SHA256,
|
||||
Iterations = 600000
|
||||
},
|
||||
MasterKeyWrappedUserKey = "wrappedKey",
|
||||
Salt = "salt"
|
||||
},
|
||||
AccountKeys = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = model.IsTdeSetPasswordRequest();
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void IsTdeSetPasswordRequest_WithAccountKeys_ReturnsFalse(string orgIdentifier)
|
||||
{
|
||||
// Arrange
|
||||
var model = new SetInitialPasswordRequestModel
|
||||
{
|
||||
OrgIdentifier = orgIdentifier,
|
||||
MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel
|
||||
{
|
||||
Kdf = new KdfRequestModel
|
||||
{
|
||||
KdfType = KdfType.PBKDF2_SHA256,
|
||||
Iterations = 600000
|
||||
},
|
||||
MasterPasswordAuthenticationHash = "authHash",
|
||||
Salt = "salt"
|
||||
},
|
||||
MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel
|
||||
{
|
||||
Kdf = new KdfRequestModel
|
||||
{
|
||||
KdfType = KdfType.PBKDF2_SHA256,
|
||||
Iterations = 600000
|
||||
},
|
||||
MasterKeyWrappedUserKey = "wrappedKey",
|
||||
Salt = "salt"
|
||||
},
|
||||
AccountKeys = new AccountKeysRequestModel
|
||||
{
|
||||
UserKeyEncryptedAccountPrivateKey = "privateKey",
|
||||
AccountPublicKey = "publicKey"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = model.IsTdeSetPasswordRequest();
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ToUser Tests (Obsolete)
|
||||
|
||||
[Theory]
|
||||
[InlineData(KdfType.PBKDF2_SHA256, 600000, null, null)]
|
||||
[InlineData(KdfType.Argon2id, 3, 64, 4)]
|
||||
public void ToUser_WithKeys_MapsPropertiesCorrectly(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism)
|
||||
{
|
||||
// Arrange
|
||||
var existingUser = new User();
|
||||
var model = new SetInitialPasswordRequestModel
|
||||
{
|
||||
OrgIdentifier = "orgIdentifier",
|
||||
MasterPasswordHash = "hash",
|
||||
MasterPasswordHint = "hint",
|
||||
Key = "key",
|
||||
Kdf = kdfType,
|
||||
KdfIterations = kdfIterations,
|
||||
KdfMemory = kdfMemory,
|
||||
KdfParallelism = kdfParallelism,
|
||||
Keys = new KeysRequestModel
|
||||
{
|
||||
PublicKey = "publicKey",
|
||||
EncryptedPrivateKey = "encryptedPrivateKey"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = model.ToUser(existingUser);
|
||||
|
||||
// Assert
|
||||
Assert.Same(existingUser, result);
|
||||
Assert.Equal("hint", result.MasterPasswordHint);
|
||||
Assert.Equal(kdfType, result.Kdf);
|
||||
Assert.Equal(kdfIterations, result.KdfIterations);
|
||||
Assert.Equal(kdfMemory, result.KdfMemory);
|
||||
Assert.Equal(kdfParallelism, result.KdfParallelism);
|
||||
Assert.Equal("key", result.Key);
|
||||
Assert.Equal("publicKey", result.PublicKey);
|
||||
Assert.Equal("encryptedPrivateKey", result.PrivateKey);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(KdfType.PBKDF2_SHA256, 600000, null, null)]
|
||||
[InlineData(KdfType.Argon2id, 3, 64, 4)]
|
||||
public void ToUser_WithoutKeys_MapsPropertiesCorrectly(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism)
|
||||
{
|
||||
// Arrange
|
||||
var existingUser = new User();
|
||||
var model = new SetInitialPasswordRequestModel
|
||||
{
|
||||
OrgIdentifier = "orgIdentifier",
|
||||
MasterPasswordHash = "hash",
|
||||
MasterPasswordHint = "hint",
|
||||
Key = "key",
|
||||
Kdf = kdfType,
|
||||
KdfIterations = kdfIterations,
|
||||
KdfMemory = kdfMemory,
|
||||
KdfParallelism = kdfParallelism,
|
||||
Keys = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = model.ToUser(existingUser);
|
||||
|
||||
// Assert
|
||||
Assert.Same(existingUser, result);
|
||||
Assert.Equal("hint", result.MasterPasswordHint);
|
||||
Assert.Equal(kdfType, result.Kdf);
|
||||
Assert.Equal(kdfIterations, result.KdfIterations);
|
||||
Assert.Equal(kdfMemory, result.KdfMemory);
|
||||
Assert.Equal(kdfParallelism, result.KdfParallelism);
|
||||
Assert.Equal("key", result.Key);
|
||||
Assert.Null(result.PublicKey);
|
||||
Assert.Null(result.PrivateKey);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ToData Tests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void ToData_MapsPropertiesCorrectly(string orgIdentifier)
|
||||
{
|
||||
// Arrange
|
||||
var kdf = new KdfRequestModel
|
||||
{
|
||||
KdfType = KdfType.PBKDF2_SHA256,
|
||||
Iterations = 600000
|
||||
};
|
||||
|
||||
var model = new SetInitialPasswordRequestModel
|
||||
{
|
||||
OrgIdentifier = orgIdentifier,
|
||||
MasterPasswordHint = "hint",
|
||||
MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel
|
||||
{
|
||||
Kdf = kdf,
|
||||
MasterPasswordAuthenticationHash = "authHash",
|
||||
Salt = "salt"
|
||||
},
|
||||
MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel
|
||||
{
|
||||
Kdf = kdf,
|
||||
MasterKeyWrappedUserKey = "wrappedKey",
|
||||
Salt = "salt"
|
||||
},
|
||||
AccountKeys = new AccountKeysRequestModel
|
||||
{
|
||||
UserKeyEncryptedAccountPrivateKey = "privateKey",
|
||||
AccountPublicKey = "publicKey"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = model.ToData();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(orgIdentifier, result.OrgSsoIdentifier);
|
||||
Assert.Equal("hint", result.MasterPasswordHint);
|
||||
Assert.NotNull(result.MasterPasswordAuthentication);
|
||||
Assert.NotNull(result.MasterPasswordUnlock);
|
||||
Assert.NotNull(result.AccountKeys);
|
||||
Assert.Equal("authHash", result.MasterPasswordAuthentication.MasterPasswordAuthenticationHash);
|
||||
Assert.Equal("wrappedKey", result.MasterPasswordUnlock.MasterKeyWrappedUserKey);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void ToData_WithNullAccountKeys_MapsCorrectly(string orgIdentifier)
|
||||
{
|
||||
// Arrange
|
||||
var kdf = new KdfRequestModel
|
||||
{
|
||||
KdfType = KdfType.PBKDF2_SHA256,
|
||||
Iterations = 600000
|
||||
};
|
||||
|
||||
var model = new SetInitialPasswordRequestModel
|
||||
{
|
||||
OrgIdentifier = orgIdentifier,
|
||||
MasterPasswordHint = "hint",
|
||||
MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel
|
||||
{
|
||||
Kdf = kdf,
|
||||
MasterPasswordAuthenticationHash = "authHash",
|
||||
Salt = "salt"
|
||||
},
|
||||
MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel
|
||||
{
|
||||
Kdf = kdf,
|
||||
MasterKeyWrappedUserKey = "wrappedKey",
|
||||
Salt = "salt"
|
||||
},
|
||||
AccountKeys = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = model.ToData();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(orgIdentifier, result.OrgSsoIdentifier);
|
||||
Assert.Null(result.AccountKeys);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
using System.Security.Claims;
|
||||
using Bit.Api.Billing.Controllers;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Models.Business;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.KeyManagement.Queries.Interfaces;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
@@ -29,8 +27,6 @@ public class AccountsControllerTests : IDisposable
|
||||
private readonly IUserService _userService;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IStripePaymentService _paymentService;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
|
||||
private readonly ILicensingService _licensingService;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly AccountsController _sut;
|
||||
@@ -40,15 +36,11 @@ public class AccountsControllerTests : IDisposable
|
||||
_userService = Substitute.For<IUserService>();
|
||||
_featureService = Substitute.For<IFeatureService>();
|
||||
_paymentService = Substitute.For<IStripePaymentService>();
|
||||
_twoFactorIsEnabledQuery = Substitute.For<ITwoFactorIsEnabledQuery>();
|
||||
_userAccountKeysQuery = Substitute.For<IUserAccountKeysQuery>();
|
||||
_licensingService = Substitute.For<ILicensingService>();
|
||||
_globalSettings = new GlobalSettings { SelfHosted = false };
|
||||
|
||||
_sut = new AccountsController(
|
||||
_userService,
|
||||
_twoFactorIsEnabledQuery,
|
||||
_userAccountKeysQuery,
|
||||
_featureService,
|
||||
_licensingService
|
||||
);
|
||||
|
||||
@@ -3,6 +3,8 @@ using Bit.Api.Billing.Models.Requests.Storage;
|
||||
using Bit.Core.Billing.Commands;
|
||||
using Bit.Core.Billing.Licenses.Queries;
|
||||
using Bit.Core.Billing.Premium.Commands;
|
||||
using Bit.Core.Billing.Subscriptions.Commands;
|
||||
using Bit.Core.Billing.Subscriptions.Queries;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -17,21 +19,26 @@ public class AccountBillingVNextControllerTests
|
||||
{
|
||||
private readonly IUpdatePremiumStorageCommand _updatePremiumStorageCommand;
|
||||
private readonly IGetUserLicenseQuery _getUserLicenseQuery;
|
||||
private readonly IUpgradePremiumToOrganizationCommand _upgradePremiumToOrganizationCommand;
|
||||
private readonly AccountBillingVNextController _sut;
|
||||
|
||||
public AccountBillingVNextControllerTests()
|
||||
{
|
||||
_updatePremiumStorageCommand = Substitute.For<IUpdatePremiumStorageCommand>();
|
||||
_getUserLicenseQuery = Substitute.For<IGetUserLicenseQuery>();
|
||||
_upgradePremiumToOrganizationCommand = Substitute.For<IUpgradePremiumToOrganizationCommand>();
|
||||
|
||||
_sut = new AccountBillingVNextController(
|
||||
Substitute.For<Core.Billing.Payment.Commands.ICreateBitPayInvoiceForCreditCommand>(),
|
||||
Substitute.For<Core.Billing.Premium.Commands.ICreatePremiumCloudHostedSubscriptionCommand>(),
|
||||
Substitute.For<IGetBitwardenSubscriptionQuery>(),
|
||||
Substitute.For<Core.Billing.Payment.Queries.IGetCreditQuery>(),
|
||||
Substitute.For<Core.Billing.Payment.Queries.IGetPaymentMethodQuery>(),
|
||||
_getUserLicenseQuery,
|
||||
Substitute.For<IReinstateSubscriptionCommand>(),
|
||||
Substitute.For<Core.Billing.Payment.Commands.IUpdatePaymentMethodCommand>(),
|
||||
_updatePremiumStorageCommand);
|
||||
_updatePremiumStorageCommand,
|
||||
_upgradePremiumToOrganizationCommand);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -60,7 +67,7 @@ public class AccountBillingVNextControllerTests
|
||||
.Returns(new BillingCommandResult<None>(new None()));
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateStorageAsync(user, request);
|
||||
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
|
||||
|
||||
// Assert
|
||||
var okResult = Assert.IsAssignableFrom<IResult>(result);
|
||||
@@ -80,7 +87,7 @@ public class AccountBillingVNextControllerTests
|
||||
.Returns(new BadRequest(errorMessage));
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateStorageAsync(user, request);
|
||||
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
|
||||
|
||||
// Assert
|
||||
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
|
||||
@@ -100,7 +107,7 @@ public class AccountBillingVNextControllerTests
|
||||
.Returns(new BadRequest(errorMessage));
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateStorageAsync(user, request);
|
||||
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
|
||||
|
||||
// Assert
|
||||
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
|
||||
@@ -120,7 +127,7 @@ public class AccountBillingVNextControllerTests
|
||||
.Returns(new BadRequest(errorMessage));
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateStorageAsync(user, request);
|
||||
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
|
||||
|
||||
// Assert
|
||||
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
|
||||
@@ -140,7 +147,7 @@ public class AccountBillingVNextControllerTests
|
||||
.Returns(new BadRequest(errorMessage));
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateStorageAsync(user, request);
|
||||
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
|
||||
|
||||
// Assert
|
||||
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
|
||||
@@ -160,7 +167,7 @@ public class AccountBillingVNextControllerTests
|
||||
.Returns(new BadRequest(errorMessage));
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateStorageAsync(user, request);
|
||||
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
|
||||
|
||||
// Assert
|
||||
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
|
||||
@@ -179,7 +186,7 @@ public class AccountBillingVNextControllerTests
|
||||
.Returns(new BillingCommandResult<None>(new None()));
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateStorageAsync(user, request);
|
||||
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
|
||||
|
||||
// Assert
|
||||
var okResult = Assert.IsAssignableFrom<IResult>(result);
|
||||
@@ -198,7 +205,7 @@ public class AccountBillingVNextControllerTests
|
||||
.Returns(new BillingCommandResult<None>(new None()));
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateStorageAsync(user, request);
|
||||
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
|
||||
|
||||
// Assert
|
||||
var okResult = Assert.IsAssignableFrom<IResult>(result);
|
||||
@@ -217,7 +224,7 @@ public class AccountBillingVNextControllerTests
|
||||
.Returns(new BillingCommandResult<None>(new None()));
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateStorageAsync(user, request);
|
||||
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
|
||||
|
||||
// Assert
|
||||
var okResult = Assert.IsAssignableFrom<IResult>(result);
|
||||
@@ -236,7 +243,7 @@ public class AccountBillingVNextControllerTests
|
||||
.Returns(new BillingCommandResult<None>(new None()));
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateStorageAsync(user, request);
|
||||
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
|
||||
|
||||
// Assert
|
||||
var okResult = Assert.IsAssignableFrom<IResult>(result);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Bit.Billing.Controllers;
|
||||
using Bit.Billing.Test.Utilities;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
@@ -565,4 +566,53 @@ public class PayPalControllerTests(ITestOutputHelper testOutputHelper)
|
||||
|
||||
private static void LoggedWarning(ICacheLogger<PayPalController> logger, string message)
|
||||
=> Logged(logger, LogLevel.Warning, message);
|
||||
|
||||
[Fact]
|
||||
public async Task PostIpn_Completed_CreatesTransaction_WithSwedishCulture_Ok()
|
||||
{
|
||||
// Save current culture
|
||||
var originalCulture = CultureInfo.CurrentCulture;
|
||||
var originalUICulture = CultureInfo.CurrentUICulture;
|
||||
|
||||
try
|
||||
{
|
||||
// Set Swedish culture (uses comma as decimal separator)
|
||||
var swedishCulture = new CultureInfo("sv-SE");
|
||||
CultureInfo.CurrentCulture = swedishCulture;
|
||||
CultureInfo.CurrentUICulture = swedishCulture;
|
||||
|
||||
var logger = testOutputHelper.BuildLoggerFor<PayPalController>();
|
||||
|
||||
_billingSettings.Value.Returns(new BillingSettings
|
||||
{
|
||||
PayPal =
|
||||
{
|
||||
WebhookKey = _defaultWebhookKey,
|
||||
BusinessId = "NHDYKLQ3L4LWL"
|
||||
}
|
||||
});
|
||||
|
||||
var ipnBody = await PayPalTestIPN.GetAsync(IPNBody.SuccessfulPayment);
|
||||
|
||||
_transactionRepository.GetByGatewayIdAsync(
|
||||
GatewayType.PayPal,
|
||||
"2PK15573S8089712Y").ReturnsNull();
|
||||
|
||||
var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody);
|
||||
|
||||
var result = await controller.PostIpn();
|
||||
|
||||
HasStatusCode(result, 200);
|
||||
|
||||
await _transactionRepository.Received().CreateAsync(Arg.Is<Transaction>(transaction =>
|
||||
transaction.Amount == 48M &&
|
||||
transaction.GatewayId == "2PK15573S8089712Y"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Restore original culture
|
||||
CultureInfo.CurrentCulture = originalCulture;
|
||||
CultureInfo.CurrentUICulture = originalUICulture;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using Bit.Billing.Services;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
@@ -17,6 +18,9 @@ public class ReconcileAdditionalStorageJobTests
|
||||
private readonly IStripeFacade _stripeFacade;
|
||||
private readonly ILogger<ReconcileAdditionalStorageJob> _logger;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IStripeEventUtilityService _stripeEventUtilityService;
|
||||
private readonly ReconcileAdditionalStorageJob _sut;
|
||||
|
||||
public ReconcileAdditionalStorageJobTests()
|
||||
@@ -24,7 +28,20 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_stripeFacade = Substitute.For<IStripeFacade>();
|
||||
_logger = Substitute.For<ILogger<ReconcileAdditionalStorageJob>>();
|
||||
_featureService = Substitute.For<IFeatureService>();
|
||||
_sut = new ReconcileAdditionalStorageJob(_stripeFacade, _logger, _featureService);
|
||||
_userRepository = Substitute.For<IUserRepository>();
|
||||
_organizationRepository = Substitute.For<IOrganizationRepository>();
|
||||
_stripeEventUtilityService = Substitute.For<IStripeEventUtilityService>();
|
||||
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, null));
|
||||
|
||||
_sut = new ReconcileAdditionalStorageJob(
|
||||
_stripeFacade,
|
||||
_logger,
|
||||
_featureService,
|
||||
_userRepository,
|
||||
_organizationRepository,
|
||||
_stripeEventUtilityService);
|
||||
}
|
||||
|
||||
#region Feature Flag Tests
|
||||
@@ -88,6 +105,36 @@ public class ReconcileAdditionalStorageJobTests
|
||||
await _stripeFacade.DidNotReceiveWithAnyArgs().UpdateSubscription(null!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_DryRunMode_DoesNotUpdateDatabase()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(false); // Dry run ON
|
||||
|
||||
// Create a personal subscription that would normally trigger a database update
|
||||
var userId = Guid.NewGuid();
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10);
|
||||
subscription.Metadata = new Dictionary<string, string> { ["userId"] = userId.ToString() };
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
|
||||
// Mock GetIdsFromMetadata to return userId
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert - Verify database repositories are never called
|
||||
await _userRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default);
|
||||
await _userRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(default!);
|
||||
await _organizationRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default);
|
||||
await _organizationRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(default!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_DryRunModeDisabled_UpdatesSubscriptions()
|
||||
{
|
||||
@@ -96,7 +143,11 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true); // Dry run OFF
|
||||
|
||||
var userId = Guid.NewGuid();
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
@@ -111,6 +162,150 @@ public class ReconcileAdditionalStorageJobTests
|
||||
Arg.Is<SubscriptionUpdateOptions>(o => o.Items.Count == 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_LiveMode_PersonalSubscription_UpdatesUserDatabase()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
// Setup user
|
||||
var userId = Guid.NewGuid();
|
||||
var user = new Bit.Core.Entities.User
|
||||
{
|
||||
Id = userId,
|
||||
Email = "test@example.com",
|
||||
GatewaySubscriptionId = "sub_personal",
|
||||
MaxStorageGb = 15 // Old value
|
||||
};
|
||||
_userRepository.GetByIdAsync(userId).Returns(user);
|
||||
_userRepository.ReplaceAsync(user).Returns(Task.CompletedTask);
|
||||
|
||||
// Create personal subscription with premium seat + 10 GB storage (will be reduced to 6 GB)
|
||||
var subscription = CreateSubscriptionWithMultipleItems("sub_personal",
|
||||
[("premium-annually", 1L), ("storage-gb-monthly", 10L)]);
|
||||
subscription.Metadata = new Dictionary<string, string> { ["userId"] = userId.ToString() };
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Mock GetIdsFromMetadata to return userId
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert - Verify Stripe update happened
|
||||
await _stripeFacade.Received(1).UpdateSubscription(
|
||||
"sub_personal",
|
||||
Arg.Is<SubscriptionUpdateOptions>(o => o.Items.Count == 1 && o.Items[0].Quantity == 6));
|
||||
|
||||
// Assert - Verify database update with correct MaxStorageGb (5 included + 6 new quantity = 11)
|
||||
await _userRepository.Received(1).GetByIdAsync(userId);
|
||||
await _userRepository.Received(1).ReplaceAsync(user);
|
||||
Assert.Equal((short)11, user.MaxStorageGb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_LiveMode_OrganizationSubscription_UpdatesOrganizationDatabase()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
// Setup organization
|
||||
var organizationId = Guid.NewGuid();
|
||||
var organization = new Bit.Core.AdminConsole.Entities.Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Name = "Test Organization",
|
||||
GatewaySubscriptionId = "sub_org",
|
||||
MaxStorageGb = 13 // Old value
|
||||
};
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
_organizationRepository.ReplaceAsync(organization).Returns(Task.CompletedTask);
|
||||
|
||||
// Create organization subscription with org seat plan + 8 GB storage (will be reduced to 4 GB)
|
||||
var subscription = CreateSubscriptionWithMultipleItems("sub_org",
|
||||
[("2023-teams-org-seat-annually", 5L), ("storage-gb-monthly", 8L)]);
|
||||
subscription.Metadata = new Dictionary<string, string> { ["organizationId"] = organizationId.ToString() };
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Mock GetIdsFromMetadata to return organizationId
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(organizationId, null, null));
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert - Verify Stripe update happened
|
||||
await _stripeFacade.Received(1).UpdateSubscription(
|
||||
"sub_org",
|
||||
Arg.Is<SubscriptionUpdateOptions>(o => o.Items.Count == 1 && o.Items[0].Quantity == 4));
|
||||
|
||||
// Assert - Verify database update with correct MaxStorageGb (5 included + 4 new quantity = 9)
|
||||
await _organizationRepository.Received(1).GetByIdAsync(organizationId);
|
||||
await _organizationRepository.Received(1).ReplaceAsync(organization);
|
||||
Assert.Equal((short)9, organization.MaxStorageGb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_LiveMode_StorageItemDeleted_UpdatesDatabaseWithBaseStorage()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateJobExecutionContext();
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
// Setup user
|
||||
var userId = Guid.NewGuid();
|
||||
var user = new Bit.Core.Entities.User
|
||||
{
|
||||
Id = userId,
|
||||
Email = "test@example.com",
|
||||
GatewaySubscriptionId = "sub_delete",
|
||||
MaxStorageGb = 8 // Old value
|
||||
};
|
||||
_userRepository.GetByIdAsync(userId).Returns(user);
|
||||
_userRepository.ReplaceAsync(user).Returns(Task.CompletedTask);
|
||||
|
||||
// Create personal subscription with premium seat + 3 GB storage (will be deleted since 3 < 4)
|
||||
var subscription = CreateSubscriptionWithMultipleItems("sub_delete",
|
||||
[("premium-annually", 1L), ("storage-gb-monthly", 3L)]);
|
||||
subscription.Metadata = new Dictionary<string, string> { ["userId"] = userId.ToString() };
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
_stripeFacade.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Mock GetIdsFromMetadata to return userId
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert - Verify Stripe update happened (item deleted)
|
||||
await _stripeFacade.Received(1).UpdateSubscription(
|
||||
"sub_delete",
|
||||
Arg.Is<SubscriptionUpdateOptions>(o => o.Items.Count == 1 && o.Items[0].Deleted == true));
|
||||
|
||||
// Assert - Verify database update with base storage only (5 GB)
|
||||
await _userRepository.Received(1).GetByIdAsync(userId);
|
||||
await _userRepository.Received(1).ReplaceAsync(user);
|
||||
Assert.Equal((short)5, user.MaxStorageGb);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Price ID Processing Tests
|
||||
@@ -174,11 +369,14 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var userId = Guid.NewGuid();
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
[StripeConstants.MetadataKeys.StorageReconciled2025] = "invalid-date"
|
||||
};
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, metadata: metadata);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
@@ -200,7 +398,10 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var userId = Guid.NewGuid();
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, metadata: null);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
@@ -226,7 +427,10 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var userId = Guid.NewGuid();
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
@@ -253,7 +457,10 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var userId = Guid.NewGuid();
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 4);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
@@ -279,7 +486,10 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var userId = Guid.NewGuid();
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 2);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
@@ -309,7 +519,10 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var userId = Guid.NewGuid();
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
@@ -333,7 +546,10 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var userId = Guid.NewGuid();
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
@@ -429,9 +645,12 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var userId = Guid.NewGuid();
|
||||
var subscription1 = CreateSubscription("sub_1", "storage-gb-monthly", quantity: 10);
|
||||
var subscription2 = CreateSubscription("sub_2", "storage-gb-monthly", quantity: 5);
|
||||
var subscription3 = CreateSubscription("sub_3", "storage-gb-monthly", quantity: 3);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription1, subscription2, subscription3));
|
||||
@@ -461,6 +680,7 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var userId = Guid.NewGuid();
|
||||
var processedMetadata = new Dictionary<string, string>
|
||||
{
|
||||
[StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o")
|
||||
@@ -469,6 +689,8 @@ public class ReconcileAdditionalStorageJobTests
|
||||
var subscription1 = CreateSubscription("sub_1", "storage-gb-monthly", quantity: 10);
|
||||
var subscription2 = CreateSubscription("sub_2", "storage-gb-monthly", quantity: 5, metadata: processedMetadata);
|
||||
var subscription3 = CreateSubscription("sub_3", "storage-gb-monthly", quantity: 3);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription1, subscription2, subscription3));
|
||||
@@ -501,9 +723,12 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var userId = Guid.NewGuid();
|
||||
var subscription1 = CreateSubscription("sub_1", "storage-gb-monthly", quantity: 10);
|
||||
var subscription2 = CreateSubscription("sub_2", "storage-gb-monthly", quantity: 5);
|
||||
var subscription3 = CreateSubscription("sub_3", "storage-gb-monthly", quantity: 3);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription1, subscription2, subscription3));
|
||||
@@ -563,7 +788,10 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var userId = Guid.NewGuid();
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, status: StripeConstants.SubscriptionStatus.Active);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
@@ -585,7 +813,10 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var userId = Guid.NewGuid();
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, status: StripeConstants.SubscriptionStatus.Trialing);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
@@ -607,7 +838,10 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var userId = Guid.NewGuid();
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", quantity: 10, status: StripeConstants.SubscriptionStatus.PastDue);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(subscription));
|
||||
@@ -669,11 +903,14 @@ public class ReconcileAdditionalStorageJobTests
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob).Returns(true);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode).Returns(true);
|
||||
|
||||
var userId = Guid.NewGuid();
|
||||
var activeSubscription = CreateSubscription("sub_active", "storage-gb-monthly", quantity: 10, status: StripeConstants.SubscriptionStatus.Active);
|
||||
var trialingSubscription = CreateSubscription("sub_trialing", "storage-gb-monthly", quantity: 8, status: StripeConstants.SubscriptionStatus.Trialing);
|
||||
var pastDueSubscription = CreateSubscription("sub_pastdue", "storage-gb-monthly", quantity: 6, status: StripeConstants.SubscriptionStatus.PastDue);
|
||||
var canceledSubscription = CreateSubscription("sub_canceled", "storage-gb-monthly", quantity: 5, status: StripeConstants.SubscriptionStatus.Canceled);
|
||||
var incompleteSubscription = CreateSubscription("sub_incomplete", "storage-gb-monthly", quantity: 4, status: StripeConstants.SubscriptionStatus.Incomplete);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(Arg.Any<Dictionary<string, string>>())
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, userId, null));
|
||||
|
||||
_stripeFacade.ListSubscriptionsAutoPagingAsync(Arg.Any<SubscriptionListOptions>())
|
||||
.Returns(AsyncEnumerable.Create(activeSubscription, trialingSubscription, pastDueSubscription, canceledSubscription, incompleteSubscription));
|
||||
@@ -731,6 +968,410 @@ public class ReconcileAdditionalStorageJobTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Method Tests
|
||||
|
||||
#region DetermineSubscriptionPlanTier Tests
|
||||
|
||||
[Fact]
|
||||
public void DetermineSubscriptionPlanTier_WithUserId_ReturnsPersonal()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
Guid? organizationId = null;
|
||||
|
||||
// Act
|
||||
var result = _sut.DetermineSubscriptionPlanTier(userId, organizationId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ReconcileAdditionalStorageJob.SubscriptionPlanTier.Personal, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetermineSubscriptionPlanTier_WithOrganizationId_ReturnsOrganization()
|
||||
{
|
||||
// Arrange
|
||||
Guid? userId = null;
|
||||
var organizationId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var result = _sut.DetermineSubscriptionPlanTier(userId, organizationId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ReconcileAdditionalStorageJob.SubscriptionPlanTier.Organization, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetermineSubscriptionPlanTier_WithBothIds_ReturnsPersonal()
|
||||
{
|
||||
// Arrange - Personal takes precedence
|
||||
var userId = Guid.NewGuid();
|
||||
var organizationId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var result = _sut.DetermineSubscriptionPlanTier(userId, organizationId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ReconcileAdditionalStorageJob.SubscriptionPlanTier.Personal, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetermineSubscriptionPlanTier_WithNoIds_ReturnsUnknown()
|
||||
{
|
||||
// Arrange
|
||||
Guid? userId = null;
|
||||
Guid? organizationId = null;
|
||||
|
||||
// Act
|
||||
var result = _sut.DetermineSubscriptionPlanTier(userId, organizationId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ReconcileAdditionalStorageJob.SubscriptionPlanTier.Unknown, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetCurrentStorageQuantityFromSubscription Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("storage-gb-monthly", 10L, 10L)]
|
||||
[InlineData("storage-gb-annually", 25L, 25L)]
|
||||
[InlineData("personal-storage-gb-annually", 5L, 5L)]
|
||||
[InlineData("storage-gb-monthly", 0L, 0L)]
|
||||
public void GetCurrentStorageQuantityFromSubscription_WithMatchingPriceId_ReturnsQuantity(
|
||||
string priceId, long quantity, long expectedQuantity)
|
||||
{
|
||||
// Arrange
|
||||
var subscription = CreateSubscription("sub_123", priceId, quantity);
|
||||
|
||||
// Act
|
||||
var result = _sut.GetCurrentStorageQuantityFromSubscription(subscription, priceId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedQuantity, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCurrentStorageQuantityFromSubscription_WithNonMatchingPriceId_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var subscription = CreateSubscription("sub_123", "storage-gb-monthly", 10L);
|
||||
|
||||
// Act
|
||||
var result = _sut.GetCurrentStorageQuantityFromSubscription(subscription, "different-price-id");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCurrentStorageQuantityFromSubscription_WithNullItems_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var subscription = new Subscription { Id = "sub_123", Items = null };
|
||||
|
||||
// Act
|
||||
var result = _sut.GetCurrentStorageQuantityFromSubscription(subscription, "storage-gb-monthly");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCurrentStorageQuantityFromSubscription_WithEmptyItems_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "sub_123",
|
||||
Items = new StripeList<SubscriptionItem> { Data = [] }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.GetCurrentStorageQuantityFromSubscription(subscription, "storage-gb-monthly");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CalculateNewMaxStorageGb Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(10L, 6L, 11)] // 5 included + 6 new quantity
|
||||
[InlineData(15L, 11L, 16)] // 5 included + 11 new quantity
|
||||
[InlineData(4L, 0L, 5)] // Item deleted, returns base storage
|
||||
[InlineData(2L, 0L, 5)] // Item deleted, returns base storage
|
||||
[InlineData(8L, 4L, 9)] // 5 included + 4 new quantity
|
||||
public void CalculateNewMaxStorageGb_WithQuantityUpdate_ReturnsCorrectMaxStorage(
|
||||
long currentQuantity, long newQuantity, short expectedMaxStorageGb)
|
||||
{
|
||||
// Arrange
|
||||
var updateOptions = new SubscriptionUpdateOptions
|
||||
{
|
||||
Items =
|
||||
[
|
||||
newQuantity == 0
|
||||
? new SubscriptionItemOptions { Id = "si_123", Deleted = true } // Item marked as deleted
|
||||
: new SubscriptionItemOptions { Id = "si_123", Quantity = newQuantity } // Item quantity updated
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.CalculateNewMaxStorageGb(currentQuantity, updateOptions);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedMaxStorageGb, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateNewMaxStorageGb_WithNullUpdateOptions_ReturnsCurrentQuantityPlusBaseIncluded()
|
||||
{
|
||||
// Arrange
|
||||
const long currentQuantity = 10;
|
||||
|
||||
// Act
|
||||
var result = _sut.CalculateNewMaxStorageGb(currentQuantity, null);
|
||||
|
||||
// Assert
|
||||
Assert.Equal((short)(5 + currentQuantity), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateNewMaxStorageGb_WithNullItems_ReturnsCurrentQuantityPlusBaseIncluded()
|
||||
{
|
||||
// Arrange
|
||||
const long currentQuantity = 10;
|
||||
var updateOptions = new SubscriptionUpdateOptions { Items = null };
|
||||
|
||||
// Act
|
||||
var result = _sut.CalculateNewMaxStorageGb(currentQuantity, updateOptions);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5 + currentQuantity, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateNewMaxStorageGb_WithEmptyItems_ReturnsCurrentQuantity()
|
||||
{
|
||||
// Arrange
|
||||
const long currentQuantity = 10;
|
||||
var updateOptions = new SubscriptionUpdateOptions
|
||||
{
|
||||
Items = []
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.CalculateNewMaxStorageGb(currentQuantity, updateOptions);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5 + currentQuantity, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateNewMaxStorageGb_WithDeletedItem_ReturnsBaseStorage()
|
||||
{
|
||||
// Arrange
|
||||
const long currentQuantity = 100;
|
||||
var updateOptions = new SubscriptionUpdateOptions
|
||||
{
|
||||
Items = [new SubscriptionItemOptions { Id = "si_123", Deleted = true }]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.CalculateNewMaxStorageGb(currentQuantity, updateOptions);
|
||||
|
||||
// Assert
|
||||
Assert.Equal((short)5, result); // Base storage
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateNewMaxStorageGb_WithItemWithoutQuantity_ReturnsCurrentQuantity()
|
||||
{
|
||||
// Arrange
|
||||
const long currentQuantity = 10;
|
||||
var updateOptions = new SubscriptionUpdateOptions
|
||||
{
|
||||
Items = [new SubscriptionItemOptions { Id = "si_123", Quantity = null }]
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _sut.CalculateNewMaxStorageGb(currentQuantity, updateOptions);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(5 + currentQuantity, result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateDatabaseMaxStorageAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDatabaseMaxStorageAsync_PersonalTier_UpdatesUser()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
var user = new Bit.Core.Entities.User
|
||||
{
|
||||
Id = userId,
|
||||
Email = "test@example.com",
|
||||
GatewaySubscriptionId = "sub_123"
|
||||
};
|
||||
_userRepository.GetByIdAsync(userId).Returns(user);
|
||||
_userRepository.ReplaceAsync(user).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateDatabaseMaxStorageAsync(
|
||||
ReconcileAdditionalStorageJob.SubscriptionPlanTier.Personal,
|
||||
userId,
|
||||
10,
|
||||
"sub_123");
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Equal((short)10, user.MaxStorageGb);
|
||||
await _userRepository.Received(1).GetByIdAsync(userId);
|
||||
await _userRepository.Received(1).ReplaceAsync(user);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDatabaseMaxStorageAsync_PersonalTier_UserNotFound_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
_userRepository.GetByIdAsync(userId).Returns((Bit.Core.Entities.User?)null);
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateDatabaseMaxStorageAsync(
|
||||
ReconcileAdditionalStorageJob.SubscriptionPlanTier.Personal,
|
||||
userId,
|
||||
10,
|
||||
"sub_123");
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
await _userRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(default!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDatabaseMaxStorageAsync_PersonalTier_ReplaceThrowsException_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
var user = new Bit.Core.Entities.User
|
||||
{
|
||||
Id = userId,
|
||||
Email = "test@example.com",
|
||||
GatewaySubscriptionId = "sub_123"
|
||||
};
|
||||
_userRepository.GetByIdAsync(userId).Returns(user);
|
||||
_userRepository.ReplaceAsync(user).Throws(new Exception("Database error"));
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateDatabaseMaxStorageAsync(
|
||||
ReconcileAdditionalStorageJob.SubscriptionPlanTier.Personal,
|
||||
userId,
|
||||
10,
|
||||
"sub_123");
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDatabaseMaxStorageAsync_OrganizationTier_UpdatesOrganization()
|
||||
{
|
||||
// Arrange
|
||||
var organizationId = Guid.NewGuid();
|
||||
var organization = new Bit.Core.AdminConsole.Entities.Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Name = "Test Org",
|
||||
GatewaySubscriptionId = "sub_456"
|
||||
};
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
_organizationRepository.ReplaceAsync(organization).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateDatabaseMaxStorageAsync(
|
||||
ReconcileAdditionalStorageJob.SubscriptionPlanTier.Organization,
|
||||
organizationId,
|
||||
20,
|
||||
"sub_456");
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
Assert.Equal((short)20, organization.MaxStorageGb);
|
||||
await _organizationRepository.Received(1).GetByIdAsync(organizationId);
|
||||
await _organizationRepository.Received(1).ReplaceAsync(organization);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDatabaseMaxStorageAsync_OrganizationTier_OrganizationNotFound_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var organizationId = Guid.NewGuid();
|
||||
_organizationRepository.GetByIdAsync(organizationId)
|
||||
.Returns((Bit.Core.AdminConsole.Entities.Organization?)null);
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateDatabaseMaxStorageAsync(
|
||||
ReconcileAdditionalStorageJob.SubscriptionPlanTier.Organization,
|
||||
organizationId,
|
||||
20,
|
||||
"sub_456");
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
await _organizationRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(default!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDatabaseMaxStorageAsync_OrganizationTier_ReplaceThrowsException_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var organizationId = Guid.NewGuid();
|
||||
var organization = new Bit.Core.AdminConsole.Entities.Organization
|
||||
{
|
||||
Id = organizationId,
|
||||
Name = "Test Org",
|
||||
GatewaySubscriptionId = "sub_456"
|
||||
};
|
||||
_organizationRepository.GetByIdAsync(organizationId).Returns(organization);
|
||||
_organizationRepository.ReplaceAsync(organization).Throws(new Exception("Database error"));
|
||||
|
||||
// Act
|
||||
var result = await _sut.UpdateDatabaseMaxStorageAsync(
|
||||
ReconcileAdditionalStorageJob.SubscriptionPlanTier.Organization,
|
||||
organizationId,
|
||||
20,
|
||||
"sub_456");
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDatabaseMaxStorageAsync_UnknownTier_ReturnsFalse()
|
||||
{
|
||||
// Arrange & Act
|
||||
var entityId = Guid.NewGuid();
|
||||
var result = await _sut.UpdateDatabaseMaxStorageAsync(
|
||||
ReconcileAdditionalStorageJob.SubscriptionPlanTier.Unknown,
|
||||
entityId,
|
||||
15,
|
||||
"sub_789");
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
await _userRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default);
|
||||
await _organizationRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static IJobExecutionContext CreateJobExecutionContext(CancellationToken cancellationToken = default)
|
||||
@@ -762,7 +1403,27 @@ public class ReconcileAdditionalStorageJobTests
|
||||
Metadata = metadata,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem> { item }
|
||||
Data = [item]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Subscription CreateSubscriptionWithMultipleItems(string id, (string priceId, long quantity)[] items)
|
||||
{
|
||||
var subscriptionItems = items.Select(i => new SubscriptionItem
|
||||
{
|
||||
Id = $"si_{id}_{i.priceId}",
|
||||
Price = new Price { Id = i.priceId },
|
||||
Quantity = i.quantity
|
||||
}).ToList();
|
||||
|
||||
return new Subscription
|
||||
{
|
||||
Id = id,
|
||||
Status = StripeConstants.SubscriptionStatus.Active,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = subscriptionItems
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,4 +96,23 @@ public class OrganizationTests
|
||||
var host = Assert.Contains("Host", (IDictionary<string, object>)duo.MetaData);
|
||||
Assert.Equal("Host_value", host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseDisableSmAdsForUsers_DefaultValue_IsFalse()
|
||||
{
|
||||
var organization = new Organization();
|
||||
|
||||
Assert.False(organization.UseDisableSmAdsForUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UseDisableSmAdsForUsers_CanBeSetToTrue()
|
||||
{
|
||||
var organization = new Organization
|
||||
{
|
||||
UseDisableSmAdsForUsers = true
|
||||
};
|
||||
|
||||
Assert.True(organization.UseDisableSmAdsForUsers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Models.Data;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.Models.Data.OrganizationUsers;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.Utilities.v2;
|
||||
@@ -727,4 +728,54 @@ public class AutomaticallyConfirmUsersCommandTests
|
||||
Arg.Any<IEnumerable<string>>(),
|
||||
organization.Id.ToString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SendOrganizationConfirmedEmailAsync_WithFeatureFlagOn_UsesNewMailer(
|
||||
Organization organization,
|
||||
string userEmail,
|
||||
SutProvider<AutomaticallyConfirmOrganizationUserCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
const bool accessSecretsManager = true;
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SendOrganizationConfirmedEmailAsync(organization, userEmail, accessSecretsManager);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<ISendOrganizationConfirmationCommand>()
|
||||
.Received(1)
|
||||
.SendConfirmationAsync(organization, userEmail, accessSecretsManager);
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.DidNotReceive()
|
||||
.SendOrganizationConfirmedEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SendOrganizationConfirmedEmailAsync_WithFeatureFlagOff_UsesLegacyMailService(
|
||||
Organization organization,
|
||||
string userEmail,
|
||||
SutProvider<AutomaticallyConfirmOrganizationUserCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
const bool accessSecretsManager = false;
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail)
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SendOrganizationConfirmedEmailAsync(organization, userEmail, accessSecretsManager);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.Received(1)
|
||||
.SendOrganizationConfirmedEmailAsync(organization.Name, userEmail, accessSecretsManager);
|
||||
await sutProvider.GetDependency<ISendOrganizationConfirmationCommand>()
|
||||
.DidNotReceive()
|
||||
.SendConfirmationAsync(Arg.Any<Organization>(), Arg.Any<string>(), Arg.Any<bool>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Enforcement.AutoConfirm;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
@@ -462,7 +463,7 @@ public class ConfirmOrganizationUserCommandTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ConfirmUserAsync_WithCreateDefaultLocationEnabled_WithOrganizationDataOwnershipPolicyApplicable_WithValidCollectionName_CreatesDefaultCollection(
|
||||
public async Task ConfirmUserAsync_WithOrganizationDataOwnershipPolicyApplicable_WithValidCollectionName_CreatesDefaultCollection(
|
||||
Organization organization, OrganizationUser confirmingUser,
|
||||
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
|
||||
string key, string collectionName, SutProvider<ConfirmOrganizationUserCommand> sutProvider)
|
||||
@@ -475,8 +476,6 @@ public class ConfirmOrganizationUserCommandTests
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
|
||||
sutProvider.GetDependency<IUserRepository>().GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.CreateDefaultLocation).Returns(true);
|
||||
|
||||
var policyDetails = new PolicyDetails
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
@@ -506,7 +505,7 @@ public class ConfirmOrganizationUserCommandTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ConfirmUserAsync_WithCreateDefaultLocationEnabled_WithOrganizationDataOwnershipPolicyApplicable_WithInvalidCollectionName_DoesNotCreateDefaultCollection(
|
||||
public async Task ConfirmUserAsync_WithOrganizationDataOwnershipPolicyApplicable_WithInvalidCollectionName_DoesNotCreateDefaultCollection(
|
||||
Organization org, OrganizationUser confirmingUser,
|
||||
[OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user,
|
||||
string key, SutProvider<ConfirmOrganizationUserCommand> sutProvider)
|
||||
@@ -519,8 +518,6 @@ public class ConfirmOrganizationUserCommandTests
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(org.Id).Returns(org);
|
||||
sutProvider.GetDependency<IUserRepository>().GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.CreateDefaultLocation).Returns(true);
|
||||
|
||||
await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, "");
|
||||
|
||||
await sutProvider.GetDependency<ICollectionRepository>()
|
||||
@@ -529,7 +526,7 @@ public class ConfirmOrganizationUserCommandTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ConfirmUserAsync_WithCreateDefaultLocationEnabled_WithOrganizationDataOwnershipPolicyNotApplicable_DoesNotCreateDefaultCollection(
|
||||
public async Task ConfirmUserAsync_WithOrganizationDataOwnershipPolicyNotApplicable_DoesNotCreateDefaultCollection(
|
||||
Organization org, OrganizationUser confirmingUser,
|
||||
[OrganizationUser(OrganizationUserStatusType.Accepted, OrganizationUserType.Owner)] OrganizationUser orgUser, User user,
|
||||
string key, string collectionName, SutProvider<ConfirmOrganizationUserCommand> sutProvider)
|
||||
@@ -541,7 +538,6 @@ public class ConfirmOrganizationUserCommandTests
|
||||
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(org.Id).Returns(org);
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
|
||||
sutProvider.GetDependency<IUserRepository>().GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.CreateDefaultLocation).Returns(true);
|
||||
|
||||
var policyDetails = new PolicyDetails
|
||||
{
|
||||
@@ -814,4 +810,52 @@ public class ConfirmOrganizationUserCommandTests
|
||||
Assert.Empty(result[1].Item2);
|
||||
Assert.Equal(new OtherOrganizationDoesNotAllowOtherMembership().Message, result[2].Item2);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SendOrganizationConfirmedEmailAsync_WithFeatureFlagOn_UsesNewMailer(
|
||||
Organization org,
|
||||
string userEmail,
|
||||
SutProvider<ConfirmOrganizationUserCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
const bool accessSecretsManager = true;
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SendOrganizationConfirmedEmailAsync(org, userEmail, accessSecretsManager);
|
||||
|
||||
// Assert - verify new mailer is called, not legacy mail service
|
||||
await sutProvider.GetDependency<ISendOrganizationConfirmationCommand>()
|
||||
.Received(1)
|
||||
.SendConfirmationAsync(org, userEmail, accessSecretsManager);
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.DidNotReceive()
|
||||
.SendOrganizationConfirmedEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SendOrganizationConfirmedEmailAsync_WithFeatureFlagOff_UsesLegacyMailService(
|
||||
Organization org,
|
||||
string userEmail,
|
||||
SutProvider<ConfirmOrganizationUserCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
const bool accessSecretsManager = false;
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.OrganizationConfirmationEmail)
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SendOrganizationConfirmedEmailAsync(org, userEmail, accessSecretsManager);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IMailService>()
|
||||
.Received(1)
|
||||
.SendOrganizationConfirmedEmailAsync(org.DisplayName(), userEmail, accessSecretsManager);
|
||||
await sutProvider.GetDependency<ISendOrganizationConfirmationCommand>()
|
||||
.DidNotReceive()
|
||||
.SendConfirmationAsync(Arg.Any<Organization>(), Arg.Any<string>(), Arg.Any<bool>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Models.Mail.Mailer.OrganizationConfirmation;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Platform.Mail.Mailer;
|
||||
using Bit.Core.Test.AutoFixture.OrganizationFixtures;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class SendOrganizationConfirmationCommandTests
|
||||
{
|
||||
[Theory]
|
||||
[OrganizationCustomize, BitAutoData]
|
||||
public async Task SendConfirmationAsync_EnterpriseOrganization_SendsEnterpriseTeamsEmail(
|
||||
Organization organization,
|
||||
string userEmail,
|
||||
SutProvider<SendOrganizationConfirmationCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
organization.Name = "Test Enterprise Org";
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IMailer>().Received(1)
|
||||
.SendEmail(Arg.Is<OrganizationConfirmationEnterpriseTeams>(mail =>
|
||||
mail.ToEmails.Contains(userEmail) &&
|
||||
mail.ToEmails.Count() == 1 &&
|
||||
mail.View.OrganizationName == organization.Name &&
|
||||
mail.Subject == GetSubject(organization.Name)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[OrganizationCustomize, BitAutoData]
|
||||
public async Task SendConfirmationAsync_TeamsOrganization_SendsEnterpriseTeamsEmail(
|
||||
Organization organization,
|
||||
string userEmail,
|
||||
SutProvider<SendOrganizationConfirmationCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
organization.PlanType = PlanType.TeamsAnnually;
|
||||
organization.Name = "Test Teams Org";
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IMailer>().Received(1)
|
||||
.SendEmail(Arg.Is<OrganizationConfirmationEnterpriseTeams>(mail =>
|
||||
mail.ToEmails.Contains(userEmail) &&
|
||||
mail.ToEmails.Count() == 1 &&
|
||||
mail.View.OrganizationName == organization.Name &&
|
||||
mail.Subject == GetSubject(organization.Name)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[OrganizationCustomize, BitAutoData]
|
||||
public async Task SendConfirmationAsync_FamilyOrganization_SendsFamilyFreeEmail(
|
||||
Organization organization,
|
||||
string userEmail,
|
||||
SutProvider<SendOrganizationConfirmationCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
organization.PlanType = PlanType.FamiliesAnnually;
|
||||
organization.Name = "Test Family Org";
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IMailer>().Received(1)
|
||||
.SendEmail(Arg.Is<OrganizationConfirmationFamilyFree>(mail =>
|
||||
mail.ToEmails.Contains(userEmail) &&
|
||||
mail.ToEmails.Count() == 1 &&
|
||||
mail.View.OrganizationName == organization.Name &&
|
||||
mail.Subject == GetSubject(organization.Name)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[OrganizationCustomize, BitAutoData]
|
||||
public async Task SendConfirmationAsync_FreeOrganization_SendsFamilyFreeEmail(
|
||||
Organization organization,
|
||||
string userEmail,
|
||||
SutProvider<SendOrganizationConfirmationCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
organization.PlanType = PlanType.Free;
|
||||
organization.Name = "Test Free Org";
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IMailer>().Received(1)
|
||||
.SendEmail(Arg.Is<OrganizationConfirmationFamilyFree>(mail =>
|
||||
mail.ToEmails.Contains(userEmail) &&
|
||||
mail.ToEmails.Count() == 1 &&
|
||||
mail.View.OrganizationName == organization.Name &&
|
||||
mail.Subject == GetSubject(organization.Name)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[OrganizationCustomize, BitAutoData]
|
||||
public async Task SendConfirmationsAsync_MultipleUsers_SendsSingleEmail(
|
||||
Organization organization,
|
||||
List<string> userEmails,
|
||||
SutProvider<SendOrganizationConfirmationCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
organization.Name = "Test Enterprise Org";
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SendConfirmationsAsync(organization, userEmails, false);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IMailer>().Received(1)
|
||||
.SendEmail(Arg.Is<OrganizationConfirmationEnterpriseTeams>(mail =>
|
||||
mail.ToEmails.SequenceEqual(userEmails) &&
|
||||
mail.View.OrganizationName == organization.Name));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[OrganizationCustomize, BitAutoData]
|
||||
public async Task SendConfirmationsAsync_EmptyUserList_DoesNotSendEmail(
|
||||
Organization organization,
|
||||
SutProvider<SendOrganizationConfirmationCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
organization.Name = "Test Enterprise Org";
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SendConfirmationsAsync(organization, [], false);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IMailer>().DidNotReceive()
|
||||
.SendEmail(Arg.Any<OrganizationConfirmationEnterpriseTeams>());
|
||||
await sutProvider.GetDependency<IMailer>().DidNotReceive()
|
||||
.SendEmail(Arg.Any<OrganizationConfirmationFamilyFree>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[OrganizationCustomize, BitAutoData]
|
||||
public async Task SendConfirmationAsync_HtmlEncodedOrganizationName_DecodesNameCorrectly(
|
||||
Organization organization,
|
||||
string userEmail,
|
||||
SutProvider<SendOrganizationConfirmationCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
organization.PlanType = PlanType.EnterpriseAnnually;
|
||||
organization.Name = "Test & Company";
|
||||
var expectedDecodedName = "Test & Company";
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IMailer>().Received(1)
|
||||
.SendEmail(Arg.Is<OrganizationConfirmationEnterpriseTeams>(mail =>
|
||||
mail.View.OrganizationName == expectedDecodedName));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[OrganizationCustomize, BitAutoData]
|
||||
public async Task SendConfirmationAsync_AllEnterpriseTeamsPlanTypes_SendsEnterpriseTeamsEmail(
|
||||
Organization organization,
|
||||
string userEmail,
|
||||
SutProvider<SendOrganizationConfirmationCommand> sutProvider)
|
||||
{
|
||||
// Test all Enterprise and Teams plan types
|
||||
var enterpriseTeamsPlanTypes = new[]
|
||||
{
|
||||
PlanType.TeamsMonthly2019, PlanType.TeamsAnnually2019,
|
||||
PlanType.TeamsMonthly2020, PlanType.TeamsAnnually2020,
|
||||
PlanType.TeamsMonthly2023, PlanType.TeamsAnnually2023,
|
||||
PlanType.TeamsStarter2023, PlanType.TeamsMonthly,
|
||||
PlanType.TeamsAnnually, PlanType.TeamsStarter,
|
||||
PlanType.EnterpriseMonthly2019, PlanType.EnterpriseAnnually2019,
|
||||
PlanType.EnterpriseMonthly2020, PlanType.EnterpriseAnnually2020,
|
||||
PlanType.EnterpriseMonthly2023, PlanType.EnterpriseAnnually2023,
|
||||
PlanType.EnterpriseMonthly, PlanType.EnterpriseAnnually
|
||||
};
|
||||
|
||||
foreach (var planType in enterpriseTeamsPlanTypes)
|
||||
{
|
||||
// Arrange
|
||||
organization.PlanType = planType;
|
||||
organization.Name = "Test Org";
|
||||
sutProvider.GetDependency<IMailer>().ClearReceivedCalls();
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IMailer>().Received(1)
|
||||
.SendEmail(Arg.Any<OrganizationConfirmationEnterpriseTeams>());
|
||||
await sutProvider.GetDependency<IMailer>().DidNotReceive()
|
||||
.SendEmail(Arg.Any<OrganizationConfirmationFamilyFree>());
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[OrganizationCustomize, BitAutoData]
|
||||
public async Task SendConfirmationAsync_AllFamilyFreePlanTypes_SendsFamilyFreeEmail(
|
||||
Organization organization,
|
||||
string userEmail,
|
||||
SutProvider<SendOrganizationConfirmationCommand> sutProvider)
|
||||
{
|
||||
// Test all Family, Free, and Custom plan types
|
||||
var familyFreePlanTypes = new[]
|
||||
{
|
||||
PlanType.Free, PlanType.FamiliesAnnually2019,
|
||||
PlanType.FamiliesAnnually2025, PlanType.FamiliesAnnually,
|
||||
PlanType.Custom
|
||||
};
|
||||
|
||||
foreach (var planType in familyFreePlanTypes)
|
||||
{
|
||||
// Arrange
|
||||
organization.PlanType = planType;
|
||||
organization.Name = "Test Org";
|
||||
sutProvider.GetDependency<IMailer>().ClearReceivedCalls();
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SendConfirmationAsync(organization, userEmail, false);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IMailer>().Received(1)
|
||||
.SendEmail(Arg.Any<OrganizationConfirmationFamilyFree>());
|
||||
await sutProvider.GetDependency<IMailer>().DidNotReceive()
|
||||
.SendEmail(Arg.Any<OrganizationConfirmationEnterpriseTeams>());
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetSubject(string organizationName) => $"You can now access items from {organizationName}";
|
||||
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
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 NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class SelfRevokeOrganizationUserCommandTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData(OrganizationUserType.User)]
|
||||
[BitAutoData(OrganizationUserType.Custom)]
|
||||
[BitAutoData(OrganizationUserType.Admin)]
|
||||
public async Task SelfRevokeUser_Success(
|
||||
OrganizationUserType userType,
|
||||
Guid organizationId,
|
||||
Guid userId,
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed)] OrganizationUser organizationUser,
|
||||
SutProvider<SelfRevokeOrganizationUserCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
organizationUser.Type = userType;
|
||||
organizationUser.OrganizationId = organizationId;
|
||||
organizationUser.UserId = userId;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(organizationId, userId)
|
||||
.Returns(organizationUser);
|
||||
|
||||
// Create policy requirement with confirmed user
|
||||
var policyDetails = new List<PolicyDetails>
|
||||
{
|
||||
new()
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
OrganizationUserId = organizationUser.Id,
|
||||
OrganizationUserStatus = OrganizationUserStatusType.Confirmed,
|
||||
OrganizationUserType = userType,
|
||||
PolicyType = PolicyType.OrganizationDataOwnership
|
||||
}
|
||||
};
|
||||
var policyRequirement = new OrganizationDataOwnershipPolicyRequirement(
|
||||
OrganizationDataOwnershipState.Enabled,
|
||||
policyDetails);
|
||||
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
||||
.GetAsync<OrganizationDataOwnershipPolicyRequirement>(userId)
|
||||
.Returns(policyRequirement);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.SelfRevokeUserAsync(organizationId, userId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuccess);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.Received(1)
|
||||
.RevokeAsync(organizationUser.Id);
|
||||
|
||||
await sutProvider.GetDependency<IEventService>()
|
||||
.Received(1)
|
||||
.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_SelfRevoked);
|
||||
|
||||
await sutProvider.GetDependency<IPushNotificationService>()
|
||||
.Received(1)
|
||||
.PushSyncOrgKeysAsync(userId);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SelfRevokeUser_WhenUserNotFound_ReturnsNotFoundError(
|
||||
Guid organizationId,
|
||||
Guid userId,
|
||||
SutProvider<SelfRevokeOrganizationUserCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(organizationId, userId)
|
||||
.Returns((OrganizationUser)null);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.SelfRevokeUserAsync(organizationId, userId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsError);
|
||||
Assert.IsType<OrganizationUserNotFound>(result.AsError);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SelfRevokeUser_WhenNotEligible_ReturnsBadRequestError(
|
||||
Guid organizationId,
|
||||
Guid userId,
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser,
|
||||
SutProvider<SelfRevokeOrganizationUserCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
organizationUser.OrganizationId = organizationId;
|
||||
organizationUser.UserId = userId;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(organizationId, userId)
|
||||
.Returns(organizationUser);
|
||||
|
||||
// Policy requirement with no policies (disabled)
|
||||
var policyRequirement = new OrganizationDataOwnershipPolicyRequirement(
|
||||
OrganizationDataOwnershipState.Disabled,
|
||||
Enumerable.Empty<PolicyDetails>());
|
||||
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
||||
.GetAsync<OrganizationDataOwnershipPolicyRequirement>(userId)
|
||||
.Returns(policyRequirement);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.SelfRevokeUserAsync(organizationId, userId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsError);
|
||||
Assert.IsType<NotEligibleForSelfRevoke>(result.AsError);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SelfRevokeUser_WhenLastOwner_ReturnsBadRequestError(
|
||||
Guid organizationId,
|
||||
Guid userId,
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser,
|
||||
SutProvider<SelfRevokeOrganizationUserCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
organizationUser.OrganizationId = organizationId;
|
||||
organizationUser.UserId = userId;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(organizationId, userId)
|
||||
.Returns(organizationUser);
|
||||
|
||||
// Create policy requirement with confirmed owner
|
||||
var policyDetails = new List<PolicyDetails>
|
||||
{
|
||||
new()
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
OrganizationUserId = organizationUser.Id,
|
||||
OrganizationUserStatus = OrganizationUserStatusType.Confirmed,
|
||||
OrganizationUserType = OrganizationUserType.Owner,
|
||||
PolicyType = PolicyType.OrganizationDataOwnership
|
||||
}
|
||||
};
|
||||
var policyRequirement = new OrganizationDataOwnershipPolicyRequirement(
|
||||
OrganizationDataOwnershipState.Enabled,
|
||||
policyDetails);
|
||||
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
||||
.GetAsync<OrganizationDataOwnershipPolicyRequirement>(userId)
|
||||
.Returns(policyRequirement);
|
||||
|
||||
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
|
||||
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>(), true)
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.SelfRevokeUserAsync(organizationId, userId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsError);
|
||||
Assert.IsType<LastOwnerCannotSelfRevoke>(result.AsError);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SelfRevokeUser_WhenOwnerButNotLastOwner_Success(
|
||||
Guid organizationId,
|
||||
Guid userId,
|
||||
[OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser,
|
||||
SutProvider<SelfRevokeOrganizationUserCommand> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
organizationUser.OrganizationId = organizationId;
|
||||
organizationUser.UserId = userId;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(organizationId, userId)
|
||||
.Returns(organizationUser);
|
||||
|
||||
// Create policy requirement with confirmed owner
|
||||
var policyDetails = new List<PolicyDetails>
|
||||
{
|
||||
new()
|
||||
{
|
||||
OrganizationId = organizationId,
|
||||
OrganizationUserId = organizationUser.Id,
|
||||
OrganizationUserStatus = OrganizationUserStatusType.Confirmed,
|
||||
OrganizationUserType = OrganizationUserType.Owner,
|
||||
PolicyType = PolicyType.OrganizationDataOwnership
|
||||
}
|
||||
};
|
||||
var policyRequirement = new OrganizationDataOwnershipPolicyRequirement(
|
||||
OrganizationDataOwnershipState.Enabled,
|
||||
policyDetails);
|
||||
|
||||
sutProvider.GetDependency<IPolicyRequirementQuery>()
|
||||
.GetAsync<OrganizationDataOwnershipPolicyRequirement>(userId)
|
||||
.Returns(policyRequirement);
|
||||
|
||||
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>()
|
||||
.HasConfirmedOwnersExceptAsync(organizationId, Arg.Any<IEnumerable<Guid>>(), true)
|
||||
.Returns(true);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.SelfRevokeUserAsync(organizationId, userId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuccess);
|
||||
|
||||
await sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.Received(1)
|
||||
.RevokeAsync(organizationUser.Id);
|
||||
|
||||
await sutProvider.GetDependency<IEventService>()
|
||||
.Received(1)
|
||||
.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_SelfRevoked);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,73 @@ public class OrganizationDataOwnershipPolicyRequirementFactoryTests
|
||||
Assert.Equal(PolicyType.OrganizationDataOwnership, sutProvider.Sut.PolicyType);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void EligibleForSelfRevoke_WithConfirmedUser_ReturnsTrue(
|
||||
Guid organizationId,
|
||||
[PolicyDetails(PolicyType.OrganizationDataOwnership, userStatus: OrganizationUserStatusType.Confirmed)] PolicyDetails[] policies,
|
||||
SutProvider<OrganizationDataOwnershipPolicyRequirementFactory> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
policies[0].OrganizationId = organizationId;
|
||||
var requirement = sutProvider.Sut.Create(policies);
|
||||
|
||||
// Act
|
||||
var result = requirement.EligibleForSelfRevoke(organizationId);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void EligibleForSelfRevoke_WithInvitedUser_ReturnsFalse(
|
||||
Guid organizationId,
|
||||
[PolicyDetails(PolicyType.OrganizationDataOwnership, userStatus: OrganizationUserStatusType.Invited)] PolicyDetails[] policies,
|
||||
SutProvider<OrganizationDataOwnershipPolicyRequirementFactory> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
policies[0].OrganizationId = organizationId;
|
||||
var requirement = sutProvider.Sut.Create(policies);
|
||||
|
||||
// Act
|
||||
var result = requirement.EligibleForSelfRevoke(organizationId);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void EligibleForSelfRevoke_WithNoPolicies_ReturnsFalse(
|
||||
Guid organizationId,
|
||||
SutProvider<OrganizationDataOwnershipPolicyRequirementFactory> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
var requirement = sutProvider.Sut.Create([]);
|
||||
|
||||
// Act
|
||||
var result = requirement.EligibleForSelfRevoke(organizationId);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void EligibleForSelfRevoke_WithDifferentOrganization_ReturnsFalse(
|
||||
Guid organizationId,
|
||||
Guid differentOrganizationId,
|
||||
[PolicyDetails(PolicyType.OrganizationDataOwnership, userStatus: OrganizationUserStatusType.Confirmed)] PolicyDetails[] policies,
|
||||
SutProvider<OrganizationDataOwnershipPolicyRequirementFactory> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
policies[0].OrganizationId = differentOrganizationId;
|
||||
var requirement = sutProvider.Sut.Create(policies);
|
||||
|
||||
// Act
|
||||
var result = requirement.EligibleForSelfRevoke(organizationId);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public void GetDefaultCollectionRequestOnPolicyEnable_WithConfirmedUser_ReturnsTrue(
|
||||
[PolicyDetails(PolicyType.OrganizationDataOwnership, userStatus: OrganizationUserStatusType.Confirmed)] PolicyDetails[] policies,
|
||||
|
||||
@@ -6,7 +6,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Test.AdminConsole.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
@@ -20,29 +19,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
{
|
||||
private const string _defaultUserCollectionName = "Default";
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ExecuteSideEffectsAsync_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.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<ICollectionRepository>()
|
||||
.DidNotReceive()
|
||||
.UpsertDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ExecuteSideEffectsAsync_PolicyAlreadyEnabled_DoesNothing(
|
||||
[PolicyUpdate(PolicyType.OrganizationDataOwnership, true)] PolicyUpdate policyUpdate,
|
||||
@@ -54,10 +30,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
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
|
||||
@@ -80,10 +52,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
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
|
||||
@@ -234,10 +202,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
previousPolicyState.OrganizationId = policyUpdate.OrganizationId;
|
||||
policyUpdate.Enabled = true;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
|
||||
.Returns(true);
|
||||
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, metadata);
|
||||
|
||||
// Act
|
||||
@@ -264,39 +228,10 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
IPolicyRepository policyRepository,
|
||||
ICollectionRepository collectionRepository)
|
||||
{
|
||||
|
||||
var featureService = Substitute.For<IFeatureService>();
|
||||
featureService
|
||||
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
|
||||
.Returns(true);
|
||||
|
||||
var sut = new OrganizationDataOwnershipPolicyValidator(policyRepository, collectionRepository, [factory], featureService);
|
||||
var sut = new OrganizationDataOwnershipPolicyValidator(policyRepository, collectionRepository, [factory]);
|
||||
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,
|
||||
@@ -308,10 +243,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
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
|
||||
@@ -334,10 +265,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
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
|
||||
@@ -432,10 +359,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests
|
||||
previousPolicyState.OrganizationId = policyUpdate.OrganizationId;
|
||||
policyUpdate.Enabled = true;
|
||||
|
||||
sutProvider.GetDependency<IFeatureService>()
|
||||
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
|
||||
.Returns(true);
|
||||
|
||||
var policyRequest = new SavePolicyModel(policyUpdate, metadata);
|
||||
|
||||
// Act
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.UserFeatures.UserMasterPassword;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@@ -21,106 +23,154 @@ public class SetInitialMasterPasswordCommandTests
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SetInitialMasterPassword_Success(SutProvider<SetInitialMasterPasswordCommand> sutProvider,
|
||||
User user, string masterPassword, string key, string orgIdentifier,
|
||||
Organization org, OrganizationUser orgUser)
|
||||
User user, UserAccountKeysData accountKeys, KdfSettings kdfSettings,
|
||||
Organization org, OrganizationUser orgUser, string serverSideHash, string masterPasswordHint)
|
||||
{
|
||||
// Arrange
|
||||
user.MasterPassword = null;
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), true, false)
|
||||
.Returns(IdentityResult.Success);
|
||||
user.Key = null;
|
||||
var model = CreateValidModel(user, accountKeys, kdfSettings, org.Identifier, masterPasswordHint);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdentifierAsync(orgIdentifier)
|
||||
.GetByIdentifierAsync(org.Identifier)
|
||||
.Returns(org);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(org.Id, user.Id)
|
||||
.Returns(orgUser);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier);
|
||||
sutProvider.GetDependency<IPasswordHasher<User>>()
|
||||
.HashPassword(user, model.MasterPasswordAuthentication.MasterPasswordAuthenticationHash)
|
||||
.Returns(serverSideHash);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(IdentityResult.Success, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SetInitialMasterPassword_UserIsNull_ThrowsArgumentNullException(SutProvider<SetInitialMasterPasswordCommand> sutProvider, string masterPassword, string key, string orgIdentifier)
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(null, masterPassword, key, orgIdentifier));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SetInitialMasterPassword_AlreadyHasPassword_ReturnsFalse(SutProvider<SetInitialMasterPasswordCommand> sutProvider, User user, string masterPassword, string key, string orgIdentifier)
|
||||
{
|
||||
// Arrange
|
||||
user.MasterPassword = "ExistingPassword";
|
||||
// Mock SetMasterPassword to return a specific UpdateUserData delegate
|
||||
UpdateUserData mockUpdateUserData = (connection, transaction) => Task.CompletedTask;
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.SetMasterPassword(user.Id, model.MasterPasswordUnlock, serverSideHash, model.MasterPasswordHint)
|
||||
.Returns(mockUpdateUserData);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier);
|
||||
await sutProvider.Sut.SetInitialMasterPasswordAsync(user, model);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Succeeded);
|
||||
await sutProvider.GetDependency<IUserRepository>().Received(1)
|
||||
.SetV2AccountCryptographicStateAsync(
|
||||
user.Id,
|
||||
model.AccountKeys,
|
||||
Arg.Do<IEnumerable<UpdateUserData>>(actions =>
|
||||
{
|
||||
var actionsList = actions.ToList();
|
||||
Assert.Single(actionsList);
|
||||
Assert.Same(mockUpdateUserData, actionsList[0]);
|
||||
}));
|
||||
|
||||
await sutProvider.GetDependency<IEventService>().Received(1)
|
||||
.LogUserEventAsync(user.Id, EventType.User_ChangedPassword);
|
||||
|
||||
await sutProvider.GetDependency<IAcceptOrgUserCommand>().Received(1)
|
||||
.AcceptOrgUserAsync(orgUser, user, sutProvider.GetDependency<IUserService>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SetInitialMasterPassword_NullOrgSsoIdentifier_ThrowsBadRequestException(
|
||||
SutProvider<SetInitialMasterPasswordCommand> sutProvider, User user, string masterPassword, string key)
|
||||
public async Task SetInitialMasterPassword_UserAlreadyHasPassword_ThrowsBadRequestException(
|
||||
SutProvider<SetInitialMasterPasswordCommand> sutProvider,
|
||||
User user, UserAccountKeysData accountKeys, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint)
|
||||
{
|
||||
// Arrange
|
||||
user.MasterPassword = null;
|
||||
string orgSsoIdentifier = null;
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), true, false)
|
||||
.Returns(IdentityResult.Success);
|
||||
user.Key = "existing-key";
|
||||
var model = CreateValidModel(user, accountKeys, kdfSettings, orgSsoIdentifier, masterPasswordHint);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgSsoIdentifier));
|
||||
Assert.Equal("Organization SSO Identifier required.", exception.Message);
|
||||
async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, model));
|
||||
Assert.Equal("User already has a master password set.", exception.Message);
|
||||
}
|
||||
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SetInitialMasterPassword_InvalidOrganization_Throws(SutProvider<SetInitialMasterPasswordCommand> sutProvider, User user, string masterPassword, string key, string orgIdentifier)
|
||||
public async Task SetInitialMasterPassword_AccountKeysNull_ThrowsBadRequestException(
|
||||
SutProvider<SetInitialMasterPasswordCommand> sutProvider,
|
||||
User user, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint)
|
||||
{
|
||||
// Arrange
|
||||
user.MasterPassword = null;
|
||||
user.Key = null;
|
||||
var model = CreateValidModel(user, null, kdfSettings, orgSsoIdentifier, masterPasswordHint);
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), true, false)
|
||||
.Returns(IdentityResult.Success);
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, model));
|
||||
Assert.Equal("Account keys are required.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData("wrong-salt", null)]
|
||||
[BitAutoData([null, "wrong-salt"])]
|
||||
[BitAutoData("wrong-salt", "different-wrong-salt")]
|
||||
public async Task SetInitialMasterPassword_InvalidSalt_ThrowsBadRequestException(
|
||||
string? authSaltOverride, string? unlockSaltOverride,
|
||||
SutProvider<SetInitialMasterPasswordCommand> sutProvider,
|
||||
User user, UserAccountKeysData accountKeys, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint)
|
||||
{
|
||||
// Arrange
|
||||
user.Key = null;
|
||||
var correctSalt = user.GetMasterPasswordSalt();
|
||||
var model = new SetInitialMasterPasswordDataModel
|
||||
{
|
||||
MasterPasswordAuthentication = new MasterPasswordAuthenticationData
|
||||
{
|
||||
Salt = authSaltOverride ?? correctSalt,
|
||||
MasterPasswordAuthenticationHash = "hash",
|
||||
Kdf = kdfSettings
|
||||
},
|
||||
MasterPasswordUnlock = new MasterPasswordUnlockData
|
||||
{
|
||||
Salt = unlockSaltOverride ?? correctSalt,
|
||||
MasterKeyWrappedUserKey = "wrapped-key",
|
||||
Kdf = kdfSettings
|
||||
},
|
||||
AccountKeys = accountKeys,
|
||||
OrgSsoIdentifier = orgSsoIdentifier,
|
||||
MasterPasswordHint = masterPasswordHint
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, model));
|
||||
Assert.Equal("Invalid master password salt.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SetInitialMasterPassword_InvalidOrgSsoIdentifier_ThrowsBadRequestException(
|
||||
SutProvider<SetInitialMasterPasswordCommand> sutProvider,
|
||||
User user, UserAccountKeysData accountKeys, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint)
|
||||
{
|
||||
// Arrange
|
||||
user.Key = null;
|
||||
var model = CreateValidModel(user, accountKeys, kdfSettings, orgSsoIdentifier, masterPasswordHint);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdentifierAsync(orgIdentifier)
|
||||
.GetByIdentifierAsync(orgSsoIdentifier)
|
||||
.ReturnsNull();
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier));
|
||||
Assert.Equal("Organization invalid.", exception.Message);
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, model));
|
||||
Assert.Equal("Organization SSO identifier is invalid.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SetInitialMasterPassword_UserNotFoundInOrganization_Throws(SutProvider<SetInitialMasterPasswordCommand> sutProvider, User user, string masterPassword, string key, Organization org)
|
||||
public async Task SetInitialMasterPassword_UserNotFoundInOrganization_ThrowsBadRequestException(
|
||||
SutProvider<SetInitialMasterPasswordCommand> sutProvider,
|
||||
User user, UserAccountKeysData accountKeys, KdfSettings kdfSettings, Organization org, string masterPasswordHint)
|
||||
{
|
||||
// Arrange
|
||||
user.MasterPassword = null;
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), true, false)
|
||||
.Returns(IdentityResult.Success);
|
||||
user.Key = null;
|
||||
var model = CreateValidModel(user, accountKeys, kdfSettings, org.Identifier, masterPasswordHint);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdentifierAsync(Arg.Any<string>())
|
||||
.GetByIdentifierAsync(org.Identifier)
|
||||
.Returns(org);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
@@ -128,67 +178,33 @@ public class SetInitialMasterPasswordCommandTests
|
||||
.ReturnsNull();
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, org.Identifier));
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, model));
|
||||
Assert.Equal("User not found within organization.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SetInitialMasterPassword_ConfirmedOrgUser_DoesNotCallAcceptOrgUser(SutProvider<SetInitialMasterPasswordCommand> sutProvider,
|
||||
User user, string masterPassword, string key, string orgIdentifier, Organization org, OrganizationUser orgUser)
|
||||
private static SetInitialMasterPasswordDataModel CreateValidModel(
|
||||
User user, UserAccountKeysData? accountKeys, KdfSettings kdfSettings,
|
||||
string orgSsoIdentifier, string? masterPasswordHint)
|
||||
{
|
||||
// Arrange
|
||||
user.MasterPassword = null;
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), true, false)
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdentifierAsync(orgIdentifier)
|
||||
.Returns(org);
|
||||
|
||||
orgUser.Status = OrganizationUserStatusType.Confirmed;
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(org.Id, user.Id)
|
||||
.Returns(orgUser);
|
||||
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(IdentityResult.Success, result);
|
||||
await sutProvider.GetDependency<IAcceptOrgUserCommand>().DidNotReceive().AcceptOrgUserAsync(Arg.Any<OrganizationUser>(), Arg.Any<User>(), Arg.Any<IUserService>());
|
||||
var salt = user.GetMasterPasswordSalt();
|
||||
return new SetInitialMasterPasswordDataModel
|
||||
{
|
||||
MasterPasswordAuthentication = new MasterPasswordAuthenticationData
|
||||
{
|
||||
Salt = salt,
|
||||
MasterPasswordAuthenticationHash = "hash",
|
||||
Kdf = kdfSettings
|
||||
},
|
||||
MasterPasswordUnlock = new MasterPasswordUnlockData
|
||||
{
|
||||
Salt = salt,
|
||||
MasterKeyWrappedUserKey = "wrapped-key",
|
||||
Kdf = kdfSettings
|
||||
},
|
||||
AccountKeys = accountKeys,
|
||||
OrgSsoIdentifier = orgSsoIdentifier,
|
||||
MasterPasswordHint = masterPasswordHint
|
||||
};
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SetInitialMasterPassword_InvitedOrgUser_CallsAcceptOrgUser(SutProvider<SetInitialMasterPasswordCommand> sutProvider,
|
||||
User user, string masterPassword, string key, string orgIdentifier, Organization org, OrganizationUser orgUser)
|
||||
{
|
||||
// Arrange
|
||||
user.MasterPassword = null;
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), true, false)
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdentifierAsync(orgIdentifier)
|
||||
.Returns(org);
|
||||
|
||||
orgUser.Status = OrganizationUserStatusType.Invited;
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(org.Id, user.Id)
|
||||
.Returns(orgUser);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(IdentityResult.Success, result);
|
||||
await sutProvider.GetDependency<IAcceptOrgUserCommand>().Received(1).AcceptOrgUserAsync(orgUser, user, sutProvider.GetDependency<IUserService>());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.UserFeatures.UserMasterPassword;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
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.Auth.UserFeatures.UserMasterPassword;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class SetInitialMasterPasswordCommandV1Tests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SetInitialMasterPassword_Success(SutProvider<SetInitialMasterPasswordCommandV1> sutProvider,
|
||||
User user, string masterPassword, string key, string orgIdentifier,
|
||||
Organization org, OrganizationUser orgUser)
|
||||
{
|
||||
// Arrange
|
||||
user.MasterPassword = null;
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), true, false)
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdentifierAsync(orgIdentifier)
|
||||
.Returns(org);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(org.Id, user.Id)
|
||||
.Returns(orgUser);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(IdentityResult.Success, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SetInitialMasterPassword_UserIsNull_ThrowsArgumentNullException(SutProvider<SetInitialMasterPasswordCommandV1> sutProvider, string masterPassword, string key, string orgIdentifier)
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(null, masterPassword, key, orgIdentifier));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SetInitialMasterPassword_AlreadyHasPassword_ReturnsFalse(SutProvider<SetInitialMasterPasswordCommandV1> sutProvider, User user, string masterPassword, string key, string orgIdentifier)
|
||||
{
|
||||
// Arrange
|
||||
user.MasterPassword = "ExistingPassword";
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Succeeded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SetInitialMasterPassword_NullOrgSsoIdentifier_ThrowsBadRequestException(
|
||||
SutProvider<SetInitialMasterPasswordCommandV1> sutProvider, User user, string masterPassword, string key)
|
||||
{
|
||||
// Arrange
|
||||
user.MasterPassword = null;
|
||||
string orgSsoIdentifier = null;
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), true, false)
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgSsoIdentifier));
|
||||
Assert.Equal("Organization SSO Identifier required.", exception.Message);
|
||||
}
|
||||
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SetInitialMasterPassword_InvalidOrganization_Throws(SutProvider<SetInitialMasterPasswordCommandV1> sutProvider, User user, string masterPassword, string key, string orgIdentifier)
|
||||
{
|
||||
// Arrange
|
||||
user.MasterPassword = null;
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), true, false)
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdentifierAsync(orgIdentifier)
|
||||
.ReturnsNull();
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier));
|
||||
Assert.Equal("Organization invalid.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SetInitialMasterPassword_UserNotFoundInOrganization_Throws(SutProvider<SetInitialMasterPasswordCommandV1> sutProvider, User user, string masterPassword, string key, Organization org)
|
||||
{
|
||||
// Arrange
|
||||
user.MasterPassword = null;
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), true, false)
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdentifierAsync(Arg.Any<string>())
|
||||
.Returns(org);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(org.Id, user.Id)
|
||||
.ReturnsNull();
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, org.Identifier));
|
||||
Assert.Equal("User not found within organization.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SetInitialMasterPassword_ConfirmedOrgUser_DoesNotCallAcceptOrgUser(SutProvider<SetInitialMasterPasswordCommandV1> sutProvider,
|
||||
User user, string masterPassword, string key, string orgIdentifier, Organization org, OrganizationUser orgUser)
|
||||
{
|
||||
// Arrange
|
||||
user.MasterPassword = null;
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), true, false)
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdentifierAsync(orgIdentifier)
|
||||
.Returns(org);
|
||||
|
||||
orgUser.Status = OrganizationUserStatusType.Confirmed;
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(org.Id, user.Id)
|
||||
.Returns(orgUser);
|
||||
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(IdentityResult.Success, result);
|
||||
await sutProvider.GetDependency<IAcceptOrgUserCommand>().DidNotReceive().AcceptOrgUserAsync(Arg.Any<OrganizationUser>(), Arg.Any<User>(), Arg.Any<IUserService>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task SetInitialMasterPassword_InvitedOrgUser_CallsAcceptOrgUser(SutProvider<SetInitialMasterPasswordCommandV1> sutProvider,
|
||||
User user, string masterPassword, string key, string orgIdentifier, Organization org, OrganizationUser orgUser)
|
||||
{
|
||||
// Arrange
|
||||
user.MasterPassword = null;
|
||||
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), true, false)
|
||||
.Returns(IdentityResult.Success);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdentifierAsync(orgIdentifier)
|
||||
.Returns(org);
|
||||
|
||||
orgUser.Status = OrganizationUserStatusType.Invited;
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(org.Id, user.Id)
|
||||
.Returns(orgUser);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(IdentityResult.Success, result);
|
||||
await sutProvider.GetDependency<IAcceptOrgUserCommand>().Received(1).AcceptOrgUserAsync(orgUser, user, sutProvider.GetDependency<IUserService>());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Auth.UserFeatures.UserMasterPassword;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
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.Auth.UserFeatures.UserMasterPassword;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class TdeSetPasswordCommandTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task OnboardMasterPassword_Success(SutProvider<TdeSetPasswordCommand> sutProvider,
|
||||
User user, KdfSettings kdfSettings,
|
||||
Organization org, OrganizationUser orgUser, string serverSideHash, string masterPasswordHint)
|
||||
{
|
||||
// Arrange
|
||||
user.Key = null;
|
||||
user.PublicKey = "public-key";
|
||||
user.PrivateKey = "private-key";
|
||||
var model = CreateValidModel(user, kdfSettings, org.Identifier, masterPasswordHint);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdentifierAsync(org.Identifier)
|
||||
.Returns(org);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(org.Id, user.Id)
|
||||
.Returns(orgUser);
|
||||
|
||||
sutProvider.GetDependency<IPasswordHasher<User>>()
|
||||
.HashPassword(user, model.MasterPasswordAuthentication.MasterPasswordAuthenticationHash)
|
||||
.Returns(serverSideHash);
|
||||
|
||||
// Mock SetMasterPassword to return a specific UpdateUserData delegate
|
||||
UpdateUserData mockUpdateUserData = (connection, transaction) => Task.CompletedTask;
|
||||
sutProvider.GetDependency<IUserRepository>()
|
||||
.SetMasterPassword(user.Id, model.MasterPasswordUnlock, serverSideHash, model.MasterPasswordHint)
|
||||
.Returns(mockUpdateUserData);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.SetMasterPasswordAsync(user, model);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IUserRepository>().Received(1)
|
||||
.UpdateUserDataAsync(Arg.Do<IEnumerable<UpdateUserData>>(actions =>
|
||||
{
|
||||
var actionsList = actions.ToList();
|
||||
Assert.Single(actionsList);
|
||||
Assert.Same(mockUpdateUserData, actionsList[0]);
|
||||
}));
|
||||
|
||||
await sutProvider.GetDependency<IEventService>().Received(1)
|
||||
.LogUserEventAsync(user.Id, EventType.User_ChangedPassword);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task OnboardMasterPassword_UserAlreadyHasPassword_ThrowsBadRequestException(
|
||||
SutProvider<TdeSetPasswordCommand> sutProvider,
|
||||
User user, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint)
|
||||
{
|
||||
// Arrange
|
||||
user.Key = "existing-key";
|
||||
var model = CreateValidModel(user, kdfSettings, orgSsoIdentifier, masterPasswordHint);
|
||||
|
||||
// Act & Assert
|
||||
var exception =
|
||||
await Assert.ThrowsAsync<BadRequestException>(async () =>
|
||||
await sutProvider.Sut.SetMasterPasswordAsync(user, model));
|
||||
Assert.Equal("User already has a master password set.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData([null, "private-key"])]
|
||||
[BitAutoData("public-key", null)]
|
||||
[BitAutoData([null, null])]
|
||||
public async Task OnboardMasterPassword_MissingAccountKeys_ThrowsBadRequestException(
|
||||
string? publicKey, string? privateKey,
|
||||
SutProvider<TdeSetPasswordCommand> sutProvider,
|
||||
User user, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint)
|
||||
{
|
||||
// Arrange
|
||||
user.Key = null;
|
||||
user.PublicKey = publicKey;
|
||||
user.PrivateKey = privateKey;
|
||||
var model = CreateValidModel(user, kdfSettings, orgSsoIdentifier, masterPasswordHint);
|
||||
|
||||
// Act & Assert
|
||||
var exception =
|
||||
await Assert.ThrowsAsync<BadRequestException>(async () =>
|
||||
await sutProvider.Sut.SetMasterPasswordAsync(user, model));
|
||||
Assert.Equal("TDE user account keys must be set before setting initial master password.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData("wrong-salt", null)]
|
||||
[BitAutoData([null, "wrong-salt"])]
|
||||
[BitAutoData("wrong-salt", "different-wrong-salt")]
|
||||
public async Task OnboardMasterPassword_InvalidSalt_ThrowsBadRequestException(
|
||||
string? authSaltOverride, string? unlockSaltOverride,
|
||||
SutProvider<TdeSetPasswordCommand> sutProvider,
|
||||
User user, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint)
|
||||
{
|
||||
// Arrange
|
||||
user.Key = null;
|
||||
user.PublicKey = "public-key";
|
||||
user.PrivateKey = "private-key";
|
||||
var correctSalt = user.GetMasterPasswordSalt();
|
||||
var model = new SetInitialMasterPasswordDataModel
|
||||
{
|
||||
MasterPasswordAuthentication =
|
||||
new MasterPasswordAuthenticationData
|
||||
{
|
||||
Salt = authSaltOverride ?? correctSalt,
|
||||
MasterPasswordAuthenticationHash = "hash",
|
||||
Kdf = kdfSettings
|
||||
},
|
||||
MasterPasswordUnlock = new MasterPasswordUnlockData
|
||||
{
|
||||
Salt = unlockSaltOverride ?? correctSalt,
|
||||
MasterKeyWrappedUserKey = "wrapped-key",
|
||||
Kdf = kdfSettings
|
||||
},
|
||||
AccountKeys = null,
|
||||
OrgSsoIdentifier = orgSsoIdentifier,
|
||||
MasterPasswordHint = masterPasswordHint
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
var exception =
|
||||
await Assert.ThrowsAsync<BadRequestException>(async () =>
|
||||
await sutProvider.Sut.SetMasterPasswordAsync(user, model));
|
||||
Assert.Equal("Invalid master password salt.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task OnboardMasterPassword_InvalidOrgSsoIdentifier_ThrowsBadRequestException(
|
||||
SutProvider<TdeSetPasswordCommand> sutProvider,
|
||||
User user, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint)
|
||||
{
|
||||
// Arrange
|
||||
user.Key = null;
|
||||
user.PublicKey = "public-key";
|
||||
user.PrivateKey = "private-key";
|
||||
var model = CreateValidModel(user, kdfSettings, orgSsoIdentifier, masterPasswordHint);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdentifierAsync(orgSsoIdentifier)
|
||||
.ReturnsNull();
|
||||
|
||||
// Act & Assert
|
||||
var exception =
|
||||
await Assert.ThrowsAsync<BadRequestException>(async () =>
|
||||
await sutProvider.Sut.SetMasterPasswordAsync(user, model));
|
||||
Assert.Equal("Organization SSO identifier is invalid.", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task OnboardMasterPassword_UserNotFoundInOrganization_ThrowsBadRequestException(
|
||||
SutProvider<TdeSetPasswordCommand> sutProvider,
|
||||
User user, KdfSettings kdfSettings, Organization org, string masterPasswordHint)
|
||||
{
|
||||
// Arrange
|
||||
user.Key = null;
|
||||
user.PublicKey = "public-key";
|
||||
user.PrivateKey = "private-key";
|
||||
var model = CreateValidModel(user, kdfSettings, org.Identifier, masterPasswordHint);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdentifierAsync(org.Identifier)
|
||||
.Returns(org);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetByOrganizationAsync(org.Id, user.Id)
|
||||
.ReturnsNull();
|
||||
|
||||
// Act & Assert
|
||||
var exception =
|
||||
await Assert.ThrowsAsync<BadRequestException>(async () =>
|
||||
await sutProvider.Sut.SetMasterPasswordAsync(user, model));
|
||||
Assert.Equal("User not found within organization.", exception.Message);
|
||||
}
|
||||
|
||||
private static SetInitialMasterPasswordDataModel CreateValidModel(
|
||||
User user, KdfSettings kdfSettings, string orgSsoIdentifier, string? masterPasswordHint)
|
||||
{
|
||||
var salt = user.GetMasterPasswordSalt();
|
||||
return new SetInitialMasterPasswordDataModel
|
||||
{
|
||||
MasterPasswordAuthentication =
|
||||
new MasterPasswordAuthenticationData
|
||||
{
|
||||
Salt = salt,
|
||||
MasterPasswordAuthenticationHash = "hash",
|
||||
Kdf = kdfSettings
|
||||
},
|
||||
MasterPasswordUnlock =
|
||||
new MasterPasswordUnlockData
|
||||
{
|
||||
Salt = salt,
|
||||
MasterKeyWrappedUserKey = "wrapped-key",
|
||||
Kdf = kdfSettings
|
||||
},
|
||||
AccountKeys = null,
|
||||
OrgSsoIdentifier = orgSsoIdentifier,
|
||||
MasterPasswordHint = masterPasswordHint
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -214,6 +214,7 @@ If you believe you need to change the version for a valid reason, please discuss
|
||||
AllowAdminAccessToAllCollectionItems = true,
|
||||
UseOrganizationDomains = true,
|
||||
UseAdminSponsoredFamilies = false,
|
||||
UseDisableSmAdsForUsers = false,
|
||||
UsePhishingBlocker = false,
|
||||
};
|
||||
}
|
||||
@@ -260,4 +261,34 @@ If you believe you need to change the version for a valid reason, please discuss
|
||||
.Returns([0x00, 0x01, 0x02, 0x03]); // Dummy signature for hash testing
|
||||
return mockService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that UseDisableSmAdsForUsers claim is properly generated in the license Token
|
||||
/// and that VerifyData correctly validates the claim.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[BitAutoData(true)]
|
||||
[BitAutoData(false)]
|
||||
public void OrganizationLicense_UseDisableSmAdsForUsers_ClaimGenerationAndValidation(bool useDisableSmAdsForUsers, ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
// Arrange
|
||||
var organization = CreateDeterministicOrganization();
|
||||
organization.UseDisableSmAdsForUsers = useDisableSmAdsForUsers;
|
||||
|
||||
var subscriptionInfo = CreateDeterministicSubscriptionInfo();
|
||||
var installationId = new Guid("78900000-0000-0000-0000-000000000123");
|
||||
var mockLicensingService = CreateMockLicensingService();
|
||||
|
||||
var license = new OrganizationLicense(organization, subscriptionInfo, installationId, mockLicensingService);
|
||||
license.Expires = DateTime.MaxValue; // Prevent expiration during test
|
||||
|
||||
var globalSettings = Substitute.For<IGlobalSettings>();
|
||||
globalSettings.Installation.Returns(new GlobalSettings.InstallationSettings
|
||||
{
|
||||
Id = installationId
|
||||
});
|
||||
|
||||
// Act & Assert - Verify VerifyData passes with the UseDisableSmAdsForUsers value
|
||||
Assert.True(license.VerifyData(organization, claimsPrincipal, globalSettings));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ public class UpdateOrganizationLicenseCommandTests
|
||||
"Hash", "Signature", "SignatureBytes", "InstallationId", "Expires",
|
||||
"ExpirationWithoutGracePeriod", "Token", "LimitCollectionCreationDeletion",
|
||||
"LimitCollectionCreation", "LimitCollectionDeletion", "AllowAdminAccessToAllCollectionItems",
|
||||
"UseOrganizationDomains", "UseAdminSponsoredFamilies", "UseAutomaticUserConfirmation", "UsePhishingBlocker") &&
|
||||
"UseOrganizationDomains", "UseAdminSponsoredFamilies", "UseAutomaticUserConfirmation", "UsePhishingBlocker", "UseDisableSmAdsForUsers") &&
|
||||
// Same property but different name, use explicit mapping
|
||||
org.ExpirationDate == license.Expires));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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.Billing.Subscriptions.Models;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Services;
|
||||
@@ -29,6 +30,7 @@ namespace Bit.Core.Test.Billing.Premium.Commands;
|
||||
public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
{
|
||||
private readonly IBraintreeGateway _braintreeGateway = Substitute.For<IBraintreeGateway>();
|
||||
private readonly IBraintreeService _braintreeService = Substitute.For<IBraintreeService>();
|
||||
private readonly IGlobalSettings _globalSettings = Substitute.For<IGlobalSettings>();
|
||||
private readonly ISetupIntentCache _setupIntentCache = Substitute.For<ISetupIntentCache>();
|
||||
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
|
||||
@@ -59,6 +61,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
|
||||
_command = new CreatePremiumCloudHostedSubscriptionCommand(
|
||||
_braintreeGateway,
|
||||
_braintreeService,
|
||||
_globalSettings,
|
||||
_setupIntentCache,
|
||||
_stripeAdapter,
|
||||
@@ -235,11 +238,15 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
var mockCustomer = Substitute.For<StripeCustomer>();
|
||||
mockCustomer.Id = "cust_123";
|
||||
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
|
||||
mockCustomer.Metadata = new Dictionary<string, string>();
|
||||
mockCustomer.Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_customer_123"
|
||||
};
|
||||
|
||||
var mockSubscription = Substitute.For<StripeSubscription>();
|
||||
mockSubscription.Id = "sub_123";
|
||||
mockSubscription.Status = "active";
|
||||
mockSubscription.LatestInvoiceId = "in_123";
|
||||
|
||||
var mockInvoice = Substitute.For<Invoice>();
|
||||
|
||||
@@ -258,6 +265,9 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
await _stripeAdapter.Received(1).CreateCustomerAsync(Arg.Any<CustomerCreateOptions>());
|
||||
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
|
||||
await _subscriberService.Received(1).CreateBraintreeCustomer(user, paymentMethod.Token);
|
||||
await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId,
|
||||
Arg.Is<InvoiceUpdateOptions>(opts => opts.AutoAdvance == false));
|
||||
await _braintreeService.Received(1).PayInvoice(Arg.Any<SubscriberId>(), mockInvoice);
|
||||
await _userService.Received(1).SaveUserAsync(user);
|
||||
await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id);
|
||||
}
|
||||
@@ -456,11 +466,15 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
var mockCustomer = Substitute.For<StripeCustomer>();
|
||||
mockCustomer.Id = "cust_123";
|
||||
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
|
||||
mockCustomer.Metadata = new Dictionary<string, string>();
|
||||
mockCustomer.Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_customer_123"
|
||||
};
|
||||
|
||||
var mockSubscription = Substitute.For<StripeSubscription>();
|
||||
mockSubscription.Id = "sub_123";
|
||||
mockSubscription.Status = "incomplete";
|
||||
mockSubscription.LatestInvoiceId = "in_123";
|
||||
mockSubscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
@@ -487,6 +501,9 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
Assert.True(result.IsT0);
|
||||
Assert.True(user.Premium);
|
||||
Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate);
|
||||
await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId,
|
||||
Arg.Is<InvoiceUpdateOptions>(opts => opts.AutoAdvance == false));
|
||||
await _braintreeService.Received(1).PayInvoice(Arg.Any<SubscriberId>(), mockInvoice);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -559,11 +576,15 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
var mockCustomer = Substitute.For<StripeCustomer>();
|
||||
mockCustomer.Id = "cust_123";
|
||||
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
|
||||
mockCustomer.Metadata = new Dictionary<string, string>();
|
||||
mockCustomer.Metadata = new Dictionary<string, string>
|
||||
{
|
||||
[Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_customer_123"
|
||||
};
|
||||
|
||||
var mockSubscription = Substitute.For<StripeSubscription>();
|
||||
mockSubscription.Id = "sub_123";
|
||||
mockSubscription.Status = "active"; // PayPal + active doesn't match pattern
|
||||
mockSubscription.LatestInvoiceId = "in_123";
|
||||
mockSubscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
@@ -590,6 +611,9 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
||||
Assert.True(result.IsT0);
|
||||
Assert.False(user.Premium);
|
||||
Assert.Null(user.PremiumExpirationDate);
|
||||
await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId,
|
||||
Arg.Is<InvoiceUpdateOptions>(opts => opts.AutoAdvance == false));
|
||||
await _braintreeService.Received(1).PayInvoice(Arg.Any<SubscriberId>(), mockInvoice);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
|
||||
@@ -18,13 +18,11 @@ public class UpdatePremiumStorageCommandTests
|
||||
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
|
||||
private readonly IUserService _userService = Substitute.For<IUserService>();
|
||||
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
|
||||
private readonly PremiumPlan _premiumPlan;
|
||||
private readonly UpdatePremiumStorageCommand _command;
|
||||
|
||||
public UpdatePremiumStorageCommandTests()
|
||||
{
|
||||
// Setup default premium plan with standard pricing
|
||||
_premiumPlan = new PremiumPlan
|
||||
var premiumPlan = new PremiumPlan
|
||||
{
|
||||
Name = "Premium",
|
||||
Available = true,
|
||||
@@ -32,7 +30,7 @@ public class UpdatePremiumStorageCommandTests
|
||||
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = "price_premium", Provided = 1 },
|
||||
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "price_storage", Provided = 1 }
|
||||
};
|
||||
_pricingClient.ListPremiumPlans().Returns(new List<PremiumPlan> { _premiumPlan });
|
||||
_pricingClient.ListPremiumPlans().Returns([premiumPlan]);
|
||||
|
||||
_command = new UpdatePremiumStorageCommand(
|
||||
_stripeAdapter,
|
||||
@@ -43,18 +41,19 @@ public class UpdatePremiumStorageCommandTests
|
||||
|
||||
private Subscription CreateMockSubscription(string subscriptionId, int? storageQuantity = null)
|
||||
{
|
||||
var items = new List<SubscriptionItem>();
|
||||
|
||||
// Always add the seat item
|
||||
items.Add(new SubscriptionItem
|
||||
var items = new List<SubscriptionItem>
|
||||
{
|
||||
Id = "si_seat",
|
||||
Price = new Price { Id = "price_premium" },
|
||||
Quantity = 1
|
||||
});
|
||||
// Always add the seat item
|
||||
new()
|
||||
{
|
||||
Id = "si_seat",
|
||||
Price = new Price { Id = "price_premium" },
|
||||
Quantity = 1
|
||||
}
|
||||
};
|
||||
|
||||
// Add storage item if quantity is provided
|
||||
if (storageQuantity.HasValue && storageQuantity.Value > 0)
|
||||
if (storageQuantity is > 0)
|
||||
{
|
||||
items.Add(new SubscriptionItem
|
||||
{
|
||||
@@ -142,7 +141,7 @@ public class UpdatePremiumStorageCommandTests
|
||||
// Assert
|
||||
Assert.True(result.IsT1);
|
||||
var badRequest = result.AsT1;
|
||||
Assert.Equal("No access to storage.", badRequest.Response);
|
||||
Assert.Equal("User has no access to storage.", badRequest.Response);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
@@ -216,7 +215,7 @@ public class UpdatePremiumStorageCommandTests
|
||||
opts.Items.Count == 1 &&
|
||||
opts.Items[0].Id == "si_storage" &&
|
||||
opts.Items[0].Quantity == 9 &&
|
||||
opts.ProrationBehavior == "create_prorations"));
|
||||
opts.ProrationBehavior == "always_invoice"));
|
||||
|
||||
// Verify user was saved
|
||||
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u =>
|
||||
@@ -233,7 +232,7 @@ public class UpdatePremiumStorageCommandTests
|
||||
user.Storage = 500L * 1024 * 1024;
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
|
||||
var subscription = CreateMockSubscription("sub_123", null);
|
||||
var subscription = CreateMockSubscription("sub_123");
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
|
||||
|
||||
// Act
|
||||
|
||||
@@ -0,0 +1,646 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Premium.Commands;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
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 UpgradePremiumToOrganizationCommandTests
|
||||
{
|
||||
// Concrete test implementation of the abstract Plan record
|
||||
private record TestPlan : Core.Models.StaticStore.Plan
|
||||
{
|
||||
public TestPlan(
|
||||
PlanType planType,
|
||||
string? stripePlanId = null,
|
||||
string? stripeSeatPlanId = null,
|
||||
string? stripePremiumAccessPlanId = null,
|
||||
string? stripeStoragePlanId = null)
|
||||
{
|
||||
Type = planType;
|
||||
ProductTier = ProductTierType.Teams;
|
||||
Name = "Test Plan";
|
||||
IsAnnual = true;
|
||||
NameLocalizationKey = "";
|
||||
DescriptionLocalizationKey = "";
|
||||
CanBeUsedByBusiness = true;
|
||||
TrialPeriodDays = null;
|
||||
HasSelfHost = false;
|
||||
HasPolicies = false;
|
||||
HasGroups = false;
|
||||
HasDirectory = false;
|
||||
HasEvents = false;
|
||||
HasTotp = false;
|
||||
Has2fa = false;
|
||||
HasApi = false;
|
||||
HasSso = false;
|
||||
HasOrganizationDomains = false;
|
||||
HasKeyConnector = false;
|
||||
HasScim = false;
|
||||
HasResetPassword = false;
|
||||
UsersGetPremium = false;
|
||||
HasCustomPermissions = false;
|
||||
UpgradeSortOrder = 0;
|
||||
DisplaySortOrder = 0;
|
||||
LegacyYear = null;
|
||||
Disabled = false;
|
||||
PasswordManager = new PasswordManagerPlanFeatures
|
||||
{
|
||||
StripePlanId = stripePlanId,
|
||||
StripeSeatPlanId = stripeSeatPlanId,
|
||||
StripePremiumAccessPlanId = stripePremiumAccessPlanId,
|
||||
StripeStoragePlanId = stripeStoragePlanId,
|
||||
BasePrice = 0,
|
||||
SeatPrice = 0,
|
||||
ProviderPortalSeatPrice = 0,
|
||||
AllowSeatAutoscale = true,
|
||||
HasAdditionalSeatsOption = true,
|
||||
BaseSeats = 1,
|
||||
HasPremiumAccessOption = !string.IsNullOrEmpty(stripePremiumAccessPlanId),
|
||||
PremiumAccessOptionPrice = 0,
|
||||
MaxSeats = null,
|
||||
BaseStorageGb = 1,
|
||||
HasAdditionalStorageOption = !string.IsNullOrEmpty(stripeStoragePlanId),
|
||||
AdditionalStoragePricePerGb = 0,
|
||||
MaxCollections = null
|
||||
};
|
||||
SecretsManager = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Core.Models.StaticStore.Plan CreateTestPlan(
|
||||
PlanType planType,
|
||||
string? stripePlanId = null,
|
||||
string? stripeSeatPlanId = null,
|
||||
string? stripePremiumAccessPlanId = null,
|
||||
string? stripeStoragePlanId = null)
|
||||
{
|
||||
return new TestPlan(planType, stripePlanId, stripeSeatPlanId, stripePremiumAccessPlanId, stripeStoragePlanId);
|
||||
}
|
||||
|
||||
private static PremiumPlan CreateTestPremiumPlan(
|
||||
string seatPriceId = "premium-annually",
|
||||
string storagePriceId = "personal-storage-gb-annually",
|
||||
bool available = true)
|
||||
{
|
||||
return new PremiumPlan
|
||||
{
|
||||
Name = "Premium",
|
||||
LegacyYear = null,
|
||||
Available = available,
|
||||
Seat = new PremiumPurchasable
|
||||
{
|
||||
StripePriceId = seatPriceId,
|
||||
Price = 10m,
|
||||
Provided = 1
|
||||
},
|
||||
Storage = new PremiumPurchasable
|
||||
{
|
||||
StripePriceId = storagePriceId,
|
||||
Price = 4m,
|
||||
Provided = 1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static List<PremiumPlan> CreateTestPremiumPlansList()
|
||||
{
|
||||
return new List<PremiumPlan>
|
||||
{
|
||||
// Current available plan
|
||||
CreateTestPremiumPlan("premium-annually", "personal-storage-gb-annually", available: true),
|
||||
// Legacy plan from 2020
|
||||
CreateTestPremiumPlan("premium-annually-2020", "personal-storage-gb-annually-2020", available: false)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
|
||||
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
|
||||
private readonly IUserService _userService = Substitute.For<IUserService>();
|
||||
private readonly IOrganizationRepository _organizationRepository = Substitute.For<IOrganizationRepository>();
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
|
||||
private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository = Substitute.For<IOrganizationApiKeyRepository>();
|
||||
private readonly IApplicationCacheService _applicationCacheService = Substitute.For<IApplicationCacheService>();
|
||||
private readonly ILogger<UpgradePremiumToOrganizationCommand> _logger = Substitute.For<ILogger<UpgradePremiumToOrganizationCommand>>();
|
||||
private readonly UpgradePremiumToOrganizationCommand _command;
|
||||
|
||||
public UpgradePremiumToOrganizationCommandTests()
|
||||
{
|
||||
_command = new UpgradePremiumToOrganizationCommand(
|
||||
_logger,
|
||||
_pricingClient,
|
||||
_stripeAdapter,
|
||||
_userService,
|
||||
_organizationRepository,
|
||||
_organizationUserRepository,
|
||||
_organizationApiKeyRepository,
|
||||
_applicationCacheService);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_UserNotPremium_ReturnsBadRequest(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = false;
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT1);
|
||||
var badRequest = result.AsT1;
|
||||
Assert.Equal("User does not have an active Premium subscription.", badRequest.Response);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_UserNoGatewaySubscriptionId_ReturnsBadRequest(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = true;
|
||||
user.GatewaySubscriptionId = null;
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT1);
|
||||
var badRequest = result.AsT1;
|
||||
Assert.Equal("User does not have an active Premium subscription.", badRequest.Response);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_UserEmptyGatewaySubscriptionId_ReturnsBadRequest(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = true;
|
||||
user.GatewaySubscriptionId = "";
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT1);
|
||||
var badRequest = result.AsT1;
|
||||
Assert.Equal("User does not have an active Premium subscription.", badRequest.Response);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_SuccessfulUpgrade_SeatBasedPlan_ReturnsSuccess(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = true;
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
user.GatewayCustomerId = "cus_123";
|
||||
user.Id = Guid.NewGuid();
|
||||
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
|
||||
var mockSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_premium",
|
||||
Price = new Price { Id = "premium-annually" },
|
||||
CurrentPeriodEnd = currentPeriodEnd
|
||||
}
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var mockPremiumPlans = CreateTestPremiumPlansList();
|
||||
var mockPlan = CreateTestPlan(
|
||||
PlanType.TeamsAnnually,
|
||||
stripeSeatPlanId: "teams-seat-annually"
|
||||
);
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123")
|
||||
.Returns(mockSubscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
|
||||
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
|
||||
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(Task.FromResult(mockSubscription));
|
||||
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
|
||||
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
|
||||
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
|
||||
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).Returns(Task.CompletedTask);
|
||||
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
||||
"sub_123",
|
||||
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
||||
opts.Items.Count == 2 && // 1 deleted + 1 seat (no storage)
|
||||
opts.Items.Any(i => i.Deleted == true) &&
|
||||
opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1)));
|
||||
|
||||
await _organizationRepository.Received(1).CreateAsync(Arg.Is<Organization>(o =>
|
||||
o.Name == "My Organization" &&
|
||||
o.Gateway == GatewayType.Stripe &&
|
||||
o.GatewaySubscriptionId == "sub_123" &&
|
||||
o.GatewayCustomerId == "cus_123"));
|
||||
await _organizationUserRepository.Received(1).CreateAsync(Arg.Is<OrganizationUser>(ou =>
|
||||
ou.Key == "encrypted-key" &&
|
||||
ou.Status == OrganizationUserStatusType.Confirmed));
|
||||
await _organizationApiKeyRepository.Received(1).CreateAsync(Arg.Any<OrganizationApiKey>());
|
||||
|
||||
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u =>
|
||||
u.Premium == false &&
|
||||
u.GatewaySubscriptionId == null &&
|
||||
u.GatewayCustomerId == null));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_SuccessfulUpgrade_NonSeatBasedPlan_ReturnsSuccess(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = true;
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
user.GatewayCustomerId = "cus_123";
|
||||
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
|
||||
var mockSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_premium",
|
||||
Price = new Price { Id = "premium-annually" },
|
||||
CurrentPeriodEnd = currentPeriodEnd
|
||||
}
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var mockPremiumPlans = CreateTestPremiumPlansList();
|
||||
var mockPlan = CreateTestPlan(
|
||||
PlanType.FamiliesAnnually,
|
||||
stripePlanId: "families-plan-annually",
|
||||
stripeSeatPlanId: null // Non-seat-based
|
||||
);
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123")
|
||||
.Returns(mockSubscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
|
||||
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(mockPlan);
|
||||
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(Task.FromResult(mockSubscription));
|
||||
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
|
||||
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
|
||||
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
|
||||
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).Returns(Task.CompletedTask);
|
||||
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, "My Families Org", "encrypted-key", PlanType.FamiliesAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
||||
"sub_123",
|
||||
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
||||
opts.Items.Count == 2 && // 1 deleted + 1 plan
|
||||
opts.Items.Any(i => i.Deleted == true) &&
|
||||
opts.Items.Any(i => i.Price == "families-plan-annually" && i.Quantity == 1)));
|
||||
|
||||
await _organizationRepository.Received(1).CreateAsync(Arg.Is<Organization>(o =>
|
||||
o.Name == "My Families Org"));
|
||||
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u =>
|
||||
u.Premium == false &&
|
||||
u.GatewaySubscriptionId == null));
|
||||
}
|
||||
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_AddsMetadataWithOriginalPremiumPriceId(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = true;
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
|
||||
var mockSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_premium",
|
||||
Price = new Price { Id = "premium-annually" },
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1)
|
||||
}
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["userId"] = user.Id.ToString()
|
||||
}
|
||||
};
|
||||
|
||||
var mockPremiumPlans = CreateTestPremiumPlansList();
|
||||
var mockPlan = CreateTestPlan(
|
||||
PlanType.TeamsAnnually,
|
||||
stripeSeatPlanId: "teams-seat-annually"
|
||||
);
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123")
|
||||
.Returns(mockSubscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
|
||||
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
|
||||
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(Task.FromResult(mockSubscription));
|
||||
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
||||
"sub_123",
|
||||
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
||||
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.OrganizationId) &&
|
||||
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPremiumPriceId) &&
|
||||
opts.Metadata[StripeConstants.MetadataKeys.PreviousPremiumPriceId] == "premium-annually" &&
|
||||
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousPeriodEndDate) &&
|
||||
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) &&
|
||||
opts.Metadata[StripeConstants.MetadataKeys.PreviousAdditionalStorage] == "0" &&
|
||||
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.UserId) &&
|
||||
opts.Metadata[StripeConstants.MetadataKeys.UserId] == string.Empty)); // Removes userId to unlink from User
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_UserOnLegacyPremiumPlan_SuccessfullyDeletesLegacyItems(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = true;
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
user.GatewayCustomerId = "cus_123";
|
||||
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
|
||||
var mockSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_premium_legacy",
|
||||
Price = new Price { Id = "premium-annually-2020" }, // Legacy price ID
|
||||
CurrentPeriodEnd = currentPeriodEnd
|
||||
},
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_storage_legacy",
|
||||
Price = new Price { Id = "personal-storage-gb-annually-2020" }, // Legacy storage price ID
|
||||
CurrentPeriodEnd = currentPeriodEnd
|
||||
}
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var mockPremiumPlans = CreateTestPremiumPlansList();
|
||||
var mockPlan = CreateTestPlan(
|
||||
PlanType.TeamsAnnually,
|
||||
stripeSeatPlanId: "teams-seat-annually"
|
||||
);
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123")
|
||||
.Returns(mockSubscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
|
||||
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
|
||||
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(Task.FromResult(mockSubscription));
|
||||
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
|
||||
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
|
||||
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
|
||||
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).Returns(Task.CompletedTask);
|
||||
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
// Verify that BOTH legacy items (password manager + storage) are deleted by ID
|
||||
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
||||
"sub_123",
|
||||
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
||||
opts.Items.Count == 3 && // 2 deleted (legacy PM + legacy storage) + 1 new seat
|
||||
opts.Items.Count(i => i.Deleted == true && i.Id == "si_premium_legacy") == 1 && // Legacy PM deleted
|
||||
opts.Items.Count(i => i.Deleted == true && i.Id == "si_storage_legacy") == 1 && // Legacy storage deleted
|
||||
opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1)));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_UserHasPremiumPlusOtherProducts_OnlyDeletesPremiumItems(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = true;
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
user.GatewayCustomerId = "cus_123";
|
||||
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
|
||||
var mockSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_premium",
|
||||
Price = new Price { Id = "premium-annually" },
|
||||
CurrentPeriodEnd = currentPeriodEnd
|
||||
},
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_other_product",
|
||||
Price = new Price { Id = "some-other-product-id" }, // Non-premium item
|
||||
CurrentPeriodEnd = currentPeriodEnd
|
||||
}
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var mockPremiumPlans = CreateTestPremiumPlansList();
|
||||
var mockPlan = CreateTestPlan(
|
||||
PlanType.TeamsAnnually,
|
||||
stripeSeatPlanId: "teams-seat-annually"
|
||||
);
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123")
|
||||
.Returns(mockSubscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
|
||||
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
|
||||
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(Task.FromResult(mockSubscription));
|
||||
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
|
||||
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
|
||||
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
|
||||
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).Returns(Task.CompletedTask);
|
||||
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
// Verify that ONLY the premium password manager item is deleted (not other products)
|
||||
// Note: We delete the specific premium item by ID, so other products are untouched
|
||||
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
||||
"sub_123",
|
||||
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
||||
opts.Items.Count == 2 && // 1 deleted (premium password manager) + 1 new seat
|
||||
opts.Items.Count(i => i.Deleted == true && i.Id == "si_premium") == 1 && // Premium item deleted by ID
|
||||
opts.Items.Count(i => i.Id == "si_other_product") == 0 && // Other product NOT in update (untouched)
|
||||
opts.Items.Any(i => i.Price == "teams-seat-annually" && i.Quantity == 1)));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_UserHasAdditionalStorage_CapturesStorageInMetadata(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = true;
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
user.GatewayCustomerId = "cus_123";
|
||||
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
|
||||
var mockSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_premium",
|
||||
Price = new Price { Id = "premium-annually" },
|
||||
CurrentPeriodEnd = currentPeriodEnd
|
||||
},
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_storage",
|
||||
Price = new Price { Id = "personal-storage-gb-annually" },
|
||||
Quantity = 5, // User has 5GB additional storage
|
||||
CurrentPeriodEnd = currentPeriodEnd
|
||||
}
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var mockPremiumPlans = CreateTestPremiumPlansList();
|
||||
var mockPlan = CreateTestPlan(
|
||||
PlanType.TeamsAnnually,
|
||||
stripeSeatPlanId: "teams-seat-annually"
|
||||
);
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123")
|
||||
.Returns(mockSubscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
|
||||
_pricingClient.GetPlanOrThrow(PlanType.TeamsAnnually).Returns(mockPlan);
|
||||
_stripeAdapter.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.Returns(Task.FromResult(mockSubscription));
|
||||
_organizationRepository.CreateAsync(Arg.Any<Organization>()).Returns(callInfo => Task.FromResult(callInfo.Arg<Organization>()));
|
||||
_organizationApiKeyRepository.CreateAsync(Arg.Any<OrganizationApiKey>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationApiKey>()));
|
||||
_organizationUserRepository.CreateAsync(Arg.Any<OrganizationUser>()).Returns(callInfo => Task.FromResult(callInfo.Arg<OrganizationUser>()));
|
||||
_applicationCacheService.UpsertOrganizationAbilityAsync(Arg.Any<Organization>()).Returns(Task.CompletedTask);
|
||||
_userService.SaveUserAsync(user).Returns(Task.CompletedTask);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT0);
|
||||
|
||||
// Verify that the additional storage quantity (5) is captured in metadata
|
||||
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
|
||||
"sub_123",
|
||||
Arg.Is<SubscriptionUpdateOptions>(opts =>
|
||||
opts.Metadata.ContainsKey(StripeConstants.MetadataKeys.PreviousAdditionalStorage) &&
|
||||
opts.Metadata[StripeConstants.MetadataKeys.PreviousAdditionalStorage] == "5" &&
|
||||
opts.Items.Count == 3 && // 2 deleted (premium + storage) + 1 new seat
|
||||
opts.Items.Count(i => i.Deleted == true) == 2));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task Run_NoPremiumSubscriptionItemFound_ReturnsBadRequest(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Premium = true;
|
||||
user.GatewaySubscriptionId = "sub_123";
|
||||
|
||||
var mockSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_123",
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new SubscriptionItem
|
||||
{
|
||||
Id = "si_other",
|
||||
Price = new Price { Id = "some-other-product" }, // Not a premium plan
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1)
|
||||
}
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var mockPremiumPlans = CreateTestPremiumPlansList();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync("sub_123")
|
||||
.Returns(mockSubscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(mockPremiumPlans);
|
||||
|
||||
// Act
|
||||
var result = await _command.Run(user, "My Organization", "encrypted-key", PlanType.TeamsAnnually);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsT1);
|
||||
var badRequest = result.AsT1;
|
||||
Assert.Equal("Premium subscription item not found.", badRequest.Response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Subscriptions.Models;
|
||||
using Bit.Core.Billing.Subscriptions.Queries;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Exceptions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Billing.Subscriptions.Queries;
|
||||
|
||||
using static StripeConstants;
|
||||
|
||||
public class GetBitwardenSubscriptionQueryTests
|
||||
{
|
||||
private readonly ILogger<GetBitwardenSubscriptionQuery> _logger = Substitute.For<ILogger<GetBitwardenSubscriptionQuery>>();
|
||||
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
|
||||
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
|
||||
private readonly GetBitwardenSubscriptionQuery _query;
|
||||
|
||||
public GetBitwardenSubscriptionQueryTests()
|
||||
{
|
||||
_query = new GetBitwardenSubscriptionQuery(
|
||||
_logger,
|
||||
_pricingClient,
|
||||
_stripeAdapter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_IncompleteStatus_ReturnsBitwardenSubscriptionWithSuspension()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Incomplete);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(SubscriptionStatus.Incomplete, result.Status);
|
||||
Assert.NotNull(result.Suspension);
|
||||
Assert.Equal(subscription.Created.AddHours(23), result.Suspension);
|
||||
Assert.Equal(1, result.GracePeriod);
|
||||
Assert.Null(result.NextCharge);
|
||||
Assert.Null(result.CancelAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_IncompleteExpiredStatus_ReturnsBitwardenSubscriptionWithSuspension()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.IncompleteExpired);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(SubscriptionStatus.IncompleteExpired, result.Status);
|
||||
Assert.NotNull(result.Suspension);
|
||||
Assert.Equal(subscription.Created.AddHours(23), result.Suspension);
|
||||
Assert.Equal(1, result.GracePeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_TrialingStatus_ReturnsBitwardenSubscriptionWithNextCharge()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Trialing);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(SubscriptionStatus.Trialing, result.Status);
|
||||
Assert.NotNull(result.NextCharge);
|
||||
Assert.Equal(subscription.Items.First().CurrentPeriodEnd, result.NextCharge);
|
||||
Assert.Null(result.Suspension);
|
||||
Assert.Null(result.GracePeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_ActiveStatus_ReturnsBitwardenSubscriptionWithNextCharge()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(SubscriptionStatus.Active, result.Status);
|
||||
Assert.NotNull(result.NextCharge);
|
||||
Assert.Equal(subscription.Items.First().CurrentPeriodEnd, result.NextCharge);
|
||||
Assert.Null(result.Suspension);
|
||||
Assert.Null(result.GracePeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_ActiveStatusWithCancelAt_ReturnsCancelAt()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var cancelAt = DateTime.UtcNow.AddMonths(1);
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active, cancelAt: cancelAt);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(SubscriptionStatus.Active, result.Status);
|
||||
Assert.Equal(cancelAt, result.CancelAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_PastDueStatus_WithOpenInvoices_ReturnsSuspension()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.PastDue, collectionMethod: "charge_automatically");
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
var openInvoice = CreateInvoice();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
_stripeAdapter.SearchInvoiceAsync(Arg.Any<InvoiceSearchOptions>())
|
||||
.Returns([openInvoice]);
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(SubscriptionStatus.PastDue, result.Status);
|
||||
Assert.NotNull(result.Suspension);
|
||||
Assert.Equal(openInvoice.Created.AddDays(14), result.Suspension);
|
||||
Assert.Equal(14, result.GracePeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_PastDueStatus_WithoutOpenInvoices_ReturnsNoSuspension()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.PastDue);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
_stripeAdapter.SearchInvoiceAsync(Arg.Any<InvoiceSearchOptions>())
|
||||
.Returns([]);
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(SubscriptionStatus.PastDue, result.Status);
|
||||
Assert.Null(result.Suspension);
|
||||
Assert.Null(result.GracePeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_UnpaidStatus_WithOpenInvoices_ReturnsSuspension()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Unpaid, collectionMethod: "charge_automatically");
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
var openInvoice = CreateInvoice();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
_stripeAdapter.SearchInvoiceAsync(Arg.Any<InvoiceSearchOptions>())
|
||||
.Returns([openInvoice]);
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(SubscriptionStatus.Unpaid, result.Status);
|
||||
Assert.NotNull(result.Suspension);
|
||||
Assert.Equal(14, result.GracePeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_CanceledStatus_ReturnsCanceledDate()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var canceledAt = DateTime.UtcNow.AddDays(-5);
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Canceled, canceledAt: canceledAt);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(SubscriptionStatus.Canceled, result.Status);
|
||||
Assert.Equal(canceledAt, result.Canceled);
|
||||
Assert.Null(result.Suspension);
|
||||
Assert.Null(result.NextCharge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_UnmanagedStatus_ThrowsConflictException()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription("unmanaged_status");
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
await Assert.ThrowsAsync<ConflictException>(() => _query.Run(user));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_WithAdditionalStorage_IncludesStorageInCart()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active, includeStorage: true);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Cart.PasswordManager.AdditionalStorage);
|
||||
Assert.Equal("additionalStorageGB", result.Cart.PasswordManager.AdditionalStorage.TranslationKey);
|
||||
Assert.Equal(2, result.Cart.PasswordManager.AdditionalStorage.Quantity);
|
||||
Assert.NotNull(result.Storage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_WithoutAdditionalStorage_ExcludesStorageFromCart()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active, includeStorage: false);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Null(result.Cart.PasswordManager.AdditionalStorage);
|
||||
Assert.NotNull(result.Storage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_WithCartLevelDiscount_IncludesDiscountInCart()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active);
|
||||
subscription.Customer.Discount = CreateDiscount(discountType: "cart");
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Cart.Discount);
|
||||
Assert.Equal(BitwardenDiscountType.PercentOff, result.Cart.Discount.Type);
|
||||
Assert.Equal(20, result.Cart.Discount.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_WithProductLevelDiscount_IncludesDiscountInCartItem()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active);
|
||||
var productDiscount = CreateDiscount(discountType: "product", productId: "prod_premium_seat");
|
||||
subscription.Discounts = [productDiscount];
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Cart.PasswordManager.Seats.Discount);
|
||||
Assert.Equal(BitwardenDiscountType.PercentOff, result.Cart.PasswordManager.Seats.Discount.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_WithoutMaxStorageGb_ReturnsNullStorage()
|
||||
{
|
||||
var user = CreateUser();
|
||||
user.MaxStorageGb = null;
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Null(result.Storage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_CalculatesStorageCorrectly()
|
||||
{
|
||||
var user = CreateUser();
|
||||
user.Storage = 5368709120; // 5 GB in bytes
|
||||
user.MaxStorageGb = 10;
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active, includeStorage: true);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Storage);
|
||||
Assert.Equal(10, result.Storage.Available);
|
||||
Assert.Equal(5.0, result.Storage.Used);
|
||||
Assert.NotEmpty(result.Storage.ReadableUsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_TaxEstimation_WithInvoiceUpcomingNoneError_ReturnsZeroTax()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.ThrowsAsync(new StripeException { StripeError = new StripeError { Code = ErrorCodes.InvoiceUpcomingNone } });
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(0, result.Cart.EstimatedTax);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_MissingPasswordManagerSeatsItem_ThrowsConflictException()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active);
|
||||
subscription.Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = []
|
||||
};
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
|
||||
await Assert.ThrowsAsync<ConflictException>(() => _query.Run(user));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_IncludesEstimatedTax()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
var invoice = CreateInvoicePreview(totalTax: 500); // $5.00 tax
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(invoice);
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(5.0m, result.Cart.EstimatedTax);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_SetsCadenceToAnnually()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active);
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
|
||||
var result = await _query.Run(user);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(PlanCadenceType.Annually, result.Cart.Cadence);
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static User CreateUser()
|
||||
{
|
||||
return new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GatewaySubscriptionId = "sub_test123",
|
||||
MaxStorageGb = 1,
|
||||
Storage = 1073741824 // 1 GB in bytes
|
||||
};
|
||||
}
|
||||
|
||||
private static Subscription CreateSubscription(
|
||||
string status,
|
||||
bool includeStorage = false,
|
||||
DateTime? cancelAt = null,
|
||||
DateTime? canceledAt = null,
|
||||
string collectionMethod = "charge_automatically")
|
||||
{
|
||||
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
|
||||
var items = new List<SubscriptionItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "si_premium_seat",
|
||||
Price = new Price
|
||||
{
|
||||
Id = "price_premium_seat",
|
||||
UnitAmountDecimal = 1000,
|
||||
Product = new Product { Id = "prod_premium_seat" }
|
||||
},
|
||||
Quantity = 1,
|
||||
CurrentPeriodStart = DateTime.UtcNow,
|
||||
CurrentPeriodEnd = currentPeriodEnd
|
||||
}
|
||||
};
|
||||
|
||||
if (includeStorage)
|
||||
{
|
||||
items.Add(new SubscriptionItem
|
||||
{
|
||||
Id = "si_storage",
|
||||
Price = new Price
|
||||
{
|
||||
Id = "price_storage",
|
||||
UnitAmountDecimal = 400,
|
||||
Product = new Product { Id = "prod_storage" }
|
||||
},
|
||||
Quantity = 2,
|
||||
CurrentPeriodStart = DateTime.UtcNow,
|
||||
CurrentPeriodEnd = currentPeriodEnd
|
||||
});
|
||||
}
|
||||
|
||||
return new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = status,
|
||||
Created = DateTime.UtcNow.AddMonths(-1),
|
||||
Customer = new Customer
|
||||
{
|
||||
Id = "cus_test123",
|
||||
Discount = null
|
||||
},
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = items
|
||||
},
|
||||
CancelAt = cancelAt,
|
||||
CanceledAt = canceledAt,
|
||||
CollectionMethod = collectionMethod,
|
||||
Discounts = []
|
||||
};
|
||||
}
|
||||
|
||||
private static List<Bit.Core.Billing.Pricing.Premium.Plan> CreatePremiumPlans()
|
||||
{
|
||||
return
|
||||
[
|
||||
new()
|
||||
{
|
||||
Name = "Premium",
|
||||
Available = true,
|
||||
Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable
|
||||
{
|
||||
StripePriceId = "price_premium_seat",
|
||||
Price = 10.0m,
|
||||
Provided = 1
|
||||
},
|
||||
Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable
|
||||
{
|
||||
StripePriceId = "price_storage",
|
||||
Price = 4.0m,
|
||||
Provided = 1
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
private static Invoice CreateInvoice()
|
||||
{
|
||||
return new Invoice
|
||||
{
|
||||
Id = "in_test123",
|
||||
Created = DateTime.UtcNow.AddDays(-10),
|
||||
PeriodEnd = DateTime.UtcNow.AddDays(-5),
|
||||
Attempted = true,
|
||||
Status = "open"
|
||||
};
|
||||
}
|
||||
|
||||
private static Invoice CreateInvoicePreview(long totalTax = 0)
|
||||
{
|
||||
var taxes = totalTax > 0
|
||||
? new List<InvoiceTotalTax> { new() { Amount = totalTax } }
|
||||
: new List<InvoiceTotalTax>();
|
||||
|
||||
return new Invoice
|
||||
{
|
||||
Id = "in_preview",
|
||||
TotalTaxes = taxes
|
||||
};
|
||||
}
|
||||
|
||||
private static Discount CreateDiscount(string discountType = "cart", string? productId = null)
|
||||
{
|
||||
var coupon = new Coupon
|
||||
{
|
||||
Valid = true,
|
||||
PercentOff = 20,
|
||||
AppliesTo = discountType == "product" && productId != null
|
||||
? new CouponAppliesTo { Products = [productId] }
|
||||
: new CouponAppliesTo { Products = [] }
|
||||
};
|
||||
|
||||
return new Discount
|
||||
{
|
||||
Coupon = coupon
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -9,5 +9,5 @@ public class TestMailView : BaseMailView
|
||||
|
||||
public class TestMail : BaseMail<TestMailView>
|
||||
{
|
||||
public override string Subject { get; } = "Test Email";
|
||||
public override string Subject { get; set; } = "Test Email";
|
||||
}
|
||||
|
||||
219
test/Core.Test/Utilities/EnumMemberJsonConverterTests.cs
Normal file
219
test/Core.Test/Utilities/EnumMemberJsonConverterTests.cs
Normal file
@@ -0,0 +1,219 @@
|
||||
using System.Runtime.Serialization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Core.Utilities;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Utilities;
|
||||
|
||||
public class EnumMemberJsonConverterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Serialize_WithEnumMemberAttribute_UsesAttributeValue()
|
||||
{
|
||||
// Arrange
|
||||
var obj = new EnumConverterTestObject
|
||||
{
|
||||
Status = EnumConverterTestStatus.InProgress
|
||||
};
|
||||
const string expectedJsonString = "{\"Status\":\"in_progress\"}";
|
||||
|
||||
// Act
|
||||
var jsonString = JsonSerializer.Serialize(obj);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedJsonString, jsonString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_WithoutEnumMemberAttribute_UsesEnumName()
|
||||
{
|
||||
// Arrange
|
||||
var obj = new EnumConverterTestObject
|
||||
{
|
||||
Status = EnumConverterTestStatus.Pending
|
||||
};
|
||||
const string expectedJsonString = "{\"Status\":\"Pending\"}";
|
||||
|
||||
// Act
|
||||
var jsonString = JsonSerializer.Serialize(obj);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedJsonString, jsonString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_MultipleValues_SerializesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var obj = new EnumConverterTestObjectWithMultiple
|
||||
{
|
||||
Status1 = EnumConverterTestStatus.Active,
|
||||
Status2 = EnumConverterTestStatus.InProgress,
|
||||
Status3 = EnumConverterTestStatus.Pending
|
||||
};
|
||||
const string expectedJsonString = "{\"Status1\":\"active\",\"Status2\":\"in_progress\",\"Status3\":\"Pending\"}";
|
||||
|
||||
// Act
|
||||
var jsonString = JsonSerializer.Serialize(obj);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedJsonString, jsonString);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_WithEnumMemberAttribute_ReturnsCorrectEnumValue()
|
||||
{
|
||||
// Arrange
|
||||
const string json = "{\"Status\":\"in_progress\"}";
|
||||
|
||||
// Act
|
||||
var obj = JsonSerializer.Deserialize<EnumConverterTestObject>(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(EnumConverterTestStatus.InProgress, obj.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_WithoutEnumMemberAttribute_ReturnsCorrectEnumValue()
|
||||
{
|
||||
// Arrange
|
||||
const string json = "{\"Status\":\"Pending\"}";
|
||||
|
||||
// Act
|
||||
var obj = JsonSerializer.Deserialize<EnumConverterTestObject>(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(EnumConverterTestStatus.Pending, obj.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_MultipleValues_DeserializesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
const string json = "{\"Status1\":\"active\",\"Status2\":\"in_progress\",\"Status3\":\"Pending\"}";
|
||||
|
||||
// Act
|
||||
var obj = JsonSerializer.Deserialize<EnumConverterTestObjectWithMultiple>(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(EnumConverterTestStatus.Active, obj.Status1);
|
||||
Assert.Equal(EnumConverterTestStatus.InProgress, obj.Status2);
|
||||
Assert.Equal(EnumConverterTestStatus.Pending, obj.Status3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_InvalidEnumString_ThrowsJsonException()
|
||||
{
|
||||
// Arrange
|
||||
const string json = "{\"Status\":\"invalid_value\"}";
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<EnumConverterTestObject>(json));
|
||||
Assert.Contains("Unable to convert 'invalid_value' to EnumConverterTestStatus", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_EmptyString_ThrowsJsonException()
|
||||
{
|
||||
// Arrange
|
||||
const string json = "{\"Status\":\"\"}";
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<EnumConverterTestObject>(json));
|
||||
Assert.Contains("Unable to convert '' to EnumConverterTestStatus", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_WithEnumMemberAttribute_PreservesValue()
|
||||
{
|
||||
// Arrange
|
||||
var originalObj = new EnumConverterTestObject
|
||||
{
|
||||
Status = EnumConverterTestStatus.Completed
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(originalObj);
|
||||
var deserializedObj = JsonSerializer.Deserialize<EnumConverterTestObject>(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(originalObj.Status, deserializedObj.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_WithoutEnumMemberAttribute_PreservesValue()
|
||||
{
|
||||
// Arrange
|
||||
var originalObj = new EnumConverterTestObject
|
||||
{
|
||||
Status = EnumConverterTestStatus.Pending
|
||||
};
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(originalObj);
|
||||
var deserializedObj = JsonSerializer.Deserialize<EnumConverterTestObject>(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(originalObj.Status, deserializedObj.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_AllEnumValues_ProducesExpectedStrings()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.Equal("\"Pending\"", JsonSerializer.Serialize(EnumConverterTestStatus.Pending, CreateOptions()));
|
||||
Assert.Equal("\"active\"", JsonSerializer.Serialize(EnumConverterTestStatus.Active, CreateOptions()));
|
||||
Assert.Equal("\"in_progress\"", JsonSerializer.Serialize(EnumConverterTestStatus.InProgress, CreateOptions()));
|
||||
Assert.Equal("\"completed\"", JsonSerializer.Serialize(EnumConverterTestStatus.Completed, CreateOptions()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_AllEnumValues_ReturnsCorrectEnums()
|
||||
{
|
||||
// Arrange & Act & Assert
|
||||
Assert.Equal(EnumConverterTestStatus.Pending, JsonSerializer.Deserialize<EnumConverterTestStatus>("\"Pending\"", CreateOptions()));
|
||||
Assert.Equal(EnumConverterTestStatus.Active, JsonSerializer.Deserialize<EnumConverterTestStatus>("\"active\"", CreateOptions()));
|
||||
Assert.Equal(EnumConverterTestStatus.InProgress, JsonSerializer.Deserialize<EnumConverterTestStatus>("\"in_progress\"", CreateOptions()));
|
||||
Assert.Equal(EnumConverterTestStatus.Completed, JsonSerializer.Deserialize<EnumConverterTestStatus>("\"completed\"", CreateOptions()));
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions CreateOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions();
|
||||
options.Converters.Add(new EnumMemberJsonConverter<EnumConverterTestStatus>());
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
public class EnumConverterTestObject
|
||||
{
|
||||
[JsonConverter(typeof(EnumMemberJsonConverter<EnumConverterTestStatus>))]
|
||||
public EnumConverterTestStatus Status { get; set; }
|
||||
}
|
||||
|
||||
public class EnumConverterTestObjectWithMultiple
|
||||
{
|
||||
[JsonConverter(typeof(EnumMemberJsonConverter<EnumConverterTestStatus>))]
|
||||
public EnumConverterTestStatus Status1 { get; set; }
|
||||
|
||||
[JsonConverter(typeof(EnumMemberJsonConverter<EnumConverterTestStatus>))]
|
||||
public EnumConverterTestStatus Status2 { get; set; }
|
||||
|
||||
[JsonConverter(typeof(EnumMemberJsonConverter<EnumConverterTestStatus>))]
|
||||
public EnumConverterTestStatus Status3 { get; set; }
|
||||
}
|
||||
|
||||
public enum EnumConverterTestStatus
|
||||
{
|
||||
Pending, // No EnumMemberAttribute
|
||||
|
||||
[EnumMember(Value = "active")]
|
||||
Active,
|
||||
|
||||
[EnumMember(Value = "in_progress")]
|
||||
InProgress,
|
||||
|
||||
[EnumMember(Value = "completed")]
|
||||
Completed
|
||||
}
|
||||
@@ -12,7 +12,6 @@ internal class OrganizationCipher : ICustomization
|
||||
{
|
||||
fixture.Customize<Cipher>(composer => composer
|
||||
.With(c => c.OrganizationId, OrganizationId ?? Guid.NewGuid())
|
||||
.Without(c => c.ArchivedDate)
|
||||
.Without(c => c.UserId));
|
||||
fixture.Customize<CipherDetails>(composer => composer
|
||||
.With(c => c.OrganizationId, Guid.NewGuid())
|
||||
@@ -28,7 +27,6 @@ internal class UserCipher : ICustomization
|
||||
{
|
||||
fixture.Customize<Cipher>(composer => composer
|
||||
.With(c => c.UserId, UserId ?? Guid.NewGuid())
|
||||
.Without(c => c.ArchivedDate)
|
||||
.Without(c => c.OrganizationId));
|
||||
fixture.Customize<CipherDetails>(composer => composer
|
||||
.With(c => c.UserId, Guid.NewGuid())
|
||||
|
||||
@@ -16,16 +16,15 @@ namespace Bit.Core.Test.Vault.Commands;
|
||||
public class ArchiveCiphersCommandTest
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData(true, false, 1, 1, 1)]
|
||||
[BitAutoData(false, false, 1, 0, 1)]
|
||||
[BitAutoData(false, true, 1, 0, 1)]
|
||||
[BitAutoData(true, true, 1, 0, 1)]
|
||||
public async Task ArchiveAsync_Works(
|
||||
bool isEditable, bool hasOrganizationId,
|
||||
[BitAutoData(true, 1, 1, 1)]
|
||||
[BitAutoData(false, 1, 0, 1)]
|
||||
[BitAutoData(false, 1, 0, 1)]
|
||||
[BitAutoData(true, 1, 0, 1)]
|
||||
public async Task ArchiveManyAsync_Works(
|
||||
bool hasOrganizationId,
|
||||
int cipherRepoCalls, int resultCountFromQuery, int pushNotificationsCalls,
|
||||
SutProvider<ArchiveCiphersCommand> sutProvider, CipherDetails cipher, User user)
|
||||
{
|
||||
cipher.Edit = isEditable;
|
||||
cipher.OrganizationId = hasOrganizationId ? Guid.NewGuid() : null;
|
||||
|
||||
var cipherList = new List<CipherDetails> { cipher };
|
||||
@@ -46,4 +45,33 @@ public class ArchiveCiphersCommandTest
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(pushNotificationsCalls)
|
||||
.PushSyncCiphersAsync(user.Id);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ArchiveManyAsync_SetsArchivedDateOnReturnedCiphers(
|
||||
SutProvider<ArchiveCiphersCommand> sutProvider,
|
||||
CipherDetails cipher,
|
||||
User user)
|
||||
{
|
||||
// Allow organization cipher to be archived in this test
|
||||
cipher.OrganizationId = Guid.Parse("3f2504e0-4f89-11d3-9a0c-0305e82c3301");
|
||||
|
||||
sutProvider.GetDependency<ICipherRepository>()
|
||||
.GetManyByUserIdAsync(user.Id)
|
||||
.Returns(new List<CipherDetails> { cipher });
|
||||
|
||||
var repoRevisionDate = DateTime.UtcNow;
|
||||
|
||||
sutProvider.GetDependency<ICipherRepository>()
|
||||
.ArchiveAsync(Arg.Any<IEnumerable<Guid>>(), user.Id)
|
||||
.Returns(repoRevisionDate);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ArchiveManyAsync(new[] { cipher.Id }, user.Id);
|
||||
|
||||
// Assert
|
||||
var archivedCipher = Assert.Single(result);
|
||||
Assert.Equal(repoRevisionDate, archivedCipher.RevisionDate);
|
||||
Assert.Equal(repoRevisionDate, archivedCipher.ArchivedDate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,16 +16,15 @@ namespace Bit.Core.Test.Vault.Commands;
|
||||
public class UnarchiveCiphersCommandTest
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData(true, false, 1, 1, 1)]
|
||||
[BitAutoData(false, false, 1, 0, 1)]
|
||||
[BitAutoData(false, true, 1, 0, 1)]
|
||||
[BitAutoData(true, true, 1, 1, 1)]
|
||||
[BitAutoData(true, 1, 1, 1)]
|
||||
[BitAutoData(false, 1, 0, 1)]
|
||||
[BitAutoData(false, 1, 0, 1)]
|
||||
[BitAutoData(true, 1, 1, 1)]
|
||||
public async Task UnarchiveAsync_Works(
|
||||
bool isEditable, bool hasOrganizationId,
|
||||
bool hasOrganizationId,
|
||||
int cipherRepoCalls, int resultCountFromQuery, int pushNotificationsCalls,
|
||||
SutProvider<UnarchiveCiphersCommand> sutProvider, CipherDetails cipher, User user)
|
||||
{
|
||||
cipher.Edit = isEditable;
|
||||
cipher.OrganizationId = hasOrganizationId ? Guid.NewGuid() : null;
|
||||
|
||||
var cipherList = new List<CipherDetails> { cipher };
|
||||
@@ -46,4 +45,33 @@ public class UnarchiveCiphersCommandTest
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(pushNotificationsCalls)
|
||||
.PushSyncCiphersAsync(user.Id);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task UnarchiveAsync_ClearsArchivedDateOnReturnedCiphers(
|
||||
SutProvider<UnarchiveCiphersCommand> sutProvider,
|
||||
CipherDetails cipher,
|
||||
User user)
|
||||
{
|
||||
cipher.OrganizationId = null;
|
||||
cipher.ArchivedDate = DateTime.UtcNow;
|
||||
|
||||
sutProvider.GetDependency<ICipherRepository>()
|
||||
.GetManyByUserIdAsync(user.Id)
|
||||
.Returns(new List<CipherDetails> { cipher });
|
||||
|
||||
var repoRevisionDate = DateTime.UtcNow.AddMinutes(1);
|
||||
|
||||
sutProvider.GetDependency<ICipherRepository>()
|
||||
.UnarchiveAsync(Arg.Any<IEnumerable<Guid>>(), user.Id)
|
||||
.Returns(repoRevisionDate);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.UnarchiveManyAsync(new[] { cipher.Id }, user.Id);
|
||||
|
||||
// Assert
|
||||
var unarchivedCipher = Assert.Single(result);
|
||||
Assert.Equal(repoRevisionDate, unarchivedCipher.RevisionDate);
|
||||
Assert.Null(unarchivedCipher.ArchivedDate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models.Data;
|
||||
using Bit.Core.Context;
|
||||
@@ -7,7 +6,6 @@ using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Identity.IdentityServer;
|
||||
using Bit.Identity.Test.AutoFixture;
|
||||
using Bit.Identity.Utilities;
|
||||
@@ -25,7 +23,6 @@ public class UserDecryptionOptionsBuilderTests
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly ILoginApprovingClientTypes _loginApprovingClientTypes;
|
||||
private readonly UserDecryptionOptionsBuilder _builder;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public UserDecryptionOptionsBuilderTests()
|
||||
{
|
||||
@@ -33,8 +30,7 @@ public class UserDecryptionOptionsBuilderTests
|
||||
_deviceRepository = Substitute.For<IDeviceRepository>();
|
||||
_organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
|
||||
_loginApprovingClientTypes = Substitute.For<ILoginApprovingClientTypes>();
|
||||
_featureService = Substitute.For<IFeatureService>();
|
||||
_builder = new UserDecryptionOptionsBuilder(_currentContext, _deviceRepository, _organizationUserRepository, _loginApprovingClientTypes, _featureService);
|
||||
_builder = new UserDecryptionOptionsBuilder(_currentContext, _deviceRepository, _organizationUserRepository, _loginApprovingClientTypes);
|
||||
var user = new User();
|
||||
_builder.ForUser(user);
|
||||
}
|
||||
@@ -227,43 +223,6 @@ public class UserDecryptionOptionsBuilderTests
|
||||
Assert.False(result.TrustedDeviceOption?.HasLoginApprovingDevice);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This logic has been flagged as part of PM-23174.
|
||||
/// When removing the server flag, please also remove this test, and remove the FeatureService
|
||||
/// dependency from this suite and the following test.
|
||||
/// </summary>
|
||||
/// <param name="organizationUserType"></param>
|
||||
/// <param name="ssoConfig"></param>
|
||||
/// <param name="configurationData"></param>
|
||||
/// <param name="organization"></param>
|
||||
/// <param name="organizationUser"></param>
|
||||
/// <param name="user"></param>
|
||||
[Theory]
|
||||
[BitAutoData(OrganizationUserType.Custom)]
|
||||
public async Task Build_WhenManageResetPasswordPermissions_ShouldReturnHasManageResetPasswordPermissionTrue(
|
||||
OrganizationUserType organizationUserType,
|
||||
SsoConfig ssoConfig,
|
||||
SsoConfigurationData configurationData,
|
||||
CurrentContextOrganization organization,
|
||||
[OrganizationUserWithDefaultPermissions] OrganizationUser organizationUser,
|
||||
User user)
|
||||
{
|
||||
configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption;
|
||||
ssoConfig.Data = configurationData.Serialize();
|
||||
ssoConfig.OrganizationId = organization.Id;
|
||||
_currentContext.Organizations.Returns([organization]);
|
||||
_currentContext.ManageResetPassword(organization.Id).Returns(true);
|
||||
organizationUser.Type = organizationUserType;
|
||||
organizationUser.OrganizationId = organization.Id;
|
||||
organizationUser.UserId = user.Id;
|
||||
organizationUser.SetPermissions(new Permissions() { ManageResetPassword = true });
|
||||
_organizationUserRepository.GetByOrganizationAsync(ssoConfig.OrganizationId, user.Id).Returns(organizationUser);
|
||||
|
||||
var result = await _builder.ForUser(user).WithSso(ssoConfig).BuildAsync();
|
||||
|
||||
Assert.True(result.TrustedDeviceOption?.HasManageResetPasswordPermission);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData(OrganizationUserType.Custom)]
|
||||
public async Task Build_WhenManageResetPasswordPermissions_ShouldFetchUserFromRepositoryAndReturnHasManageResetPasswordPermissionTrue(
|
||||
@@ -274,8 +233,6 @@ public class UserDecryptionOptionsBuilderTests
|
||||
[OrganizationUserWithDefaultPermissions] OrganizationUser organizationUser,
|
||||
User user)
|
||||
{
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword)
|
||||
.Returns(true);
|
||||
configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption;
|
||||
ssoConfig.Data = configurationData.Serialize();
|
||||
ssoConfig.OrganizationId = organization.Id;
|
||||
|
||||
@@ -95,6 +95,7 @@ public static class OrganizationTestHelpers
|
||||
SyncSeats = false,
|
||||
UseAutomaticUserConfirmation = true,
|
||||
UsePhishingBlocker = true,
|
||||
UseDisableSmAdsForUsers = true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -675,6 +675,7 @@ public class OrganizationUserRepositoryTests
|
||||
UseRiskInsights = false,
|
||||
UseAdminSponsoredFamilies = false,
|
||||
UsePhishingBlocker = false,
|
||||
UseDisableSmAdsForUsers = false,
|
||||
});
|
||||
|
||||
var organizationDomain = new OrganizationDomain
|
||||
|
||||
@@ -1207,10 +1207,110 @@ public class CipherRepositoryTests
|
||||
// Act
|
||||
await sutRepository.ArchiveAsync(new List<Guid> { cipher.Id }, user.Id);
|
||||
|
||||
// Assert
|
||||
var archivedCipher = await sutRepository.GetByIdAsync(cipher.Id, user.Id);
|
||||
Assert.NotNull(archivedCipher);
|
||||
Assert.NotNull(archivedCipher.ArchivedDate);
|
||||
// Assert – per-user view should show an archive date
|
||||
var archivedCipherForUser = await sutRepository.GetByIdAsync(cipher.Id, user.Id);
|
||||
Assert.NotNull(archivedCipherForUser);
|
||||
Assert.NotNull(archivedCipherForUser.ArchivedDate);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
public async Task ArchiveAsync_IsPerUserForSharedCipher(
|
||||
ICipherRepository cipherRepository,
|
||||
IUserRepository userRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
ICollectionRepository collectionRepository,
|
||||
ICollectionCipherRepository collectionCipherRepository)
|
||||
{
|
||||
// Arrange: two users in the same org, both with access to the same cipher
|
||||
var user1 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User 1",
|
||||
Email = $"test+{Guid.NewGuid()}@email.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
var user2 = await userRepository.CreateAsync(new User
|
||||
{
|
||||
Name = "Test User 2",
|
||||
Email = $"test+{Guid.NewGuid()}@email.com",
|
||||
ApiKey = "TEST",
|
||||
SecurityStamp = "stamp",
|
||||
});
|
||||
|
||||
var org = await organizationRepository.CreateAsync(new Organization
|
||||
{
|
||||
Name = "Test Organization",
|
||||
BillingEmail = user1.Email,
|
||||
Plan = "Test",
|
||||
});
|
||||
|
||||
var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
UserId = user1.Id,
|
||||
OrganizationId = org.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
Type = OrganizationUserType.Owner,
|
||||
});
|
||||
|
||||
var orgUser2 = await organizationUserRepository.CreateAsync(new OrganizationUser
|
||||
{
|
||||
UserId = user2.Id,
|
||||
OrganizationId = org.Id,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
Type = OrganizationUserType.User,
|
||||
});
|
||||
|
||||
var sharedCollection = await collectionRepository.CreateAsync(new Collection
|
||||
{
|
||||
Name = "Shared Collection",
|
||||
OrganizationId = org.Id,
|
||||
});
|
||||
|
||||
var cipher = await cipherRepository.CreateAsync(new Cipher
|
||||
{
|
||||
Type = CipherType.Login,
|
||||
OrganizationId = org.Id,
|
||||
Data = "",
|
||||
});
|
||||
|
||||
await collectionCipherRepository.UpdateCollectionsForAdminAsync(
|
||||
cipher.Id,
|
||||
org.Id,
|
||||
new List<Guid> { sharedCollection.Id });
|
||||
|
||||
// Give both org users access to the shared collection
|
||||
await collectionRepository.UpdateUsersAsync(sharedCollection.Id, new List<CollectionAccessSelection>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = orgUser1.Id,
|
||||
HidePasswords = false,
|
||||
ReadOnly = false,
|
||||
Manage = true,
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = orgUser2.Id,
|
||||
HidePasswords = false,
|
||||
ReadOnly = false,
|
||||
Manage = true,
|
||||
},
|
||||
});
|
||||
|
||||
// Act: user1 archives the shared cipher
|
||||
await cipherRepository.ArchiveAsync(new List<Guid> { cipher.Id }, user1.Id);
|
||||
|
||||
// Assert: user1 sees it as archived
|
||||
var cipherForUser1 = await cipherRepository.GetByIdAsync(cipher.Id, user1.Id);
|
||||
Assert.NotNull(cipherForUser1);
|
||||
Assert.NotNull(cipherForUser1.ArchivedDate);
|
||||
|
||||
// Assert: user2 still sees it as *not* archived
|
||||
var cipherForUser2 = await cipherRepository.GetByIdAsync(cipher.Id, user2.Id);
|
||||
Assert.NotNull(cipherForUser2);
|
||||
Assert.Null(cipherForUser2.ArchivedDate);
|
||||
}
|
||||
|
||||
[DatabaseTheory, DatabaseData]
|
||||
|
||||
@@ -193,7 +193,7 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase<Startup>
|
||||
/// Registers a new user to the Identity Application Factory based on the RegisterFinishRequestModel
|
||||
/// </summary>
|
||||
/// <param name="requestModel">RegisterFinishRequestModel needed to seed data to the test user</param>
|
||||
/// <param name="marketingEmails">optional parameter that is tracked during the inital steps of registration.</param>
|
||||
/// <param name="marketingEmails">optional parameter that is tracked during the initial steps of registration.</param>
|
||||
/// <returns>returns the newly created user</returns>
|
||||
public async Task<User> RegisterNewIdentityFactoryUserAsync(
|
||||
RegisterFinishRequestModel requestModel,
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Identity\Identity.csproj" />
|
||||
<ProjectReference Include="..\..\util\Migrator\Migrator.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user