1
0
mirror of https://github.com/bitwarden/server synced 2026-02-21 11:53:42 +00:00

Merge branch 'main' into auth/pm-29584/create-email-for-emergency-access-removal

This commit is contained in:
enmande
2026-01-09 12:52:45 -05:00
372 changed files with 52833 additions and 1785 deletions

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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.KeyManagement.Models.Requests;
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.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);
}
}

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -1,14 +1,17 @@
using System.Security.Claims;
using Bit.Api.Auth.Controllers;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.KeyManagement.Models.Requests;
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 +36,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 +54,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 +71,8 @@ public class AccountsControllerTests : IDisposable
_userService,
_policyService,
_setInitialMasterPasswordCommand,
_setInitialMasterPasswordCommandV1,
_tdeSetPasswordCommand,
_tdeOffboardingPasswordCommand,
_twoFactorIsEnabledQuery,
_featureService,
@@ -379,13 +388,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 +411,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 +427,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 +476,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 +861,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;
}
}

View File

@@ -0,0 +1,682 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.KeyManagement.Models.Requests;
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
}

View File

@@ -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
);
@@ -85,6 +77,7 @@ public class AccountsControllerTests : IDisposable
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
_licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal());
user.Gateway = GatewayType.Stripe; // User has payment gateway
@@ -124,6 +117,7 @@ public class AccountsControllerTests : IDisposable
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(false);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
_licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal());
user.Gateway = GatewayType.Stripe; // User has payment gateway
@@ -161,6 +155,7 @@ public class AccountsControllerTests : IDisposable
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
_licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal());
user.Gateway = GatewayType.Stripe; // User has payment gateway
@@ -207,6 +202,7 @@ public class AccountsControllerTests : IDisposable
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_userService.GenerateLicenseAsync(user).Returns(license);
_licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal());
// Act
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
@@ -243,6 +239,7 @@ public class AccountsControllerTests : IDisposable
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
_licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal());
user.Gateway = GatewayType.Stripe; // User has payment gateway
@@ -293,6 +290,7 @@ public class AccountsControllerTests : IDisposable
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
_licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal());
user.Gateway = GatewayType.Stripe;
@@ -349,6 +347,7 @@ public class AccountsControllerTests : IDisposable
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
_licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal());
user.Gateway = GatewayType.Stripe;
// Act & Assert - Feature flag ENABLED
@@ -413,6 +412,7 @@ public class AccountsControllerTests : IDisposable
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
_licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal());
user.Gateway = GatewayType.Stripe;
// Act - Step 4: Call AccountsController.GetSubscriptionAsync
@@ -507,6 +507,7 @@ public class AccountsControllerTests : IDisposable
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
_licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal());
user.Gateway = GatewayType.Stripe;
// Act
@@ -558,6 +559,7 @@ public class AccountsControllerTests : IDisposable
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
_licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal());
user.Gateway = GatewayType.Stripe;
// Act
@@ -611,6 +613,7 @@ public class AccountsControllerTests : IDisposable
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
_licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal());
user.Gateway = GatewayType.Stripe;
// Act
@@ -658,6 +661,7 @@ public class AccountsControllerTests : IDisposable
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
_licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal());
user.Gateway = GatewayType.Stripe;
// Act
@@ -726,6 +730,7 @@ public class AccountsControllerTests : IDisposable
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
_licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal());
user.Gateway = GatewayType.Stripe;
// Act - Full pipeline: Stripe → SubscriptionInfo → SubscriptionResponseModel → API response
@@ -791,6 +796,7 @@ public class AccountsControllerTests : IDisposable
};
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
_userService.GenerateLicenseAsync(user).Returns(license);
_licensingService.GetClaimsPrincipalFromLicense(license).Returns(new ClaimsPrincipal());
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); // Flag enabled
// Act

View File

@@ -0,0 +1,248 @@
using Bit.Api.Billing.Controllers.VNext;
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.Entities;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Http;
using NSubstitute;
using OneOf.Types;
using Xunit;
using BadRequest = Bit.Core.Billing.Commands.BadRequest;
namespace Bit.Api.Test.Billing.Controllers.VNext;
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<Core.Billing.Payment.Queries.IGetCreditQuery>(),
Substitute.For<Core.Billing.Payment.Queries.IGetPaymentMethodQuery>(),
_getUserLicenseQuery,
Substitute.For<Core.Billing.Payment.Commands.IUpdatePaymentMethodCommand>(),
_updatePremiumStorageCommand,
_upgradePremiumToOrganizationCommand);
}
[Theory, BitAutoData]
public async Task GetLicenseAsync_ValidUser_ReturnsLicenseResponse(
User user,
Core.Billing.Licenses.Models.Api.Response.LicenseResponseModel licenseResponse)
{
// Arrange
_getUserLicenseQuery.Run(user).Returns(licenseResponse);
// Act
var result = await _sut.GetLicenseAsync(user);
// Assert
var okResult = Assert.IsAssignableFrom<IResult>(result);
await _getUserLicenseQuery.Received(1).Run(user);
}
[Theory, BitAutoData]
public async Task UpdateStorageAsync_Success_ReturnsOk(User user)
{
// Arrange
var request = new StorageUpdateRequest { AdditionalStorageGb = 10 };
_updatePremiumStorageCommand.Run(
Arg.Is<User>(u => u.Id == user.Id),
Arg.Is<short>(s => s == 10))
.Returns(new BillingCommandResult<None>(new None()));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
// Assert
var okResult = Assert.IsAssignableFrom<IResult>(result);
await _updatePremiumStorageCommand.Received(1).Run(user, 10);
}
[Theory, BitAutoData]
public async Task UpdateStorageAsync_UserNotPremium_ReturnsBadRequest(User user)
{
// Arrange
var request = new StorageUpdateRequest { AdditionalStorageGb = 10 };
var errorMessage = "User does not have a premium subscription.";
_updatePremiumStorageCommand.Run(
Arg.Is<User>(u => u.Id == user.Id),
Arg.Is<short>(s => s == 10))
.Returns(new BadRequest(errorMessage));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
// Assert
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
await _updatePremiumStorageCommand.Received(1).Run(user, 10);
}
[Theory, BitAutoData]
public async Task UpdateStorageAsync_NoPaymentMethod_ReturnsBadRequest(User user)
{
// Arrange
var request = new StorageUpdateRequest { AdditionalStorageGb = 10 };
var errorMessage = "No payment method found.";
_updatePremiumStorageCommand.Run(
Arg.Is<User>(u => u.Id == user.Id),
Arg.Is<short>(s => s == 10))
.Returns(new BadRequest(errorMessage));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
// Assert
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
await _updatePremiumStorageCommand.Received(1).Run(user, 10);
}
[Theory, BitAutoData]
public async Task UpdateStorageAsync_StorageLessThanBase_ReturnsBadRequest(User user)
{
// Arrange
var request = new StorageUpdateRequest { AdditionalStorageGb = 1 };
var errorMessage = "Storage cannot be less than the base amount of 1 GB.";
_updatePremiumStorageCommand.Run(
Arg.Is<User>(u => u.Id == user.Id),
Arg.Is<short>(s => s == 1))
.Returns(new BadRequest(errorMessage));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
// Assert
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
await _updatePremiumStorageCommand.Received(1).Run(user, 1);
}
[Theory, BitAutoData]
public async Task UpdateStorageAsync_StorageExceedsMaximum_ReturnsBadRequest(User user)
{
// Arrange
var request = new StorageUpdateRequest { AdditionalStorageGb = 100 };
var errorMessage = "Maximum storage is 100 GB.";
_updatePremiumStorageCommand.Run(
Arg.Is<User>(u => u.Id == user.Id),
Arg.Is<short>(s => s == 100))
.Returns(new BadRequest(errorMessage));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
// Assert
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
await _updatePremiumStorageCommand.Received(1).Run(user, 100);
}
[Theory, BitAutoData]
public async Task UpdateStorageAsync_StorageExceedsCurrentUsage_ReturnsBadRequest(User user)
{
// Arrange
var request = new StorageUpdateRequest { AdditionalStorageGb = 2 };
var errorMessage = "You are currently using 5.00 GB of storage. Delete some stored data first.";
_updatePremiumStorageCommand.Run(
Arg.Is<User>(u => u.Id == user.Id),
Arg.Is<short>(s => s == 2))
.Returns(new BadRequest(errorMessage));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
// Assert
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
await _updatePremiumStorageCommand.Received(1).Run(user, 2);
}
[Theory, BitAutoData]
public async Task UpdateStorageAsync_IncreaseStorage_Success(User user)
{
// Arrange
var request = new StorageUpdateRequest { AdditionalStorageGb = 15 };
_updatePremiumStorageCommand.Run(
Arg.Is<User>(u => u.Id == user.Id),
Arg.Is<short>(s => s == 15))
.Returns(new BillingCommandResult<None>(new None()));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
// Assert
var okResult = Assert.IsAssignableFrom<IResult>(result);
await _updatePremiumStorageCommand.Received(1).Run(user, 15);
}
[Theory, BitAutoData]
public async Task UpdateStorageAsync_DecreaseStorage_Success(User user)
{
// Arrange
var request = new StorageUpdateRequest { AdditionalStorageGb = 3 };
_updatePremiumStorageCommand.Run(
Arg.Is<User>(u => u.Id == user.Id),
Arg.Is<short>(s => s == 3))
.Returns(new BillingCommandResult<None>(new None()));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
// Assert
var okResult = Assert.IsAssignableFrom<IResult>(result);
await _updatePremiumStorageCommand.Received(1).Run(user, 3);
}
[Theory, BitAutoData]
public async Task UpdateStorageAsync_MaximumStorage_Success(User user)
{
// Arrange
var request = new StorageUpdateRequest { AdditionalStorageGb = 100 };
_updatePremiumStorageCommand.Run(
Arg.Is<User>(u => u.Id == user.Id),
Arg.Is<short>(s => s == 100))
.Returns(new BillingCommandResult<None>(new None()));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
// Assert
var okResult = Assert.IsAssignableFrom<IResult>(result);
await _updatePremiumStorageCommand.Received(1).Run(user, 100);
}
[Theory, BitAutoData]
public async Task UpdateStorageAsync_NullPaymentSecret_Success(User user)
{
// Arrange
var request = new StorageUpdateRequest { AdditionalStorageGb = 5 };
_updatePremiumStorageCommand.Run(
Arg.Is<User>(u => u.Id == user.Id),
Arg.Is<short>(s => s == 5))
.Returns(new BillingCommandResult<None>(new None()));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
// Assert
var okResult = Assert.IsAssignableFrom<IResult>(result);
await _updatePremiumStorageCommand.Received(1).Run(user, 5);
}
}

View File

@@ -1,10 +1,10 @@
using Bit.Api.AdminConsole.Controllers;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Api.Dirt.Controllers;
using Bit.Api.Dirt.Models.Request;
using Bit.Api.Dirt.Models.Response;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Enums;
using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.Exceptions;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@@ -12,7 +12,7 @@ using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Controllers;
namespace Bit.Api.Test.Dirt.Controllers;
[ControllerCustomize(typeof(OrganizationIntegrationController))]
[SutProviderCustomize]

View File

@@ -1,9 +1,9 @@
using Bit.Api.AdminConsole.Controllers;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
using Bit.Api.Dirt.Controllers;
using Bit.Api.Dirt.Models.Request;
using Bit.Api.Dirt.Models.Response;
using Bit.Core.Context;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
using Bit.Core.Exceptions;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@@ -11,7 +11,7 @@ using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Controllers;
namespace Bit.Api.Test.Dirt.Controllers;
[ControllerCustomize(typeof(OrganizationIntegrationConfigurationController))]
[SutProviderCustomize]

View File

@@ -1,13 +1,13 @@
#nullable enable
using Bit.Api.AdminConsole.Controllers;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Api.Dirt.Controllers;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Enums;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Repositories;
using Bit.Core.Dirt.Services;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Mvc;
@@ -16,7 +16,7 @@ using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Controllers;
namespace Bit.Api.Test.Dirt.Controllers;
[ControllerCustomize(typeof(SlackIntegrationController))]
[SutProviderCustomize]

View File

@@ -1,14 +1,14 @@
#nullable enable
using Bit.Api.AdminConsole.Controllers;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Api.Dirt.Controllers;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Enums;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Models.Data.Teams;
using Bit.Core.Dirt.Repositories;
using Bit.Core.Dirt.Services;
using Bit.Core.Exceptions;
using Bit.Core.Models.Teams;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Http;
@@ -20,7 +20,7 @@ using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Controllers;
namespace Bit.Api.Test.Dirt.Controllers;
[ControllerCustomize(typeof(TeamsIntegrationController))]
[SutProviderCustomize]

View File

@@ -1,13 +1,13 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums;
using Bit.Api.Dirt.Models.Request;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Enums;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Models.Request.Organizations;
namespace Bit.Api.Test.Dirt.Models.Request;
public class OrganizationIntegrationRequestModelTests
{

View File

@@ -1,15 +1,15 @@
#nullable enable
using System.Text.Json;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums;
using Bit.Core.Models.Teams;
using Bit.Api.Dirt.Models.Response;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Enums;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Models.Data.Teams;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Models.Response.Organizations;
namespace Bit.Api.Test.Dirt.Models.Response;
public class OrganizationIntegrationResponseModelTests
{

View File

@@ -1,6 +1,9 @@
using System.Text.Json;
using System.Security.Claims;
using System.Text.Json;
using AutoFixture.Xunit2;
using Bit.Api.Models.Response;
using Bit.Api.Tools.Controllers;
using Bit.Api.Tools.Models;
using Bit.Api.Tools.Models.Request;
using Bit.Api.Tools.Models.Response;
using Bit.Core.Entities;
@@ -12,6 +15,7 @@ using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Repositories;
using Bit.Core.Tools.SendFeatures.Commands.Interfaces;
using Bit.Core.Tools.SendFeatures.Queries.Interfaces;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Mvc;
@@ -29,6 +33,7 @@ public class SendsControllerTests : IDisposable
private readonly ISendRepository _sendRepository;
private readonly INonAnonymousSendCommand _nonAnonymousSendCommand;
private readonly IAnonymousSendCommand _anonymousSendCommand;
private readonly ISendOwnerQuery _sendOwnerQuery;
private readonly ISendAuthorizationService _sendAuthorizationService;
private readonly ISendFileStorageService _sendFileStorageService;
private readonly ILogger<SendsController> _logger;
@@ -39,6 +44,7 @@ public class SendsControllerTests : IDisposable
_sendRepository = Substitute.For<ISendRepository>();
_nonAnonymousSendCommand = Substitute.For<INonAnonymousSendCommand>();
_anonymousSendCommand = Substitute.For<IAnonymousSendCommand>();
_sendOwnerQuery = Substitute.For<ISendOwnerQuery>();
_sendAuthorizationService = Substitute.For<ISendAuthorizationService>();
_sendFileStorageService = Substitute.For<ISendFileStorageService>();
_globalSettings = new GlobalSettings();
@@ -50,6 +56,7 @@ public class SendsControllerTests : IDisposable
_sendAuthorizationService,
_anonymousSendCommand,
_nonAnonymousSendCommand,
_sendOwnerQuery,
_sendFileStorageService,
_logger,
_globalSettings
@@ -109,4 +116,641 @@ public class SendsControllerTests : IDisposable
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostFile(request));
Assert.Equal(expected, exception.Message);
}
[Theory, AutoData]
public async Task Get_WithValidId_ReturnsSendResponseModel(Guid sendId, Send send)
{
send.Type = SendType.Text;
var textData = new SendTextData("Test Send", "Notes", "Sample text", false);
send.Data = JsonSerializer.Serialize(textData);
_sendOwnerQuery.Get(sendId, Arg.Any<ClaimsPrincipal>()).Returns(send);
var result = await _sut.Get(sendId.ToString());
Assert.NotNull(result);
Assert.IsType<SendResponseModel>(result);
Assert.Equal(send.Id, result.Id);
await _sendOwnerQuery.Received(1).Get(sendId, Arg.Any<ClaimsPrincipal>());
}
[Theory, AutoData]
public async Task Get_WithInvalidGuid_ThrowsException(string invalidId)
{
await Assert.ThrowsAsync<FormatException>(() => _sut.Get(invalidId));
}
[Fact]
public async Task GetAllOwned_ReturnsListResponseModelWithSendResponseModels()
{
var textSendData = new SendTextData("Test Send 1", "Notes 1", "Sample text", false);
var fileSendData = new SendFileData("Test Send 2", "Notes 2", "test.txt") { Id = "file-123", Size = 1024 };
var sends = new List<Send>
{
new Send { Id = Guid.NewGuid(), Type = SendType.Text, Data = JsonSerializer.Serialize(textSendData) },
new Send { Id = Guid.NewGuid(), Type = SendType.File, Data = JsonSerializer.Serialize(fileSendData) }
};
_sendOwnerQuery.GetOwned(Arg.Any<ClaimsPrincipal>()).Returns(sends);
var result = await _sut.GetAll();
Assert.NotNull(result);
Assert.IsType<ListResponseModel<SendResponseModel>>(result);
Assert.Equal(2, result.Data.Count());
var sendResponseModels = result.Data.ToList();
Assert.Equal(sends[0].Id, sendResponseModels[0].Id);
Assert.Equal(sends[1].Id, sendResponseModels[1].Id);
await _sendOwnerQuery.Received(1).GetOwned(Arg.Any<ClaimsPrincipal>());
}
[Fact]
public async Task GetAllOwned_WhenNoSends_ReturnsEmptyListResponseModel()
{
_sendOwnerQuery.GetOwned(Arg.Any<ClaimsPrincipal>()).Returns(new List<Send>());
var result = await _sut.GetAll();
Assert.NotNull(result);
Assert.IsType<ListResponseModel<SendResponseModel>>(result);
Assert.Empty(result.Data);
await _sendOwnerQuery.Received(1).GetOwned(Arg.Any<ClaimsPrincipal>());
}
[Theory, AutoData]
public async Task Post_WithPassword_InfersAuthTypePassword(Guid userId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var request = new SendRequestModel
{
Type = SendType.Text,
Key = "key",
Text = new SendTextModel { Text = "text" },
Password = "password",
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.Post(request);
Assert.NotNull(result);
Assert.Equal(AuthType.Password, result.AuthType);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
s.AuthType == AuthType.Password &&
s.Password != null &&
s.Emails == null &&
s.UserId == userId &&
s.Type == SendType.Text));
_userService.Received(1).GetProperUserId(Arg.Any<ClaimsPrincipal>());
}
[Theory, AutoData]
public async Task Post_WithEmails_InfersAuthTypeEmail(Guid userId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var request = new SendRequestModel
{
Type = SendType.Text,
Key = "key",
Text = new SendTextModel { Text = "text" },
Emails = "test@example.com",
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.Post(request);
Assert.NotNull(result);
Assert.Equal(AuthType.Email, result.AuthType);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
s.AuthType == AuthType.Email &&
s.Emails != null &&
s.Password == null &&
s.UserId == userId &&
s.Type == SendType.Text));
_userService.Received(1).GetProperUserId(Arg.Any<ClaimsPrincipal>());
}
[Theory, AutoData]
public async Task Post_WithoutPasswordOrEmails_InfersAuthTypeNone(Guid userId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var request = new SendRequestModel
{
Type = SendType.Text,
Key = "key",
Text = new SendTextModel { Text = "text" },
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.Post(request);
Assert.NotNull(result);
Assert.Equal(AuthType.None, result.AuthType);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
s.AuthType == AuthType.None &&
s.Password == null &&
s.Emails == null &&
s.UserId == userId &&
s.Type == SendType.Text));
_userService.Received(1).GetProperUserId(Arg.Any<ClaimsPrincipal>());
}
[Theory]
[InlineData(AuthType.Password)]
[InlineData(AuthType.Email)]
[InlineData(AuthType.None)]
public async Task Access_ReturnsCorrectAuthType(AuthType authType)
{
var sendId = Guid.NewGuid();
var accessId = CoreHelpers.Base64UrlEncode(sendId.ToByteArray());
var send = new Send
{
Id = sendId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new Dictionary<string, string>()),
AuthType = authType
};
_sendRepository.GetByIdAsync(sendId).Returns(send);
_sendAuthorizationService.AccessAsync(send, "pwd123").Returns(SendAccessResult.Granted);
var request = new SendAccessRequestModel();
var actionResult = await _sut.Access(accessId, request);
var response = (actionResult as ObjectResult)?.Value as SendAccessResponseModel;
Assert.NotNull(response);
Assert.Equal(authType, response.AuthType);
}
[Theory]
[InlineData(AuthType.Password)]
[InlineData(AuthType.Email)]
[InlineData(AuthType.None)]
public async Task Get_ReturnsCorrectAuthType(AuthType authType)
{
var sendId = Guid.NewGuid();
var send = new Send
{
Id = sendId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("a", "b", "c", false)),
AuthType = authType
};
_sendOwnerQuery.Get(sendId, Arg.Any<ClaimsPrincipal>()).Returns(send);
var result = await _sut.Get(sendId.ToString());
Assert.NotNull(result);
Assert.Equal(authType, result.AuthType);
}
[Theory, AutoData]
public async Task Put_WithValidSend_UpdatesSuccessfully(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = userId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false)),
AuthType = AuthType.None
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
var request = new SendRequestModel
{
Type = SendType.Text,
Key = "updated-key",
Text = new SendTextModel { Text = "updated text" },
Password = "new-password",
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.Put(sendId.ToString(), request);
Assert.NotNull(result);
Assert.Equal(sendId, result.Id);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s => s.Id == sendId));
}
[Theory, AutoData]
public async Task Put_WithNonExistentSend_ThrowsNotFoundException(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
_sendRepository.GetByIdAsync(sendId).Returns((Send)null);
var request = new SendRequestModel
{
Type = SendType.Text,
Key = "key",
Text = new SendTextModel { Text = "text" },
DeletionDate = DateTime.UtcNow.AddDays(7)
};
await Assert.ThrowsAsync<NotFoundException>(() => _sut.Put(sendId.ToString(), request));
}
[Theory, AutoData]
public async Task Put_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = otherUserId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false))
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
var request = new SendRequestModel
{
Type = SendType.Text,
Key = "key",
Text = new SendTextModel { Text = "text" },
DeletionDate = DateTime.UtcNow.AddDays(7)
};
await Assert.ThrowsAsync<NotFoundException>(() => _sut.Put(sendId.ToString(), request));
}
[Theory, AutoData]
public async Task PutRemovePassword_WithValidSend_RemovesPasswordAndSetsAuthTypeNone(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = userId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
Password = "hashed-password",
AuthType = AuthType.Password
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
var result = await _sut.PutRemovePassword(sendId.ToString());
Assert.NotNull(result);
Assert.Equal(sendId, result.Id);
Assert.Equal(AuthType.None, result.AuthType);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
s.Id == sendId &&
s.Password == null &&
s.AuthType == AuthType.None));
}
[Theory, AutoData]
public async Task PutRemovePassword_WithNonExistentSend_ThrowsNotFoundException(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
_sendRepository.GetByIdAsync(sendId).Returns((Send)null);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.PutRemovePassword(sendId.ToString()));
}
[Theory, AutoData]
public async Task PutRemovePassword_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = otherUserId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
Password = "hashed-password"
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.PutRemovePassword(sendId.ToString()));
}
[Theory, AutoData]
public async Task Delete_WithValidSend_DeletesSuccessfully(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = userId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false))
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
await _sut.Delete(sendId.ToString());
await _nonAnonymousSendCommand.Received(1).DeleteSendAsync(Arg.Is<Send>(s => s.Id == sendId));
}
[Theory, AutoData]
public async Task Delete_WithNonExistentSend_ThrowsNotFoundException(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
_sendRepository.GetByIdAsync(sendId).Returns((Send)null);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.Delete(sendId.ToString()));
}
[Theory, AutoData]
public async Task Delete_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = otherUserId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false))
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.Delete(sendId.ToString()));
}
[Theory, AutoData]
public async Task PostFile_WithPassword_InfersAuthTypePassword(Guid userId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
_nonAnonymousSendCommand.SaveFileSendAsync(Arg.Any<Send>(), Arg.Any<SendFileData>(), Arg.Any<long>())
.Returns("https://example.com/upload")
.AndDoes(callInfo =>
{
var send = callInfo.ArgAt<Send>(0);
var data = callInfo.ArgAt<SendFileData>(1);
send.Data = JsonSerializer.Serialize(data);
});
var request = new SendRequestModel
{
Type = SendType.File,
Key = "key",
File = new SendFileModel { FileName = "test.txt" },
FileLength = 1024L,
Password = "password",
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.PostFile(request);
Assert.NotNull(result);
Assert.NotNull(result.SendResponse);
Assert.Equal(AuthType.Password, result.SendResponse.AuthType);
await _nonAnonymousSendCommand.Received(1).SaveFileSendAsync(
Arg.Is<Send>(s =>
s.AuthType == AuthType.Password &&
s.Password != null &&
s.Emails == null &&
s.UserId == userId),
Arg.Any<SendFileData>(),
1024L);
}
[Theory, AutoData]
public async Task PostFile_WithEmails_InfersAuthTypeEmail(Guid userId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
_nonAnonymousSendCommand.SaveFileSendAsync(Arg.Any<Send>(), Arg.Any<SendFileData>(), Arg.Any<long>())
.Returns("https://example.com/upload")
.AndDoes(callInfo =>
{
var send = callInfo.ArgAt<Send>(0);
var data = callInfo.ArgAt<SendFileData>(1);
send.Data = JsonSerializer.Serialize(data);
});
var request = new SendRequestModel
{
Type = SendType.File,
Key = "key",
File = new SendFileModel { FileName = "test.txt" },
FileLength = 1024L,
Emails = "test@example.com",
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.PostFile(request);
Assert.NotNull(result);
Assert.NotNull(result.SendResponse);
Assert.Equal(AuthType.Email, result.SendResponse.AuthType);
await _nonAnonymousSendCommand.Received(1).SaveFileSendAsync(
Arg.Is<Send>(s =>
s.AuthType == AuthType.Email &&
s.Emails != null &&
s.Password == null &&
s.UserId == userId),
Arg.Any<SendFileData>(),
1024L);
}
[Theory, AutoData]
public async Task PostFile_WithoutPasswordOrEmails_InfersAuthTypeNone(Guid userId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
_nonAnonymousSendCommand.SaveFileSendAsync(Arg.Any<Send>(), Arg.Any<SendFileData>(), Arg.Any<long>())
.Returns("https://example.com/upload")
.AndDoes(callInfo =>
{
var send = callInfo.ArgAt<Send>(0);
var data = callInfo.ArgAt<SendFileData>(1);
send.Data = JsonSerializer.Serialize(data);
});
var request = new SendRequestModel
{
Type = SendType.File,
Key = "key",
File = new SendFileModel { FileName = "test.txt" },
FileLength = 1024L,
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.PostFile(request);
Assert.NotNull(result);
Assert.NotNull(result.SendResponse);
Assert.Equal(AuthType.None, result.SendResponse.AuthType);
await _nonAnonymousSendCommand.Received(1).SaveFileSendAsync(
Arg.Is<Send>(s =>
s.AuthType == AuthType.None &&
s.Password == null &&
s.Emails == null &&
s.UserId == userId),
Arg.Any<SendFileData>(),
1024L);
}
[Theory, AutoData]
public async Task Put_ChangingFromPasswordToEmails_UpdatesAuthTypeToEmail(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = userId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false)),
Password = "hashed-password",
AuthType = AuthType.Password
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
var request = new SendRequestModel
{
Type = SendType.Text,
Key = "updated-key",
Text = new SendTextModel { Text = "updated text" },
Emails = "new@example.com",
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.Put(sendId.ToString(), request);
Assert.NotNull(result);
Assert.Equal(sendId, result.Id);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
s.Id == sendId &&
s.AuthType == AuthType.Email &&
s.Emails != null &&
s.Password == null));
}
[Theory, AutoData]
public async Task Put_ChangingFromEmailToPassword_UpdatesAuthTypeToPassword(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = userId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false)),
Emails = "old@example.com",
AuthType = AuthType.Email
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
var request = new SendRequestModel
{
Type = SendType.Text,
Key = "updated-key",
Text = new SendTextModel { Text = "updated text" },
Password = "new-password",
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.Put(sendId.ToString(), request);
Assert.NotNull(result);
Assert.Equal(sendId, result.Id);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
s.Id == sendId &&
s.AuthType == AuthType.Password &&
s.Password != null &&
s.Emails == null));
}
[Theory, AutoData]
public async Task Put_WithoutPasswordOrEmails_PreservesExistingPassword(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = userId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false)),
Password = "hashed-password",
AuthType = AuthType.Password
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
var request = new SendRequestModel
{
Type = SendType.Text,
Key = "updated-key",
Text = new SendTextModel { Text = "updated text" },
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.Put(sendId.ToString(), request);
Assert.NotNull(result);
Assert.Equal(sendId, result.Id);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
s.Id == sendId &&
s.AuthType == AuthType.Password &&
s.Password == "hashed-password" &&
s.Emails == null));
}
[Theory, AutoData]
public async Task Put_WithoutPasswordOrEmails_PreservesExistingEmails(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = userId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false)),
Emails = "test@example.com",
AuthType = AuthType.Email
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
var request = new SendRequestModel
{
Type = SendType.Text,
Key = "updated-key",
Text = new SendTextModel { Text = "updated text" },
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.Put(sendId.ToString(), request);
Assert.NotNull(result);
Assert.Equal(sendId, result.Id);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
s.Id == sendId &&
s.AuthType == AuthType.Email &&
s.Emails == "test@example.com" &&
s.Password == null));
}
[Theory, AutoData]
public async Task Put_WithoutPasswordOrEmails_PreservesNoneAuthType(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = userId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false)),
Password = null,
Emails = null,
AuthType = AuthType.None
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
var request = new SendRequestModel
{
Type = SendType.Text,
Key = "updated-key",
Text = new SendTextModel { Text = "updated text" },
DeletionDate = DateTime.UtcNow.AddDays(7)
};
var result = await _sut.Put(sendId.ToString(), request);
Assert.NotNull(result);
Assert.Equal(sendId, result.Id);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
s.Id == sendId &&
s.AuthType == AuthType.None &&
s.Password == null &&
s.Emails == null));
}
}

View File

@@ -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;
}
}
}

View File

@@ -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
}
};
}

View File

@@ -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);
}
}

View File

@@ -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>());
}
}

View File

@@ -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>());
}
}

View File

@@ -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 &amp; 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}";
}

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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

View File

@@ -1,4 +1,4 @@
using Bit.Core.Enums;
using Bit.Core.Dirt.Enums;
using Xunit;
namespace Bit.Core.Test.Services;

View File

@@ -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>());
}
}

View File

@@ -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>());
}
}

View File

@@ -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
};
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}

View File

@@ -407,4 +407,85 @@ public class UpdateBillingAddressCommandTests
options => options.Type == TaxIdType.SpanishNIF &&
options.Value == input.TaxId.Value));
}
[Fact]
public async Task Run_BusinessOrganization_UpdatingWithSameTaxId_DeletesBeforeCreating()
{
var organization = new Organization
{
PlanType = PlanType.EnterpriseAnnually,
GatewayCustomerId = "cus_123",
GatewaySubscriptionId = "sub_123"
};
var input = new BillingAddress
{
Country = "US",
PostalCode = "12345",
Line1 = "123 Main St.",
Line2 = "Suite 100",
City = "New York",
State = "NY",
TaxId = new TaxID("us_ein", "987654321")
};
var existingTaxId = new TaxId { Id = "tax_id_123", Type = "us_ein", Value = "987654321" };
var customer = new Customer
{
Address = new Address
{
Country = "US",
PostalCode = "12345",
Line1 = "123 Main St.",
Line2 = "Suite 100",
City = "New York",
State = "NY"
},
Id = organization.GatewayCustomerId,
Subscriptions = new StripeList<Subscription>
{
Data =
[
new Subscription
{
Id = organization.GatewaySubscriptionId,
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }
}
]
},
TaxIds = new StripeList<TaxId>
{
Data = [existingTaxId]
}
};
_stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Address.Matches(input) &&
options.HasExpansions("subscriptions", "tax_ids") &&
options.TaxExempt == TaxExempt.None
)).Returns(customer);
var newTaxId = new TaxId { Id = "tax_id_456", Type = "us_ein", Value = "987654321" };
_stripeAdapter.CreateTaxIdAsync(customer.Id, Arg.Is<TaxIdCreateOptions>(
options => options.Type == "us_ein" && options.Value == "987654321"
)).Returns(newTaxId);
var result = await _command.Run(organization, input);
Assert.True(result.IsT0);
var output = result.AsT0;
Assert.Equivalent(input, output);
// Verify that deletion happens before creation
Received.InOrder(() =>
{
_stripeAdapter.DeleteTaxIdAsync(customer.Id, existingTaxId.Id);
_stripeAdapter.CreateTaxIdAsync(customer.Id, Arg.Any<TaxIdCreateOptions>());
});
await _stripeAdapter.Received(1).DeleteTaxIdAsync(customer.Id, existingTaxId.Id);
await _stripeAdapter.Received(1).CreateTaxIdAsync(customer.Id, Arg.Is<TaxIdCreateOptions>(
options => options.Type == "us_ein" && options.Value == "987654321"));
}
}

View File

@@ -0,0 +1,339 @@
using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
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 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
{
Name = "Premium",
Available = true,
LegacyYear = null,
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 });
_command = new UpdatePremiumStorageCommand(
_stripeAdapter,
_userService,
_pricingClient,
Substitute.For<ILogger<UpdatePremiumStorageCommand>>());
}
private Subscription CreateMockSubscription(string subscriptionId, int? storageQuantity = null)
{
var items = new List<SubscriptionItem>();
// Always add the seat item
items.Add(new SubscriptionItem
{
Id = "si_seat",
Price = new Price { Id = "price_premium" },
Quantity = 1
});
// Add storage item if quantity is provided
if (storageQuantity.HasValue && storageQuantity.Value > 0)
{
items.Add(new SubscriptionItem
{
Id = "si_storage",
Price = new Price { Id = "price_storage" },
Quantity = storageQuantity.Value
});
}
return new Subscription
{
Id = subscriptionId,
Items = new StripeList<SubscriptionItem>
{
Data = items
}
};
}
[Theory, BitAutoData]
public async Task Run_UserNotPremium_ReturnsBadRequest(User user)
{
// Arrange
user.Premium = false;
// Act
var result = await _command.Run(user, 5);
// Assert
Assert.True(result.IsT1);
var badRequest = result.AsT1;
Assert.Equal("User does not have a premium subscription.", badRequest.Response);
}
[Theory, BitAutoData]
public async Task Run_NegativeStorage_ReturnsBadRequest(User user)
{
// Arrange
user.Premium = true;
user.MaxStorageGb = 5;
user.GatewaySubscriptionId = "sub_123";
var subscription = CreateMockSubscription("sub_123", 4);
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
// Act
var result = await _command.Run(user, -5);
// Assert
Assert.True(result.IsT1);
var badRequest = result.AsT1;
Assert.Equal("Additional storage cannot be negative.", badRequest.Response);
}
[Theory, BitAutoData]
public async Task Run_StorageExceedsMaximum_ReturnsBadRequest(User user)
{
// Arrange
user.Premium = true;
user.MaxStorageGb = 5;
user.GatewaySubscriptionId = "sub_123";
var subscription = CreateMockSubscription("sub_123", 4);
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
// Act
var result = await _command.Run(user, 100);
// Assert
Assert.True(result.IsT1);
var badRequest = result.AsT1;
Assert.Equal("Maximum storage is 100 GB.", badRequest.Response);
}
[Theory, BitAutoData]
public async Task Run_NoMaxStorageGb_ReturnsBadRequest(User user)
{
// Arrange
user.Premium = true;
user.MaxStorageGb = null;
// Act
var result = await _command.Run(user, 5);
// Assert
Assert.True(result.IsT1);
var badRequest = result.AsT1;
Assert.Equal("No access to storage.", badRequest.Response);
}
[Theory, BitAutoData]
public async Task Run_StorageExceedsCurrentUsage_ReturnsBadRequest(User user)
{
// Arrange
user.Premium = true;
user.MaxStorageGb = 10;
user.Storage = 5L * 1024 * 1024 * 1024; // 5 GB currently used
user.GatewaySubscriptionId = "sub_123";
var subscription = CreateMockSubscription("sub_123", 9);
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
// Act
var result = await _command.Run(user, 0);
// Assert
Assert.True(result.IsT1);
var badRequest = result.AsT1;
Assert.Contains("You are currently using", badRequest.Response);
Assert.Contains("Delete some stored data first", badRequest.Response);
}
[Theory, BitAutoData]
public async Task Run_SameStorageAmount_Idempotent(User user)
{
// Arrange
user.Premium = true;
user.MaxStorageGb = 5;
user.Storage = 2L * 1024 * 1024 * 1024;
user.GatewaySubscriptionId = "sub_123";
var subscription = CreateMockSubscription("sub_123", 4);
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
// Act
var result = await _command.Run(user, 4);
// Assert
Assert.True(result.IsT0);
// Verify subscription was fetched but NOT updated
await _stripeAdapter.Received(1).GetSubscriptionAsync("sub_123");
await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
await _userService.DidNotReceive().SaveUserAsync(Arg.Any<User>());
}
[Theory, BitAutoData]
public async Task Run_IncreaseStorage_Success(User user)
{
// Arrange
user.Premium = true;
user.MaxStorageGb = 5;
user.Storage = 2L * 1024 * 1024 * 1024;
user.GatewaySubscriptionId = "sub_123";
var subscription = CreateMockSubscription("sub_123", 4);
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
// Act
var result = await _command.Run(user, 9);
// Assert
Assert.True(result.IsT0);
// Verify subscription was updated
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.Items.Count == 1 &&
opts.Items[0].Id == "si_storage" &&
opts.Items[0].Quantity == 9 &&
opts.ProrationBehavior == "create_prorations"));
// Verify user was saved
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u =>
u.Id == user.Id &&
u.MaxStorageGb == 10));
}
[Theory, BitAutoData]
public async Task Run_AddStorageFromZero_Success(User user)
{
// Arrange
user.Premium = true;
user.MaxStorageGb = 1;
user.Storage = 500L * 1024 * 1024;
user.GatewaySubscriptionId = "sub_123";
var subscription = CreateMockSubscription("sub_123", null);
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
// Act
var result = await _command.Run(user, 9);
// Assert
Assert.True(result.IsT0);
// Verify subscription was updated with new storage item
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.Items.Count == 1 &&
opts.Items[0].Price == "price_storage" &&
opts.Items[0].Quantity == 9));
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u => u.MaxStorageGb == 10));
}
[Theory, BitAutoData]
public async Task Run_DecreaseStorage_Success(User user)
{
// Arrange
user.Premium = true;
user.MaxStorageGb = 10;
user.Storage = 2L * 1024 * 1024 * 1024;
user.GatewaySubscriptionId = "sub_123";
var subscription = CreateMockSubscription("sub_123", 9);
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
// Act
var result = await _command.Run(user, 2);
// Assert
Assert.True(result.IsT0);
// Verify subscription was updated
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.Items.Count == 1 &&
opts.Items[0].Id == "si_storage" &&
opts.Items[0].Quantity == 2));
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u => u.MaxStorageGb == 3));
}
[Theory, BitAutoData]
public async Task Run_RemoveAllAdditionalStorage_Success(User user)
{
// Arrange
user.Premium = true;
user.MaxStorageGb = 10;
user.Storage = 500L * 1024 * 1024;
user.GatewaySubscriptionId = "sub_123";
var subscription = CreateMockSubscription("sub_123", 9);
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
// Act
var result = await _command.Run(user, 0);
// Assert
Assert.True(result.IsT0);
// Verify subscription item was deleted
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.Items.Count == 1 &&
opts.Items[0].Id == "si_storage" &&
opts.Items[0].Deleted == true));
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u => u.MaxStorageGb == 1));
}
[Theory, BitAutoData]
public async Task Run_MaximumStorage_Success(User user)
{
// Arrange
user.Premium = true;
user.MaxStorageGb = 5;
user.Storage = 2L * 1024 * 1024 * 1024;
user.GatewaySubscriptionId = "sub_123";
var subscription = CreateMockSubscription("sub_123", 4);
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
// Act
var result = await _command.Run(user, 99);
// Assert
Assert.True(result.IsT0);
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
"sub_123",
Arg.Is<SubscriptionUpdateOptions>(opts =>
opts.Items[0].Quantity == 99));
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u => u.MaxStorageGb == 100));
}
}

View File

@@ -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);
}
}

View File

@@ -1,12 +1,15 @@
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.AdminConsole.Services.NoopImplementations;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations.Interfaces;
using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Repositories;
using Bit.Core.Dirt.Services;
using Bit.Core.Dirt.Services.Implementations;
using Bit.Core.Dirt.Services.NoopImplementations;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Test.Dirt.Models.Data.EventIntegrations;
using Bit.Core.Utilities;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Integration.AspNet.Core;
@@ -19,7 +22,7 @@ using StackExchange.Redis;
using Xunit;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.Test.AdminConsole.EventIntegrations;
namespace Bit.Core.Test.Dirt.EventIntegrations;
public class EventIntegrationServiceCollectionExtensionsTests
{

View File

@@ -1,9 +1,10 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Enums;
using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations;
using Bit.Core.Dirt.Repositories;
using Bit.Core.Dirt.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@@ -11,7 +12,7 @@ using NSubstitute;
using Xunit;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations;
namespace Bit.Core.Test.Dirt.EventIntegrations.OrganizationIntegrationConfigurations;
[SutProviderCustomize]
public class CreateOrganizationIntegrationConfigurationCommandTests

View File

@@ -1,8 +1,9 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Enums;
using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations;
using Bit.Core.Dirt.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@@ -10,7 +11,7 @@ using NSubstitute;
using Xunit;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations;
namespace Bit.Core.Test.Dirt.EventIntegrations.OrganizationIntegrationConfigurations;
[SutProviderCustomize]
public class DeleteOrganizationIntegrationConfigurationCommandTests

View File

@@ -1,13 +1,13 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations;
using Bit.Core.Dirt.Repositories;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations;
namespace Bit.Core.Test.Dirt.EventIntegrations.OrganizationIntegrationConfigurations;
[SutProviderCustomize]
public class GetOrganizationIntegrationConfigurationsQueryTests

View File

@@ -1,9 +1,10 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Enums;
using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrationConfigurations;
using Bit.Core.Dirt.Repositories;
using Bit.Core.Dirt.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@@ -11,7 +12,7 @@ using NSubstitute;
using Xunit;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrationConfigurations;
namespace Bit.Core.Test.Dirt.EventIntegrations.OrganizationIntegrationConfigurations;
[SutProviderCustomize]
public class UpdateOrganizationIntegrationConfigurationCommandTests

View File

@@ -1,8 +1,8 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
using Bit.Core.Enums;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Enums;
using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations;
using Bit.Core.Dirt.Repositories;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@@ -10,7 +10,7 @@ using NSubstitute;
using Xunit;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrations;
namespace Bit.Core.Test.Dirt.EventIntegrations.OrganizationIntegrations;
[SutProviderCustomize]
public class CreateOrganizationIntegrationCommandTests

View File

@@ -1,8 +1,8 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
using Bit.Core.Enums;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Enums;
using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations;
using Bit.Core.Dirt.Repositories;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@@ -10,7 +10,7 @@ using NSubstitute;
using Xunit;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrations;
namespace Bit.Core.Test.Dirt.EventIntegrations.OrganizationIntegrations;
[SutProviderCustomize]
public class DeleteOrganizationIntegrationCommandTests

View File

@@ -1,12 +1,12 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
using Bit.Core.Repositories;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations;
using Bit.Core.Dirt.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrations;
namespace Bit.Core.Test.Dirt.EventIntegrations.OrganizationIntegrations;
[SutProviderCustomize]
public class GetOrganizationIntegrationsQueryTests

View File

@@ -1,8 +1,8 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
using Bit.Core.Enums;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Enums;
using Bit.Core.Dirt.EventIntegrations.OrganizationIntegrations;
using Bit.Core.Dirt.Repositories;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@@ -10,7 +10,7 @@ using NSubstitute;
using Xunit;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.Test.AdminConsole.EventIntegrations.OrganizationIntegrations;
namespace Bit.Core.Test.Dirt.EventIntegrations.OrganizationIntegrations;
[SutProviderCustomize]
public class UpdateOrganizationIntegrationCommandTests

View File

@@ -1,8 +1,8 @@
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Core.Test.AdminConsole.Models.Data.EventIntegrations;
namespace Bit.Core.Test.Dirt.Models.Data.EventIntegrations;
public class IntegrationHandlerResultTests
{

View File

@@ -1,9 +1,9 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums;
using Bit.Core.Dirt.Enums;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Xunit;
namespace Bit.Core.Test.Models.Data.EventIntegrations;
namespace Bit.Core.Test.Dirt.Models.Data.EventIntegrations;
public class IntegrationMessageTests
{

View File

@@ -1,12 +1,12 @@
#nullable enable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Time.Testing;
using Xunit;
namespace Bit.Core.Test.AdminConsole.Models.Data.EventIntegrations;
namespace Bit.Core.Test.Dirt.Models.Data.EventIntegrations;
public class IntegrationOAuthStateTests
{

View File

@@ -1,13 +1,13 @@
#nullable enable
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Core.Test.AdminConsole.Models.Data.EventIntegrations;
namespace Bit.Core.Test.Dirt.Models.Data.EventIntegrations;
public class IntegrationTemplateContextTests
{

View File

@@ -1,8 +1,8 @@
using System.Text.Json;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Xunit;
namespace Bit.Core.Test.Models.Data.Organizations;
namespace Bit.Core.Test.Dirt.Models.Data.EventIntegrations;
public class OrganizationIntegrationConfigurationDetailsTests
{

View File

@@ -1,6 +1,7 @@
using Bit.Core.Enums;
using Bit.Core.Dirt.Enums;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations;
namespace Bit.Core.Test.Dirt.Models.Data.EventIntegrations;
public class TestListenerConfiguration : IIntegrationListenerConfiguration
{

View File

@@ -1,8 +1,8 @@
using Bit.Core.AdminConsole.Models.Teams;
using Bit.Core.Dirt.Models.Data.Teams;
using Microsoft.Bot.Connector.Authentication;
using Xunit;
namespace Bit.Core.Test.Models.Data.Teams;
namespace Bit.Core.Test.Dirt.Models.Data.Teams;
public class TeamsBotCredentialProviderTests
{

View File

@@ -2,9 +2,10 @@
using System.Text.Json;
using Azure.Messaging.ServiceBus;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Services;
using Bit.Core.Dirt.Services.Implementations;
using Bit.Core.Models.Data;
using Bit.Core.Services;
using Bit.Core.Test.Dirt.Models.Data.EventIntegrations;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
@@ -12,7 +13,7 @@ using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Services;
namespace Bit.Core.Test.Dirt.Services;
[SutProviderCustomize]
public class AzureServiceBusEventListenerServiceTests

View File

@@ -2,8 +2,10 @@
using System.Text.Json;
using Azure.Messaging.ServiceBus;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Services;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Services;
using Bit.Core.Dirt.Services.Implementations;
using Bit.Core.Test.Dirt.Models.Data.EventIntegrations;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Logging;
@@ -11,7 +13,7 @@ using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Bit.Core.Test.Services;
namespace Bit.Core.Test.Dirt.Services;
[SutProviderCustomize]
public class AzureServiceBusIntegrationListenerServiceTests

View File

@@ -1,8 +1,8 @@
#nullable enable
using System.Net;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Services;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Services.Implementations;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
@@ -11,7 +11,7 @@ using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Services;
namespace Bit.Core.Test.Dirt.Services;
[SutProviderCustomize]
public class DatadogIntegrationHandlerTests

View File

@@ -1,12 +1,13 @@
using System.Text.Json;
using Bit.Core.Dirt.Services;
using Bit.Core.Dirt.Services.Implementations;
using Bit.Core.Models.Data;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Services;
namespace Bit.Core.Test.Dirt.Services;
[SutProviderCustomize]
public class EventIntegrationEventWriteServiceTests

View File

@@ -2,14 +2,15 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Dirt.Enums;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Repositories;
using Bit.Core.Dirt.Services;
using Bit.Core.Dirt.Services.Implementations;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@@ -19,7 +20,7 @@ using NSubstitute;
using Xunit;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.Test.Services;
namespace Bit.Core.Test.Dirt.Services;
[SutProviderCustomize]
public class EventIntegrationHandlerTests

View File

@@ -1,4 +1,5 @@
using Bit.Core.Models.Data;
using Bit.Core.Dirt.Services.Implementations;
using Bit.Core.Models.Data;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@@ -6,7 +7,7 @@ using Bit.Test.Common.Helpers;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Services;
namespace Bit.Core.Test.Dirt.Services;
[SutProviderCustomize]
public class EventRepositoryHandlerTests

View File

@@ -1,9 +1,9 @@
using Bit.Core.Models.Data;
using Bit.Core.Services;
using Bit.Core.Dirt.Services.Implementations;
using Bit.Core.Models.Data;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Core.Test.Services;
namespace Bit.Core.Test.Dirt.Services;
public class IntegrationFilterFactoryTests
{

View File

@@ -1,13 +1,13 @@
#nullable enable
using System.Text.Json;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Services.Implementations;
using Bit.Core.Models.Data;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Core.Test.Services;
namespace Bit.Core.Test.Dirt.Services;
public class IntegrationFilterServiceTests
{

View File

@@ -1,10 +1,10 @@
using System.Net;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums;
using Bit.Core.Services;
using Bit.Core.Dirt.Enums;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Services;
using Xunit;
namespace Bit.Core.Test.Services;
namespace Bit.Core.Test.Dirt.Services;
public class IntegrationHandlerTests
{

View File

@@ -1,11 +1,11 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Enums;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Enums;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Services.Implementations;
using Xunit;
namespace Bit.Core.Test.AdminConsole.Services;
namespace Bit.Core.Test.Dirt.Services;
public class OrganizationIntegrationConfigurationValidatorTests
{

View File

@@ -1,9 +1,10 @@
#nullable enable
using System.Text.Json;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Services;
using Bit.Core.Dirt.Services.Implementations;
using Bit.Core.Models.Data;
using Bit.Core.Services;
using Bit.Core.Test.Dirt.Models.Data.EventIntegrations;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
@@ -13,7 +14,7 @@ using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using Xunit;
namespace Bit.Core.Test.Services;
namespace Bit.Core.Test.Dirt.Services;
[SutProviderCustomize]
public class RabbitMqEventListenerServiceTests

View File

@@ -1,8 +1,10 @@
#nullable enable
using System.Text;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Services;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Services;
using Bit.Core.Dirt.Services.Implementations;
using Bit.Core.Test.Dirt.Models.Data.EventIntegrations;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
@@ -13,7 +15,7 @@ using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using Xunit;
namespace Bit.Core.Test.Services;
namespace Bit.Core.Test.Dirt.Services;
[SutProviderCustomize]
public class RabbitMqIntegrationListenerServiceTests

View File

@@ -1,13 +1,14 @@
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Models.Slack;
using Bit.Core.Services;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Models.Data.Slack;
using Bit.Core.Dirt.Services;
using Bit.Core.Dirt.Services.Implementations;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Services;
namespace Bit.Core.Test.Dirt.Services;
[SutProviderCustomize]
public class SlackIntegrationHandlerTests

View File

@@ -3,7 +3,7 @@
using System.Net;
using System.Text.Json;
using System.Web;
using Bit.Core.Services;
using Bit.Core.Dirt.Services.Implementations;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.MockedHttpClient;
@@ -11,7 +11,7 @@ using NSubstitute;
using Xunit;
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
namespace Bit.Core.Test.Services;
namespace Bit.Core.Test.Dirt.Services;
[SutProviderCustomize]
public class SlackServiceTests

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Services;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Services;
using Bit.Core.Dirt.Services.Implementations;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
@@ -9,7 +10,7 @@ using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Bit.Core.Test.Services;
namespace Bit.Core.Test.Dirt.Services;
[SutProviderCustomize]
public class TeamsIntegrationHandlerTests

View File

@@ -3,11 +3,11 @@
using System.Net;
using System.Text.Json;
using System.Web;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Models.Teams;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Models.Data.Teams;
using Bit.Core.Dirt.Repositories;
using Bit.Core.Dirt.Services.Implementations;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.MockedHttpClient;
@@ -15,7 +15,7 @@ using NSubstitute;
using Xunit;
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
namespace Bit.Core.Test.Services;
namespace Bit.Core.Test.Dirt.Services;
[SutProviderCustomize]
public class TeamsServiceTests

View File

@@ -1,7 +1,7 @@
using System.Net;
using System.Net.Http.Headers;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Services;
using Bit.Core.Dirt.Models.Data.EventIntegrations;
using Bit.Core.Dirt.Services.Implementations;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
@@ -10,7 +10,7 @@ using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Services;
namespace Bit.Core.Test.Dirt.Services;
[SutProviderCustomize]
public class WebhookIntegrationHandlerTests

View File

@@ -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";
}

View File

@@ -0,0 +1,169 @@
using System.Security.Claims;
using Bit.Core.Exceptions;
using Bit.Core.Services;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Repositories;
using Bit.Core.Tools.SendFeatures.Queries;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Tools.Services;
public class SendOwnerQueryTests
{
private readonly ISendRepository _sendRepository;
private readonly IFeatureService _featureService;
private readonly IUserService _userService;
private readonly SendOwnerQuery _sendOwnerQuery;
private readonly Guid _currentUserId = Guid.NewGuid();
private readonly ClaimsPrincipal _user;
public SendOwnerQueryTests()
{
_sendRepository = Substitute.For<ISendRepository>();
_featureService = Substitute.For<IFeatureService>();
_userService = Substitute.For<IUserService>();
_user = new ClaimsPrincipal();
_userService.GetProperUserId(_user).Returns(_currentUserId);
_sendOwnerQuery = new SendOwnerQuery(_sendRepository, _featureService, _userService);
}
[Fact]
public async Task Get_WithValidSendOwnedByUser_ReturnsExpectedSend()
{
// Arrange
var sendId = Guid.NewGuid();
var expectedSend = CreateSend(sendId, _currentUserId);
_sendRepository.GetByIdAsync(sendId).Returns(expectedSend);
// Act
var result = await _sendOwnerQuery.Get(sendId, _user);
// Assert
Assert.Same(expectedSend, result);
await _sendRepository.Received(1).GetByIdAsync(sendId);
}
[Fact]
public async Task Get_WithNonExistentSend_ThrowsNotFoundException()
{
// Arrange
var sendId = Guid.NewGuid();
_sendRepository.GetByIdAsync(sendId).Returns((Send?)null);
// Act & Assert
await Assert.ThrowsAsync<NotFoundException>(() => _sendOwnerQuery.Get(sendId, _user));
}
[Fact]
public async Task Get_WithSendOwnedByDifferentUser_ThrowsNotFoundException()
{
// Arrange
var sendId = Guid.NewGuid();
var differentUserId = Guid.NewGuid();
var send = CreateSend(sendId, differentUserId);
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act & Assert
await Assert.ThrowsAsync<NotFoundException>(() => _sendOwnerQuery.Get(sendId, _user));
}
[Fact]
public async Task Get_WithNullCurrentUserId_ThrowsBadRequestException()
{
// Arrange
var sendId = Guid.NewGuid();
var send = CreateSend(sendId, _currentUserId);
_sendRepository.GetByIdAsync(sendId).Returns(send);
var nullUser = new ClaimsPrincipal();
_userService.GetProperUserId(nullUser).Returns((Guid?)null);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sendOwnerQuery.Get(sendId, nullUser));
Assert.Equal("invalid user.", exception.Message);
}
[Fact]
public async Task GetOwned_WithFeatureFlagEnabled_ReturnsAllSends()
{
// Arrange
var sends = new List<Send>
{
CreateSend(Guid.NewGuid(), _currentUserId, emails: null),
CreateSend(Guid.NewGuid(), _currentUserId, emails: "test@example.com"),
CreateSend(Guid.NewGuid(), _currentUserId, emails: "other@example.com")
};
_sendRepository.GetManyByUserIdAsync(_currentUserId).Returns(sends);
_featureService.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends).Returns(true);
// Act
var result = await _sendOwnerQuery.GetOwned(_user);
// Assert
Assert.Equal(3, result.Count);
Assert.Contains(sends[0], result);
Assert.Contains(sends[1], result);
Assert.Contains(sends[2], result);
await _sendRepository.Received(1).GetManyByUserIdAsync(_currentUserId);
_featureService.Received(1).IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends);
}
[Fact]
public async Task GetOwned_WithFeatureFlagDisabled_FiltersOutEmailOtpSends()
{
// Arrange
var sendWithoutEmails = CreateSend(Guid.NewGuid(), _currentUserId, emails: null);
var sendWithEmails = CreateSend(Guid.NewGuid(), _currentUserId, emails: "test@example.com");
var sends = new List<Send> { sendWithoutEmails, sendWithEmails };
_sendRepository.GetManyByUserIdAsync(_currentUserId).Returns(sends);
_featureService.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends).Returns(false);
// Act
var result = await _sendOwnerQuery.GetOwned(_user);
// Assert
Assert.Single(result);
Assert.Contains(sendWithoutEmails, result);
Assert.DoesNotContain(sendWithEmails, result);
await _sendRepository.Received(1).GetManyByUserIdAsync(_currentUserId);
_featureService.Received(1).IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends);
}
[Fact]
public async Task GetOwned_WithNullCurrentUserId_ThrowsBadRequestException()
{
// Arrange
var nullUser = new ClaimsPrincipal();
_userService.GetProperUserId(nullUser).Returns((Guid?)null);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sendOwnerQuery.GetOwned(nullUser));
Assert.Equal("invalid user.", exception.Message);
}
[Fact]
public async Task GetOwned_WithEmptyCollection_ReturnsEmptyCollection()
{
// Arrange
var emptySends = new List<Send>();
_sendRepository.GetManyByUserIdAsync(_currentUserId).Returns(emptySends);
_featureService.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends).Returns(true);
// Act
var result = await _sendOwnerQuery.GetOwned(_user);
// Assert
Assert.Empty(result);
await _sendRepository.Received(1).GetManyByUserIdAsync(_currentUserId);
}
private static Send CreateSend(Guid id, Guid userId, string? emails = null)
{
return new Send
{
Id = id,
UserId = userId,
Emails = emails
};
}
}

View File

@@ -1,4 +1,5 @@
using Bit.Core.Enums;
using Bit.Core.Dirt.Enums;
using Bit.Core.Enums;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;

View File

@@ -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())

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -95,6 +95,7 @@ public static class OrganizationTestHelpers
SyncSeats = false,
UseAutomaticUserConfirmation = true,
UsePhishingBlocker = true,
UseDisableSmAdsForUsers = true,
});
}

View File

@@ -675,6 +675,7 @@ public class OrganizationUserRepositoryTests
UseRiskInsights = false,
UseAdminSponsoredFamilies = false,
UsePhishingBlocker = false,
UseDisableSmAdsForUsers = false,
});
var organizationDomain = new OrganizationDomain

View File

@@ -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]