1
0
mirror of https://github.com/bitwarden/server synced 2025-12-31 15:43:16 +00:00

Merge branch 'main' into vault/pm-25957/sharing-cipher-to-org

This commit is contained in:
Nick Krantz
2025-10-24 08:55:21 -05:00
committed by GitHub
447 changed files with 49040 additions and 6302 deletions

View File

@@ -1,5 +1,7 @@
using Bit.Admin.AdminConsole.Controllers;
using Bit.Admin.AdminConsole.Models;
using Bit.Admin.Enums;
using Bit.Admin.Services;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
@@ -276,5 +278,40 @@ public class OrganizationsControllerTests
await providerBillingService.Received(1).ScaleSeats(provider, update.PlanType!.Value, update.Seats!.Value - organization.Seats.Value + organization.Seats.Value);
}
[BitAutoData]
[SutProviderCustomize]
[Theory]
public async Task Edit_UseAutomaticUserConfirmation_FullUpdate_SavesFeatureCorrectly(
Organization organization,
SutProvider<OrganizationsController> sutProvider)
{
// Arrange
var update = new OrganizationEditModel
{
PlanType = PlanType.TeamsMonthly,
UseAutomaticUserConfirmation = true
};
organization.UseAutomaticUserConfirmation = false;
sutProvider.GetDependency<IAccessControlService>()
.UserHasPermission(Permission.Org_Plan_Edit)
.Returns(true);
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
// Act
_ = await sutProvider.Sut.Edit(organization.Id, update);
// Assert
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(o => o.Id == organization.Id
&& o.UseAutomaticUserConfirmation == true));
// Annul
await organizationRepository.DeleteAsync(organization);
}
#endregion
}

View File

@@ -1,31 +1,81 @@
using System.Net.Http.Headers;
using System.Net;
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.Entities;
using Bit.Core.Enums;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Identity;
using NSubstitute;
using Xunit;
namespace Bit.Api.IntegrationTest.Controllers;
public class AccountsControllerTest : IClassFixture<ApiApplicationFactory>
public class AccountsControllerTest : IClassFixture<ApiApplicationFactory>, IAsyncLifetime
{
private readonly ApiApplicationFactory _factory;
private static readonly string _masterKeyWrappedUserKey =
"2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=";
public AccountsControllerTest(ApiApplicationFactory factory) => _factory = factory;
private static readonly string _masterPasswordHash = "master_password_hash";
private static readonly string _newMasterPasswordHash = "new_master_password_hash";
private static readonly KdfRequestModel _defaultKdfRequest =
new() { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 };
private readonly HttpClient _client;
private readonly ApiApplicationFactory _factory;
private readonly LoginHelper _loginHelper;
private readonly IUserRepository _userRepository;
private readonly IPushNotificationService _pushNotificationService;
private readonly IFeatureService _featureService;
private readonly IPasswordHasher<User> _passwordHasher;
private string _ownerEmail = null!;
public AccountsControllerTest(ApiApplicationFactory factory)
{
_factory = factory;
_factory.SubstituteService<IPushNotificationService>(_ => { });
_factory.SubstituteService<IFeatureService>(_ => { });
_client = factory.CreateClient();
_loginHelper = new LoginHelper(_factory, _client);
_userRepository = _factory.GetService<IUserRepository>();
_pushNotificationService = _factory.GetService<IPushNotificationService>();
_featureService = _factory.GetService<IFeatureService>();
_passwordHasher = _factory.GetService<IPasswordHasher<User>>();
}
public async Task InitializeAsync()
{
_ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
await _factory.LoginWithNewAccount(_ownerEmail);
}
public Task DisposeAsync()
{
_client.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task GetAccountsProfile_success()
{
var tokens = await _factory.LoginWithNewAccount();
var client = _factory.CreateClient();
await _loginHelper.LoginAsync(_ownerEmail);
using var message = new HttpRequestMessage(HttpMethod.Get, "/accounts/profile");
message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token);
var response = await client.SendAsync(message);
var response = await _client.SendAsync(message);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadFromJsonAsync<ProfileResponseModel>();
Assert.NotNull(content);
Assert.Equal("integration-test@bitwarden.com", content.Email);
Assert.Equal(_ownerEmail, content.Email);
Assert.NotNull(content.Name);
Assert.True(content.EmailVerified);
Assert.False(content.Premium);
@@ -35,4 +85,354 @@ public class AccountsControllerTest : IClassFixture<ApiApplicationFactory>
Assert.NotNull(content.PrivateKey);
Assert.NotNull(content.SecurityStamp);
}
[Theory]
[BitAutoData(KdfType.PBKDF2_SHA256, 600001, null, null)]
[BitAutoData(KdfType.Argon2id, 4, 65, 5)]
public async Task PostKdf_ValidRequestLogoutOnKdfChangeFeatureFlagOff_SuccessLogout(KdfType kdf,
int kdfIterations, int? kdfMemory, int? kdfParallelism)
{
var userBeforeKdfChange = await _userRepository.GetByEmailAsync(_ownerEmail);
Assert.NotNull(userBeforeKdfChange);
_featureService.IsEnabled(FeatureFlagKeys.NoLogoutOnKdfChange).Returns(false);
await _loginHelper.LoginAsync(_ownerEmail);
var kdfRequest = new KdfRequestModel
{
KdfType = kdf,
Iterations = kdfIterations,
Memory = kdfMemory,
Parallelism = kdfParallelism,
};
var response = await PostKdfWithKdfRequestAsync(kdfRequest);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
// Validate that the user fields were updated correctly
var user = await _userRepository.GetByEmailAsync(_ownerEmail);
Assert.NotNull(user);
Assert.Equal(kdfRequest.KdfType, user.Kdf);
Assert.Equal(kdfRequest.Iterations, user.KdfIterations);
Assert.Equal(kdfRequest.Memory, user.KdfMemory);
Assert.Equal(kdfRequest.Parallelism, user.KdfParallelism);
Assert.Equal(_masterKeyWrappedUserKey, user.Key);
Assert.NotNull(user.LastKdfChangeDate);
Assert.True(user.LastKdfChangeDate > DateTime.UtcNow.AddMinutes(-1));
Assert.True(user.RevisionDate > DateTime.UtcNow.AddMinutes(-1));
Assert.True(user.AccountRevisionDate > DateTime.UtcNow.AddMinutes(-1));
Assert.NotEqual(userBeforeKdfChange.SecurityStamp, user.SecurityStamp);
Assert.Equal(PasswordVerificationResult.Success,
_passwordHasher.VerifyHashedPassword(user, user.MasterPassword!, _newMasterPasswordHash));
// Validate push notification
await _pushNotificationService.Received(1).PushLogOutAsync(user.Id);
}
[Theory]
[BitAutoData(KdfType.PBKDF2_SHA256, 600001, null, null)]
[BitAutoData(KdfType.Argon2id, 4, 65, 5)]
public async Task PostKdf_ValidRequestLogoutOnKdfChangeFeatureFlagOn_SuccessSyncAndLogoutWithReason(KdfType kdf,
int kdfIterations, int? kdfMemory, int? kdfParallelism)
{
var userBeforeKdfChange = await _userRepository.GetByEmailAsync(_ownerEmail);
Assert.NotNull(userBeforeKdfChange);
_featureService.IsEnabled(FeatureFlagKeys.NoLogoutOnKdfChange).Returns(true);
await _loginHelper.LoginAsync(_ownerEmail);
var kdfRequest = new KdfRequestModel
{
KdfType = kdf,
Iterations = kdfIterations,
Memory = kdfMemory,
Parallelism = kdfParallelism,
};
var response = await PostKdfWithKdfRequestAsync(kdfRequest);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
// Validate that the user fields were updated correctly
var user = await _userRepository.GetByEmailAsync(_ownerEmail);
Assert.NotNull(user);
Assert.Equal(kdfRequest.KdfType, user.Kdf);
Assert.Equal(kdfRequest.Iterations, user.KdfIterations);
Assert.Equal(kdfRequest.Memory, user.KdfMemory);
Assert.Equal(kdfRequest.Parallelism, user.KdfParallelism);
Assert.Equal(_masterKeyWrappedUserKey, user.Key);
Assert.NotNull(user.LastKdfChangeDate);
Assert.True(user.LastKdfChangeDate > DateTime.UtcNow.AddMinutes(-1));
Assert.True(user.RevisionDate > DateTime.UtcNow.AddMinutes(-1));
Assert.True(user.AccountRevisionDate > DateTime.UtcNow.AddMinutes(-1));
Assert.Equal(userBeforeKdfChange.SecurityStamp, user.SecurityStamp);
Assert.Equal(PasswordVerificationResult.Success,
_passwordHasher.VerifyHashedPassword(user, user.MasterPassword!, _newMasterPasswordHash));
// Validate push notification
await _pushNotificationService.Received(1)
.PushLogOutAsync(user.Id, false, PushNotificationLogOutReason.KdfChange);
await _pushNotificationService.Received(1).PushSyncSettingsAsync(user.Id);
}
[Fact]
public async Task PostKdf_Unauthorized_ReturnsUnauthorized()
{
// Don't call LoginAsync to test unauthorized access
var response = await PostKdfWithKdfRequestAsync(_defaultKdfRequest);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Theory]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public async Task PostKdf_AuthenticationDataOrUnlockDataNull_BadRequest(bool authenticationDataNull,
bool unlockDataNull)
{
await _loginHelper.LoginAsync(_ownerEmail);
var authenticationData = authenticationDataNull
? null
: new MasterPasswordAuthenticationDataRequestModel
{
Kdf = _defaultKdfRequest,
MasterPasswordAuthenticationHash = _newMasterPasswordHash,
Salt = _ownerEmail
};
var unlockData = unlockDataNull
? null
: new MasterPasswordUnlockDataRequestModel
{
Kdf = _defaultKdfRequest,
MasterKeyWrappedUserKey = _masterKeyWrappedUserKey,
Salt = _ownerEmail
};
var response = await PostKdfAsync(authenticationData, unlockData);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("AuthenticationData and UnlockData must be provided.", content);
}
[Fact]
public async Task PostKdf_InvalidMasterPasswordHash_BadRequest()
{
await _loginHelper.LoginAsync(_ownerEmail);
var authenticationData = new MasterPasswordAuthenticationDataRequestModel
{
Kdf = _defaultKdfRequest,
MasterPasswordAuthenticationHash = _newMasterPasswordHash,
Salt = _ownerEmail
};
var unlockData = new MasterPasswordUnlockDataRequestModel
{
Kdf = _defaultKdfRequest,
MasterKeyWrappedUserKey = _masterKeyWrappedUserKey,
Salt = _ownerEmail
};
var requestModel = new PasswordRequestModel
{
MasterPasswordHash = "wrong-master-password-hash",
NewMasterPasswordHash = _newMasterPasswordHash,
Key = _masterKeyWrappedUserKey,
AuthenticationData = authenticationData,
UnlockData = unlockData
};
using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/kdf");
message.Content = JsonContent.Create(requestModel);
var response = await _client.SendAsync(message);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("Incorrect password", content);
}
[Fact]
public async Task PostKdf_ChangedSaltInAuthenticationData_BadRequest()
{
await _loginHelper.LoginAsync(_ownerEmail);
var authenticationData = new MasterPasswordAuthenticationDataRequestModel
{
Kdf = _defaultKdfRequest,
MasterPasswordAuthenticationHash = _newMasterPasswordHash,
Salt = "wrong-salt@bitwarden.com"
};
var unlockData = new MasterPasswordUnlockDataRequestModel
{
Kdf = _defaultKdfRequest,
MasterKeyWrappedUserKey = _masterKeyWrappedUserKey,
Salt = _ownerEmail
};
var response = await PostKdfAsync(authenticationData, unlockData);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("Invalid master password salt.", content);
}
[Fact]
public async Task PostKdf_ChangedSaltInUnlockData_BadRequest()
{
await _loginHelper.LoginAsync(_ownerEmail);
var authenticationData = new MasterPasswordAuthenticationDataRequestModel
{
Kdf = _defaultKdfRequest,
MasterPasswordAuthenticationHash = _newMasterPasswordHash,
Salt = _ownerEmail
};
var unlockData = new MasterPasswordUnlockDataRequestModel
{
Kdf = _defaultKdfRequest,
MasterKeyWrappedUserKey = _masterKeyWrappedUserKey,
Salt = "wrong-salt@bitwarden.com"
};
var response = await PostKdfAsync(authenticationData, unlockData);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("Invalid master password salt.", content);
}
[Fact]
public async Task PostKdf_KdfNotMatching_BadRequest()
{
await _loginHelper.LoginAsync(_ownerEmail);
var authenticationData = new MasterPasswordAuthenticationDataRequestModel
{
Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 },
MasterPasswordAuthenticationHash = _newMasterPasswordHash,
Salt = _ownerEmail
};
var unlockData = new MasterPasswordUnlockDataRequestModel
{
Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_001 },
MasterKeyWrappedUserKey = _masterKeyWrappedUserKey,
Salt = _ownerEmail
};
var response = await PostKdfAsync(authenticationData, unlockData);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("KDF settings must be equal for authentication and unlock.", content);
}
[Theory]
[InlineData(KdfType.PBKDF2_SHA256, 1, null, null)]
[InlineData(KdfType.Argon2id, 4, null, 5)]
[InlineData(KdfType.Argon2id, 4, 65, null)]
public async Task PostKdf_InvalidKdf_BadRequest(KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism)
{
await _loginHelper.LoginAsync(_ownerEmail);
var kdfRequest = new KdfRequestModel
{
KdfType = kdf,
Iterations = kdfIterations,
Memory = kdfMemory,
Parallelism = kdfParallelism
};
var response = await PostKdfWithKdfRequestAsync(kdfRequest);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("KDF settings are invalid", content);
}
[Fact]
public async Task PostKdf_InvalidNewMasterPassword_BadRequest()
{
var newMasterPasswordHash = "too-short";
await _loginHelper.LoginAsync(_ownerEmail);
var authenticationData = new MasterPasswordAuthenticationDataRequestModel
{
Kdf = _defaultKdfRequest,
MasterPasswordAuthenticationHash = newMasterPasswordHash,
Salt = _ownerEmail
};
var unlockData = new MasterPasswordUnlockDataRequestModel
{
Kdf = _defaultKdfRequest,
MasterKeyWrappedUserKey = _masterKeyWrappedUserKey,
Salt = _ownerEmail
};
var requestModel = new PasswordRequestModel
{
MasterPasswordHash = _masterPasswordHash,
NewMasterPasswordHash = newMasterPasswordHash,
Key = _masterKeyWrappedUserKey,
AuthenticationData = authenticationData,
UnlockData = unlockData
};
using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/kdf");
message.Content = JsonContent.Create(requestModel);
var response = await _client.SendAsync(message);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("Passwords must be at least", content);
}
private async Task<HttpResponseMessage> PostKdfWithKdfRequestAsync(KdfRequestModel kdfRequest)
{
var authenticationData = new MasterPasswordAuthenticationDataRequestModel
{
Kdf = kdfRequest,
MasterPasswordAuthenticationHash = _newMasterPasswordHash,
Salt = _ownerEmail
};
var unlockData = new MasterPasswordUnlockDataRequestModel
{
Kdf = kdfRequest,
MasterKeyWrappedUserKey = _masterKeyWrappedUserKey,
Salt = _ownerEmail
};
return await PostKdfAsync(authenticationData, unlockData);
}
private async Task<HttpResponseMessage> PostKdfAsync(
MasterPasswordAuthenticationDataRequestModel? authenticationDataRequest,
MasterPasswordUnlockDataRequestModel? unlockDataRequest)
{
var requestModel = new PasswordRequestModel
{
MasterPasswordHash = _masterPasswordHash,
NewMasterPasswordHash = _newMasterPasswordHash,
Key = _masterKeyWrappedUserKey,
AuthenticationData = authenticationDataRequest,
UnlockData = unlockDataRequest
};
using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/kdf");
message.Content = JsonContent.Create(requestModel);
return await _client.SendAsync(message);
}
}

View File

@@ -12,6 +12,10 @@ using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Entities;
using Bit.Core.KeyManagement.Enums;
using Bit.Core.KeyManagement.Models.Api.Request;
using Bit.Core.KeyManagement.Repositories;
using Bit.Core.Repositories;
using Bit.Core.Vault.Enums;
using Bit.Test.Common.AutoFixture.Attributes;
@@ -24,6 +28,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
{
private static readonly string _mockEncryptedString =
"2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=";
private static readonly string _mockEncryptedType7String = "7.AOs41Hd8OQiCPXjyJKCiDA==";
private readonly HttpClient _client;
private readonly IEmergencyAccessRepository _emergencyAccessRepository;
@@ -34,6 +39,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
private readonly IDeviceRepository _deviceRepository;
private readonly IPasswordHasher<User> _passwordHasher;
private readonly IOrganizationRepository _organizationRepository;
private readonly IUserSignatureKeyPairRepository _userSignatureKeyPairRepository;
private string _ownerEmail = null!;
public AccountsKeyManagementControllerTests(ApiApplicationFactory factory)
@@ -49,6 +55,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
_organizationUserRepository = _factory.GetService<IOrganizationUserRepository>();
_passwordHasher = _factory.GetService<IPasswordHasher<User>>();
_organizationRepository = _factory.GetService<IOrganizationRepository>();
_userSignatureKeyPairRepository = _factory.GetService<IUserSignatureKeyPairRepository>();
}
public async Task InitializeAsync()
@@ -200,6 +207,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
var password = _passwordHasher.HashPassword(user, "newMasterPassword");
user.MasterPassword = password;
user.PublicKey = "publicKey";
user.PrivateKey = _mockEncryptedString;
await _userRepository.ReplaceAsync(user);
request.AccountUnlockData.MasterPasswordUnlockData.KdfType = user.Kdf;
@@ -209,6 +217,8 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
request.AccountUnlockData.MasterPasswordUnlockData.Email = user.Email;
request.AccountKeys.AccountPublicKey = "publicKey";
request.AccountKeys.UserKeyEncryptedAccountPrivateKey = _mockEncryptedString;
request.AccountKeys.PublicKeyEncryptionKeyPair = null;
request.AccountKeys.SignatureKeyPair = null;
request.OldMasterKeyAuthenticationHash = "newMasterPassword";
@@ -354,4 +364,196 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
Assert.Equal(DateTime.UtcNow, user.RevisionDate, TimeSpan.FromMinutes(1));
Assert.Equal(DateTime.UtcNow, user.AccountRevisionDate, TimeSpan.FromMinutes(1));
}
[Theory]
[BitAutoData]
public async Task RotateV2UserAccountKeysAsync_Success(RotateUserAccountKeysAndDataRequestModel request)
{
await _loginHelper.LoginAsync(_ownerEmail);
var user = await _userRepository.GetByEmailAsync(_ownerEmail);
if (user == null)
{
throw new InvalidOperationException("User not found.");
}
var password = _passwordHasher.HashPassword(user, "newMasterPassword");
user.MasterPassword = password;
user.PublicKey = "publicKey";
user.PrivateKey = _mockEncryptedType7String;
await _userRepository.ReplaceAsync(user);
await _userSignatureKeyPairRepository.CreateAsync(new UserSignatureKeyPair
{
UserId = user.Id,
SignatureAlgorithm = SignatureAlgorithm.Ed25519,
SigningKey = _mockEncryptedType7String,
VerifyingKey = "verifyingKey",
});
request.AccountUnlockData.MasterPasswordUnlockData.KdfType = user.Kdf;
request.AccountUnlockData.MasterPasswordUnlockData.KdfIterations = user.KdfIterations;
request.AccountUnlockData.MasterPasswordUnlockData.KdfMemory = user.KdfMemory;
request.AccountUnlockData.MasterPasswordUnlockData.KdfParallelism = user.KdfParallelism;
request.AccountUnlockData.MasterPasswordUnlockData.Email = user.Email;
request.AccountKeys.AccountPublicKey = "publicKey";
request.AccountKeys.UserKeyEncryptedAccountPrivateKey = _mockEncryptedType7String;
request.AccountKeys.PublicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairRequestModel
{
PublicKey = "publicKey",
WrappedPrivateKey = _mockEncryptedType7String,
SignedPublicKey = "signedPublicKey",
};
request.AccountKeys.SignatureKeyPair = new SignatureKeyPairRequestModel
{
SignatureAlgorithm = "ed25519",
WrappedSigningKey = _mockEncryptedType7String,
VerifyingKey = "verifyingKey",
};
request.OldMasterKeyAuthenticationHash = "newMasterPassword";
request.AccountData.Ciphers =
[
new CipherWithIdRequestModel
{
Id = Guid.NewGuid(),
Type = CipherType.Login,
Name = _mockEncryptedString,
Login = new CipherLoginModel
{
Username = _mockEncryptedString,
Password = _mockEncryptedString,
},
},
];
request.AccountData.Folders = [
new FolderWithIdRequestModel
{
Id = Guid.NewGuid(),
Name = _mockEncryptedString,
},
];
request.AccountData.Sends = [
new SendWithIdRequestModel
{
Id = Guid.NewGuid(),
Name = _mockEncryptedString,
Key = _mockEncryptedString,
Disabled = false,
DeletionDate = DateTime.UtcNow.AddDays(1),
},
];
request.AccountUnlockData.MasterPasswordUnlockData.MasterKeyEncryptedUserKey = _mockEncryptedString;
request.AccountUnlockData.PasskeyUnlockData = [];
request.AccountUnlockData.DeviceKeyUnlockData = [];
request.AccountUnlockData.EmergencyAccessUnlockData = [];
request.AccountUnlockData.OrganizationAccountRecoveryUnlockData = [];
var response = await _client.PostAsJsonAsync("/accounts/key-management/rotate-user-account-keys", request);
var responseMessage = await response.Content.ReadAsStringAsync();
response.EnsureSuccessStatusCode();
var userNewState = await _userRepository.GetByEmailAsync(_ownerEmail);
Assert.NotNull(userNewState);
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.Email, userNewState.Email);
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.KdfType, userNewState.Kdf);
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.KdfIterations, userNewState.KdfIterations);
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.KdfMemory, userNewState.KdfMemory);
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.KdfParallelism, userNewState.KdfParallelism);
}
[Theory]
[BitAutoData]
public async Task RotateUpgradeToV2UserAccountKeysAsync_Success(RotateUserAccountKeysAndDataRequestModel request)
{
await _loginHelper.LoginAsync(_ownerEmail);
var user = await _userRepository.GetByEmailAsync(_ownerEmail);
if (user == null)
{
throw new InvalidOperationException("User not found.");
}
var password = _passwordHasher.HashPassword(user, "newMasterPassword");
user.MasterPassword = password;
user.PublicKey = "publicKey";
user.PrivateKey = _mockEncryptedString;
await _userRepository.ReplaceAsync(user);
request.AccountUnlockData.MasterPasswordUnlockData.KdfType = user.Kdf;
request.AccountUnlockData.MasterPasswordUnlockData.KdfIterations = user.KdfIterations;
request.AccountUnlockData.MasterPasswordUnlockData.KdfMemory = user.KdfMemory;
request.AccountUnlockData.MasterPasswordUnlockData.KdfParallelism = user.KdfParallelism;
request.AccountUnlockData.MasterPasswordUnlockData.Email = user.Email;
request.AccountKeys.AccountPublicKey = "publicKey";
request.AccountKeys.UserKeyEncryptedAccountPrivateKey = _mockEncryptedType7String;
request.AccountKeys.PublicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairRequestModel
{
PublicKey = "publicKey",
WrappedPrivateKey = _mockEncryptedType7String,
SignedPublicKey = "signedPublicKey",
};
request.AccountKeys.SignatureKeyPair = new SignatureKeyPairRequestModel
{
SignatureAlgorithm = "ed25519",
WrappedSigningKey = _mockEncryptedType7String,
VerifyingKey = "verifyingKey",
};
request.AccountKeys.SecurityState = new SecurityStateModel
{
SecurityVersion = 2,
SecurityState = "v2",
};
request.OldMasterKeyAuthenticationHash = "newMasterPassword";
request.AccountData.Ciphers =
[
new CipherWithIdRequestModel
{
Id = Guid.NewGuid(),
Type = CipherType.Login,
Name = _mockEncryptedString,
Login = new CipherLoginModel
{
Username = _mockEncryptedString,
Password = _mockEncryptedString,
},
},
];
request.AccountData.Folders = [
new FolderWithIdRequestModel
{
Id = Guid.NewGuid(),
Name = _mockEncryptedString,
},
];
request.AccountData.Sends = [
new SendWithIdRequestModel
{
Id = Guid.NewGuid(),
Name = _mockEncryptedString,
Key = _mockEncryptedString,
Disabled = false,
DeletionDate = DateTime.UtcNow.AddDays(1),
},
];
request.AccountUnlockData.MasterPasswordUnlockData.MasterKeyEncryptedUserKey = _mockEncryptedString;
request.AccountUnlockData.PasskeyUnlockData = [];
request.AccountUnlockData.DeviceKeyUnlockData = [];
request.AccountUnlockData.EmergencyAccessUnlockData = [];
request.AccountUnlockData.OrganizationAccountRecoveryUnlockData = [];
var response = await _client.PostAsJsonAsync("/accounts/key-management/rotate-user-account-keys", request);
var responseMessage = await response.Content.ReadAsStringAsync();
response.EnsureSuccessStatusCode();
var userNewState = await _userRepository.GetByEmailAsync(_ownerEmail);
Assert.NotNull(userNewState);
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.Email, userNewState.Email);
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.KdfType, userNewState.Kdf);
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.KdfIterations, userNewState.KdfIterations);
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.KdfMemory, userNewState.KdfMemory);
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.KdfParallelism, userNewState.KdfParallelism);
}
}

View File

@@ -34,7 +34,7 @@ public class SlackIntegrationControllerTests
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ISlackService>()
.ObtainTokenViaOAuth(_validSlackCode, Arg.Any<string>())
@@ -60,7 +60,7 @@ public class SlackIntegrationControllerTests
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
@@ -80,7 +80,7 @@ public class SlackIntegrationControllerTests
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
@@ -99,13 +99,13 @@ public class SlackIntegrationControllerTests
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ISlackService>()
.ObtainTokenViaOAuth(_validSlackCode, Arg.Any<string>())
.Returns(_slackToken);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(_validSlackCode, String.Empty));
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(_validSlackCode, string.Empty));
}
[Theory, BitAutoData]
@@ -116,7 +116,7 @@ public class SlackIntegrationControllerTests
var timeProvider = new FakeTimeProvider(new DateTime(2024, 4, 3, 2, 1, 0, DateTimeKind.Utc));
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ISlackService>()
.ObtainTokenViaOAuth(_validSlackCode, Arg.Any<string>())
@@ -135,7 +135,7 @@ public class SlackIntegrationControllerTests
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ISlackService>()
.ObtainTokenViaOAuth(_validSlackCode, Arg.Any<string>())
@@ -147,7 +147,7 @@ public class SlackIntegrationControllerTests
}
[Theory, BitAutoData]
public async Task CreateAsync_StateHasWrongOgranizationHash_ThrowsNotFound(
public async Task CreateAsync_StateHasWrongOrganizationHash_ThrowsNotFound(
SutProvider<SlackIntegrationController> sutProvider,
OrganizationIntegration integration,
OrganizationIntegration wrongOrgIntegration)
@@ -156,7 +156,7 @@ public class SlackIntegrationControllerTests
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ISlackService>()
.ObtainTokenViaOAuth(_validSlackCode, Arg.Any<string>())
@@ -179,7 +179,7 @@ public class SlackIntegrationControllerTests
integration.Configuration = "{}";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ISlackService>()
.ObtainTokenViaOAuth(_validSlackCode, Arg.Any<string>())
@@ -201,7 +201,7 @@ public class SlackIntegrationControllerTests
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ISlackService>()
.ObtainTokenViaOAuth(_validSlackCode, Arg.Any<string>())
@@ -224,7 +224,7 @@ public class SlackIntegrationControllerTests
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns(expectedUrl);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(integration.OrganizationId)
@@ -260,7 +260,7 @@ public class SlackIntegrationControllerTests
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns(expectedUrl);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
@@ -291,7 +291,7 @@ public class SlackIntegrationControllerTests
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns(expectedUrl);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
@@ -316,7 +316,7 @@ public class SlackIntegrationControllerTests
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == nameof(SlackIntegrationController.CreateAsync)))
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "SlackIntegration_Create"))
.Returns(expectedUrl);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)

View File

@@ -0,0 +1,392 @@
#nullable enable
using Bit.Api.AdminConsole.Controllers;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Context;
using Bit.Core.Enums;
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;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Integration.AspNet.Core;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Controllers;
[ControllerCustomize(typeof(TeamsIntegrationController))]
[SutProviderCustomize]
public class TeamsIntegrationControllerTests
{
private const string _teamsToken = "test-token";
private const string _validTeamsCode = "A_test_code";
[Theory, BitAutoData]
public async Task CreateAsync_AllParamsProvided_Succeeds(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Teams;
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
sutProvider.GetDependency<ITeamsService>()
.GetJoinedTeamsAsync(_teamsToken)
.Returns([
new TeamInfo() { DisplayName = "Test Team", Id = Guid.NewGuid().ToString(), TenantId = Guid.NewGuid().ToString() }
]);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(integration);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
var requestAction = await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString());
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.UpsertAsync(Arg.Any<OrganizationIntegration>());
Assert.IsType<CreatedResult>(requestAction);
}
[Theory, BitAutoData]
public async Task CreateAsync_CodeIsEmpty_ThrowsBadRequest(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Teams;
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(integration);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.CreateAsync(string.Empty, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_NoTeamsFound_ThrowsBadRequest(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Teams;
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
sutProvider.GetDependency<ITeamsService>()
.GetJoinedTeamsAsync(_teamsToken)
.Returns([]);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(integration);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_TeamsServiceReturnsEmptyToken_ThrowsBadRequest(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Teams;
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(integration);
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(string.Empty);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_StateEmpty_ThrowsNotFound(
SutProvider<TeamsIntegrationController> sutProvider)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, string.Empty));
}
[Theory, BitAutoData]
public async Task CreateAsync_StateExpired_ThrowsNotFound(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
var timeProvider = new FakeTimeProvider(new DateTime(2024, 4, 3, 2, 1, 0, DateTimeKind.Utc));
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
var state = IntegrationOAuthState.FromIntegration(integration, timeProvider);
timeProvider.Advance(TimeSpan.FromMinutes(30));
sutProvider.SetDependency<TimeProvider>(timeProvider);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_StateHasNonexistentIntegration_ThrowsNotFound(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_StateHasWrongOrganizationHash_ThrowsNotFound(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration,
OrganizationIntegration wrongOrgIntegration)
{
wrongOrgIntegration.Id = integration.Id;
wrongOrgIntegration.Type = IntegrationType.Teams;
wrongOrgIntegration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(wrongOrgIntegration);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_StateHasNonEmptyIntegration_ThrowsNotFound(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Teams;
integration.Configuration = "{}";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(integration);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task CreateAsync_StateHasNonTeamsIntegration_ThrowsNotFound(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Type = IntegrationType.Hec;
integration.Configuration = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns("https://localhost");
sutProvider.GetDependency<ITeamsService>()
.ObtainTokenViaOAuth(_validTeamsCode, Arg.Any<string>())
.Returns(_teamsToken);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(integration.Id)
.Returns(integration);
var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString()));
}
[Theory, BitAutoData]
public async Task RedirectAsync_Success(
SutProvider<TeamsIntegrationController> sutProvider,
OrganizationIntegration integration)
{
integration.Configuration = null;
var expectedUrl = "https://localhost/";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns(expectedUrl);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(integration.OrganizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(integration.OrganizationId)
.Returns([]);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegration>())
.Returns(integration);
sutProvider.GetDependency<ITeamsService>().GetRedirectUrl(Arg.Any<string>(), Arg.Any<string>()).Returns(expectedUrl);
var expectedState = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
var requestAction = await sutProvider.Sut.RedirectAsync(integration.OrganizationId);
Assert.IsType<RedirectResult>(requestAction);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.CreateAsync(Arg.Any<OrganizationIntegration>());
sutProvider.GetDependency<ITeamsService>().Received(1).GetRedirectUrl(Arg.Any<string>(), expectedState.ToString());
}
[Theory, BitAutoData]
public async Task RedirectAsync_IntegrationAlreadyExistsWithNullConfig_Success(
SutProvider<TeamsIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration integration)
{
integration.OrganizationId = organizationId;
integration.Configuration = null;
integration.Type = IntegrationType.Teams;
var expectedUrl = "https://localhost/";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns(expectedUrl);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(organizationId)
.Returns([integration]);
sutProvider.GetDependency<ITeamsService>().GetRedirectUrl(Arg.Any<string>(), Arg.Any<string>()).Returns(expectedUrl);
var requestAction = await sutProvider.Sut.RedirectAsync(organizationId);
var expectedState = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency<TimeProvider>());
Assert.IsType<RedirectResult>(requestAction);
sutProvider.GetDependency<ITeamsService>().Received(1).GetRedirectUrl(Arg.Any<string>(), expectedState.ToString());
}
[Theory, BitAutoData]
public async Task RedirectAsync_IntegrationAlreadyExistsWithConfig_ThrowsBadRequest(
SutProvider<TeamsIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration integration)
{
integration.OrganizationId = organizationId;
integration.Configuration = "{}";
integration.Type = IntegrationType.Teams;
var expectedUrl = "https://localhost/";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns(expectedUrl);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(organizationId)
.Returns([integration]);
sutProvider.GetDependency<ITeamsService>().GetRedirectUrl(Arg.Any<string>(), Arg.Any<string>()).Returns(expectedUrl);
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.RedirectAsync(organizationId));
}
[Theory, BitAutoData]
public async Task RedirectAsync_TeamsServiceReturnsEmpty_ThrowsNotFound(
SutProvider<TeamsIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration integration)
{
integration.OrganizationId = organizationId;
integration.Configuration = null;
var expectedUrl = "https://localhost/";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.Sut.Url
.RouteUrl(Arg.Is<UrlRouteContext>(c => c.RouteName == "TeamsIntegration_Create"))
.Returns(expectedUrl);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetManyByOrganizationAsync(organizationId)
.Returns([]);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegration>())
.Returns(integration);
sutProvider.GetDependency<ITeamsService>().GetRedirectUrl(Arg.Any<string>(), Arg.Any<string>()).Returns(string.Empty);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.RedirectAsync(organizationId));
}
[Theory, BitAutoData]
public async Task RedirectAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider<TeamsIntegrationController> sutProvider,
Guid organizationId)
{
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(false);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.RedirectAsync(organizationId));
}
[Theory, BitAutoData]
public async Task IncomingPostAsync_ForwardsToBot(SutProvider<TeamsIntegrationController> sutProvider)
{
var adapter = sutProvider.GetDependency<IBotFrameworkHttpAdapter>();
var bot = sutProvider.GetDependency<IBot>();
await sutProvider.Sut.IncomingPostAsync();
await adapter.Received(1).ProcessAsync(Arg.Any<HttpRequest>(), Arg.Any<HttpResponse>(), bot);
}
}

View File

@@ -39,7 +39,7 @@ public class OrganizationIntegrationConfigurationRequestModelTests
[Theory]
[InlineData(data: "")]
[InlineData(data: " ")]
public void IsValidForType_EmptyNonNullHecConfiguration_ReturnsFalse(string? config)
public void IsValidForType_EmptyNonNullConfiguration_ReturnsFalse(string? config)
{
var model = new OrganizationIntegrationConfigurationRequestModel
{
@@ -48,10 +48,12 @@ public class OrganizationIntegrationConfigurationRequestModelTests
};
Assert.False(condition: model.IsValidForType(IntegrationType.Hec));
Assert.False(condition: model.IsValidForType(IntegrationType.Datadog));
Assert.False(condition: model.IsValidForType(IntegrationType.Teams));
}
[Fact]
public void IsValidForType_NullHecConfiguration_ReturnsTrue()
public void IsValidForType_NullConfiguration_ReturnsTrue()
{
var model = new OrganizationIntegrationConfigurationRequestModel
{
@@ -60,32 +62,8 @@ public class OrganizationIntegrationConfigurationRequestModelTests
};
Assert.True(condition: model.IsValidForType(IntegrationType.Hec));
}
[Theory]
[InlineData(data: "")]
[InlineData(data: " ")]
public void IsValidForType_EmptyNonNullDatadogConfiguration_ReturnsFalse(string? config)
{
var model = new OrganizationIntegrationConfigurationRequestModel
{
Configuration = config,
Template = "template"
};
Assert.False(condition: model.IsValidForType(IntegrationType.Datadog));
}
[Fact]
public void IsValidForType_NullDatadogConfiguration_ReturnsTrue()
{
var model = new OrganizationIntegrationConfigurationRequestModel
{
Configuration = null,
Template = "template"
};
Assert.True(condition: model.IsValidForType(IntegrationType.Datadog));
Assert.True(condition: model.IsValidForType(IntegrationType.Teams));
}
[Theory]
@@ -107,6 +85,8 @@ public class OrganizationIntegrationConfigurationRequestModelTests
Assert.False(condition: model.IsValidForType(IntegrationType.Slack));
Assert.False(condition: model.IsValidForType(IntegrationType.Webhook));
Assert.False(condition: model.IsValidForType(IntegrationType.Hec));
Assert.False(condition: model.IsValidForType(IntegrationType.Datadog));
Assert.False(condition: model.IsValidForType(IntegrationType.Teams));
}
[Fact]
@@ -121,6 +101,8 @@ public class OrganizationIntegrationConfigurationRequestModelTests
Assert.False(condition: model.IsValidForType(IntegrationType.Slack));
Assert.False(condition: model.IsValidForType(IntegrationType.Webhook));
Assert.False(condition: model.IsValidForType(IntegrationType.Hec));
Assert.False(condition: model.IsValidForType(IntegrationType.Datadog));
Assert.False(condition: model.IsValidForType(IntegrationType.Teams));
}

View File

@@ -57,6 +57,22 @@ public class OrganizationIntegrationRequestModelTests
Assert.Contains("cannot be created directly", results[0].ErrorMessage);
}
[Fact]
public void Validate_Teams_ReturnsCannotBeCreatedDirectlyError()
{
var model = new OrganizationIntegrationRequestModel
{
Type = IntegrationType.Teams,
Configuration = null
};
var results = model.Validate(new ValidationContext(model)).ToList();
Assert.Single(results);
Assert.Contains(nameof(model.Type), results[0].MemberNames);
Assert.Contains("cannot be created directly", results[0].ErrorMessage);
}
[Fact]
public void Validate_Webhook_WithNullConfiguration_ReturnsNoErrors()
{

View File

@@ -1,8 +1,11 @@
#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.Test.Common.AutoFixture.Attributes;
using Xunit;
@@ -58,6 +61,46 @@ public class OrganizationIntegrationResponseModelTests
Assert.Equal(OrganizationIntegrationStatus.Completed, model.Status);
}
[Theory, BitAutoData]
public void Status_Teams_NullConfig_ReturnsInitiated(OrganizationIntegration oi)
{
oi.Type = IntegrationType.Teams;
oi.Configuration = null;
var model = new OrganizationIntegrationResponseModel(oi);
Assert.Equal(OrganizationIntegrationStatus.Initiated, model.Status);
}
[Theory, BitAutoData]
public void Status_Teams_WithTenantAndTeamsConfig_ReturnsInProgress(OrganizationIntegration oi)
{
oi.Type = IntegrationType.Teams;
oi.Configuration = JsonSerializer.Serialize(new TeamsIntegration(
TenantId: "tenant", Teams: [new TeamInfo() { DisplayName = "Team", Id = "TeamId", TenantId = "tenant" }]
));
var model = new OrganizationIntegrationResponseModel(oi);
Assert.Equal(OrganizationIntegrationStatus.InProgress, model.Status);
}
[Theory, BitAutoData]
public void Status_Teams_WithCompletedConfig_ReturnsCompleted(OrganizationIntegration oi)
{
oi.Type = IntegrationType.Teams;
oi.Configuration = JsonSerializer.Serialize(new TeamsIntegration(
TenantId: "tenant",
Teams: [new TeamInfo() { DisplayName = "Team", Id = "TeamId", TenantId = "tenant" }],
ServiceUrl: new Uri("https://example.com"),
ChannelId: "channellId"
));
var model = new OrganizationIntegrationResponseModel(oi);
Assert.Equal(OrganizationIntegrationStatus.Completed, model.Status);
}
[Theory, BitAutoData]
public void Status_Webhook_AlwaysCompleted(OrganizationIntegration oi)
{

View File

@@ -11,6 +11,8 @@ using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Kdf;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture.Attributes;
@@ -33,10 +35,10 @@ public class AccountsControllerTests : IDisposable
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand;
private readonly IFeatureService _featureService;
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
private readonly ITwoFactorEmailService _twoFactorEmailService;
private readonly IChangeKdfCommand _changeKdfCommand;
public AccountsControllerTests()
{
_userService = Substitute.For<IUserService>();
@@ -48,6 +50,7 @@ public class AccountsControllerTests : IDisposable
_twoFactorIsEnabledQuery = Substitute.For<ITwoFactorIsEnabledQuery>();
_tdeOffboardingPasswordCommand = Substitute.For<ITdeOffboardingPasswordCommand>();
_featureService = Substitute.For<IFeatureService>();
_userAccountKeysQuery = Substitute.For<IUserAccountKeysQuery>();
_twoFactorEmailService = Substitute.For<ITwoFactorEmailService>();
_changeKdfCommand = Substitute.For<IChangeKdfCommand>();
@@ -61,6 +64,7 @@ public class AccountsControllerTests : IDisposable
_tdeOffboardingPasswordCommand,
_twoFactorIsEnabledQuery,
_featureService,
_userAccountKeysQuery,
_twoFactorEmailService,
_changeKdfCommand
);
@@ -614,6 +618,16 @@ public class AccountsControllerTests : IDisposable
await _twoFactorEmailService.Received(1).SendNewDeviceVerificationEmailAsync(user);
}
[Theory]
[BitAutoData]
public async Task PostKdf_UserNotFound_ShouldFail(PasswordRequestModel model)
{
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult<User>(null));
// Act
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => _sut.PostKdf(model));
}
[Theory]
[BitAutoData]
public async Task PostKdf_WithNullAuthenticationData_ShouldFail(
@@ -623,7 +637,9 @@ public class AccountsControllerTests : IDisposable
model.AuthenticationData = null;
// Act
await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostKdf(model));
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostKdf(model));
Assert.Contains("AuthenticationData and UnlockData must be provided.", exception.Message);
}
[Theory]
@@ -635,7 +651,41 @@ public class AccountsControllerTests : IDisposable
model.UnlockData = null;
// Act
await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostKdf(model));
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostKdf(model));
Assert.Contains("AuthenticationData and UnlockData must be provided.", exception.Message);
}
[Theory]
[BitAutoData]
public async Task PostKdf_ChangeKdfFailed_ShouldFail(
User user, PasswordRequestModel model)
{
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult(user));
_changeKdfCommand.ChangeKdfAsync(Arg.Any<User>(), Arg.Any<string>(),
Arg.Any<MasterPasswordAuthenticationData>(), Arg.Any<MasterPasswordUnlockData>())
.Returns(Task.FromResult(IdentityResult.Failed(new IdentityError { Description = "Change KDF failed" })));
// Act
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostKdf(model));
Assert.NotNull(exception.ModelState);
Assert.Contains("Change KDF failed",
exception.ModelState.Values.SelectMany(x => x.Errors).Select(x => x.ErrorMessage));
}
[Theory]
[BitAutoData]
public async Task PostKdf_ChangeKdfSuccess_NoError(
User user, PasswordRequestModel model)
{
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(Task.FromResult(user));
_changeKdfCommand.ChangeKdfAsync(Arg.Any<User>(), Arg.Any<string>(),
Arg.Any<MasterPasswordAuthenticationData>(), Arg.Any<MasterPasswordUnlockData>())
.Returns(Task.FromResult(IdentityResult.Success));
// Act
await _sut.PostKdf(model);
}
// Below are helper functions that currently belong to this

View File

@@ -1,5 +1,4 @@
using Bit.Api.Billing.Controllers;
using Bit.Api.Billing.Models.Responses;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Organizations.Models;
@@ -53,19 +52,16 @@ public class OrganizationBillingControllerTests
{
sutProvider.GetDependency<ICurrentContext>().OrganizationUser(organizationId).Returns(true);
sutProvider.GetDependency<IOrganizationBillingService>().GetMetadata(organizationId)
.Returns(new OrganizationMetadata(true, true, true, true, true, true, true, null, null, null, 0));
.Returns(new OrganizationMetadata(true, 10));
var result = await sutProvider.Sut.GetMetadataAsync(organizationId);
Assert.IsType<Ok<OrganizationMetadataResponse>>(result);
Assert.IsType<Ok<OrganizationMetadata>>(result);
var response = ((Ok<OrganizationMetadataResponse>)result).Value;
var response = ((Ok<OrganizationMetadata>)result).Value;
Assert.True(response.IsEligibleForSelfHost);
Assert.True(response.IsManaged);
Assert.True(response.IsOnSecretsManagerStandalone);
Assert.True(response.IsSubscriptionUnpaid);
Assert.True(response.HasSubscription);
Assert.Equal(10, response.OrganizationOccupiedSeats);
}
[Theory, BitAutoData]

View File

@@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Entities;
using Bit.Core.Billing.Providers.Repositories;
@@ -270,7 +271,6 @@ public class ProviderBillingControllerTests
var subscription = new Subscription
{
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
CurrentPeriodEnd = new DateTime(now.Year, now.Month, daysInThisMonth),
Customer = new Customer
{
Address = new Address
@@ -291,20 +291,23 @@ public class ProviderBillingControllerTests
Data = [
new SubscriptionItem
{
CurrentPeriodEnd = new DateTime(now.Year, now.Month, daysInThisMonth),
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise }
},
new SubscriptionItem
{
CurrentPeriodEnd = new DateTime(now.Year, now.Month, daysInThisMonth),
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams }
}
]
},
Status = "unpaid",
Status = "unpaid"
};
stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId, Arg.Is<SubscriptionGetOptions>(
options =>
options.Expand.Contains("customer.tax_ids") &&
options.Expand.Contains("discounts") &&
options.Expand.Contains("test_clock"))).Returns(subscription);
var daysInLastMonth = DateTime.DaysInMonth(oneMonthAgo.Year, oneMonthAgo.Month);
@@ -365,7 +368,7 @@ public class ProviderBillingControllerTests
var response = ((Ok<ProviderSubscriptionResponse>)result).Value;
Assert.Equal(subscription.Status, response.Status);
Assert.Equal(subscription.CurrentPeriodEnd, response.CurrentPeriodEndDate);
Assert.Equal(subscription.GetCurrentPeriodEnd(), response.CurrentPeriodEndDate);
Assert.Equal(subscription.Customer!.Discount!.Coupon!.PercentOff, response.DiscountPercentage);
Assert.Equal(subscription.CollectionMethod, response.CollectionMethod);
@@ -405,6 +408,118 @@ public class ProviderBillingControllerTests
Assert.Equal(14, response.Suspension.GracePeriod);
}
[Theory, BitAutoData]
public async Task GetSubscriptionAsync_SubscriptionLevelDiscount_Ok(
Provider provider,
SutProvider<ProviderBillingController> sutProvider)
{
ConfigureStableProviderServiceUserInputs(provider, sutProvider);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var now = DateTime.UtcNow;
var oneMonthAgo = now.AddMonths(-1);
var daysInThisMonth = DateTime.DaysInMonth(now.Year, now.Month);
var subscription = new Subscription
{
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
Customer = new Customer
{
Address = new Address
{
Country = "US",
PostalCode = "12345",
Line1 = "123 Example St.",
Line2 = "Unit 1",
City = "Example Town",
State = "NY"
},
Balance = -100000,
Discount = null, // No customer-level discount
TaxIds = new StripeList<TaxId> { Data = [new TaxId { Value = "123456789" }] }
},
Discounts =
[
new Discount { Coupon = new Coupon { PercentOff = 15 } } // Subscription-level discount
],
Items = new StripeList<SubscriptionItem>
{
Data = [
new SubscriptionItem
{
CurrentPeriodEnd = new DateTime(now.Year, now.Month, daysInThisMonth),
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise }
},
new SubscriptionItem
{
CurrentPeriodEnd = new DateTime(now.Year, now.Month, daysInThisMonth),
Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams }
}
]
},
Status = "active"
};
stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId, Arg.Is<SubscriptionGetOptions>(
options =>
options.Expand.Contains("customer.tax_ids") &&
options.Expand.Contains("discounts") &&
options.Expand.Contains("test_clock"))).Returns(subscription);
stripeAdapter.InvoiceSearchAsync(Arg.Is<InvoiceSearchOptions>(
options => options.Query == $"subscription:'{subscription.Id}' status:'open'"))
.Returns([]);
var providerPlans = new List<ProviderPlan>
{
new ()
{
Id = Guid.NewGuid(),
ProviderId = provider.Id,
PlanType = PlanType.TeamsMonthly,
SeatMinimum = 50,
PurchasedSeats = 10,
AllocatedSeats = 60
},
new ()
{
Id = Guid.NewGuid(),
ProviderId = provider.Id,
PlanType = PlanType.EnterpriseMonthly,
SeatMinimum = 100,
PurchasedSeats = 0,
AllocatedSeats = 90
}
};
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
foreach (var providerPlan in providerPlans)
{
var plan = StaticStore.GetPlan(providerPlan.PlanType);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(providerPlan.PlanType).Returns(plan);
var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType);
sutProvider.GetDependency<IStripeAdapter>().PriceGetAsync(priceId)
.Returns(new Price
{
UnitAmountDecimal = plan.PasswordManager.ProviderPortalSeatPrice * 100
});
}
var result = await sutProvider.Sut.GetSubscriptionAsync(provider.Id);
Assert.IsType<Ok<ProviderSubscriptionResponse>>(result);
var response = ((Ok<ProviderSubscriptionResponse>)result).Value;
Assert.Equal(subscription.Status, response.Status);
Assert.Equal(subscription.GetCurrentPeriodEnd(), response.CurrentPeriodEndDate);
Assert.Equal(15, response.DiscountPercentage); // Verify subscription-level discount is used
Assert.Equal(subscription.CollectionMethod, response.CollectionMethod);
}
#endregion
#region UpdateTaxInformationAsync

View File

@@ -110,6 +110,7 @@ public class AccountsKeyManagementControllerTests
public async Task RotateUserAccountKeysSuccess(SutProvider<AccountsKeyManagementController> sutProvider,
RotateUserAccountKeysAndDataRequestModel data, User user)
{
data.AccountKeys.SignatureKeyPair = null;
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
sutProvider.GetDependency<IRotateUserAccountKeysCommand>().RotateUserAccountKeysAsync(Arg.Any<User>(), Arg.Any<RotateUserAccountKeysData>())
.Returns(IdentityResult.Success);
@@ -142,8 +143,60 @@ public class AccountsKeyManagementControllerTests
&& d.MasterPasswordUnlockData.MasterKeyAuthenticationHash == data.AccountUnlockData.MasterPasswordUnlockData.MasterKeyAuthenticationHash
&& d.MasterPasswordUnlockData.MasterKeyEncryptedUserKey == data.AccountUnlockData.MasterPasswordUnlockData.MasterKeyEncryptedUserKey
&& d.AccountPublicKey == data.AccountKeys.AccountPublicKey
&& d.UserKeyEncryptedAccountPrivateKey == data.AccountKeys.UserKeyEncryptedAccountPrivateKey
&& d.AccountKeys!.PublicKeyEncryptionKeyPairData.WrappedPrivateKey == data.AccountKeys.PublicKeyEncryptionKeyPair!.WrappedPrivateKey
&& d.AccountKeys!.PublicKeyEncryptionKeyPairData.PublicKey == data.AccountKeys.PublicKeyEncryptionKeyPair!.PublicKey
));
}
[Theory]
[BitAutoData]
public async Task RotateUserAccountKeys_UserCryptoV2_Success_Async(SutProvider<AccountsKeyManagementController> sutProvider,
RotateUserAccountKeysAndDataRequestModel data, User user)
{
data.AccountKeys.SignatureKeyPair = new SignatureKeyPairRequestModel
{
SignatureAlgorithm = "ed25519",
WrappedSigningKey = "wrappedSigningKey",
VerifyingKey = "verifyingKey"
};
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
sutProvider.GetDependency<IRotateUserAccountKeysCommand>().RotateUserAccountKeysAsync(Arg.Any<User>(), Arg.Any<RotateUserAccountKeysData>())
.Returns(IdentityResult.Success);
await sutProvider.Sut.RotateUserAccountKeysAsync(data);
await sutProvider.GetDependency<IRotationValidator<IEnumerable<EmergencyAccessWithIdRequestModel>, IEnumerable<EmergencyAccess>>>().Received(1)
.ValidateAsync(Arg.Any<User>(), Arg.Is(data.AccountUnlockData.EmergencyAccessUnlockData));
await sutProvider.GetDependency<IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>, IReadOnlyList<OrganizationUser>>>().Received(1)
.ValidateAsync(Arg.Any<User>(), Arg.Is(data.AccountUnlockData.OrganizationAccountRecoveryUnlockData));
await sutProvider.GetDependency<IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>>().Received(1)
.ValidateAsync(Arg.Any<User>(), Arg.Is(data.AccountUnlockData.PasskeyUnlockData));
await sutProvider.GetDependency<IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>>>().Received(1)
.ValidateAsync(Arg.Any<User>(), Arg.Is(data.AccountData.Ciphers));
await sutProvider.GetDependency<IRotationValidator<IEnumerable<FolderWithIdRequestModel>, IEnumerable<Folder>>>().Received(1)
.ValidateAsync(Arg.Any<User>(), Arg.Is(data.AccountData.Folders));
await sutProvider.GetDependency<IRotationValidator<IEnumerable<SendWithIdRequestModel>, IReadOnlyList<Send>>>().Received(1)
.ValidateAsync(Arg.Any<User>(), Arg.Is(data.AccountData.Sends));
await sutProvider.GetDependency<IRotateUserAccountKeysCommand>().Received(1)
.RotateUserAccountKeysAsync(Arg.Is(user), Arg.Is<RotateUserAccountKeysData>(d =>
d.OldMasterKeyAuthenticationHash == data.OldMasterKeyAuthenticationHash
&& d.MasterPasswordUnlockData.KdfType == data.AccountUnlockData.MasterPasswordUnlockData.KdfType
&& d.MasterPasswordUnlockData.KdfIterations == data.AccountUnlockData.MasterPasswordUnlockData.KdfIterations
&& d.MasterPasswordUnlockData.KdfMemory == data.AccountUnlockData.MasterPasswordUnlockData.KdfMemory
&& d.MasterPasswordUnlockData.KdfParallelism == data.AccountUnlockData.MasterPasswordUnlockData.KdfParallelism
&& d.MasterPasswordUnlockData.Email == data.AccountUnlockData.MasterPasswordUnlockData.Email
&& d.MasterPasswordUnlockData.MasterKeyAuthenticationHash == data.AccountUnlockData.MasterPasswordUnlockData.MasterKeyAuthenticationHash
&& d.MasterPasswordUnlockData.MasterKeyEncryptedUserKey == data.AccountUnlockData.MasterPasswordUnlockData.MasterKeyEncryptedUserKey
&& d.AccountKeys!.PublicKeyEncryptionKeyPairData.WrappedPrivateKey == data.AccountKeys.PublicKeyEncryptionKeyPair!.WrappedPrivateKey
&& d.AccountKeys!.PublicKeyEncryptionKeyPairData.PublicKey == data.AccountKeys.PublicKeyEncryptionKeyPair!.PublicKey
&& d.AccountKeys!.PublicKeyEncryptionKeyPairData.SignedPublicKey == data.AccountKeys.PublicKeyEncryptionKeyPair!.SignedPublicKey
&& d.AccountKeys!.SignatureKeyPairData!.SignatureAlgorithm == Core.KeyManagement.Enums.SignatureAlgorithm.Ed25519
&& d.AccountKeys!.SignatureKeyPairData.WrappedSigningKey == data.AccountKeys.SignatureKeyPair!.WrappedSigningKey
&& d.AccountKeys!.SignatureKeyPairData.VerifyingKey == data.AccountKeys.SignatureKeyPair!.VerifyingKey
));
}
@@ -153,6 +206,7 @@ public class AccountsKeyManagementControllerTests
public async Task RotateUserKeyNoUser_Throws(SutProvider<AccountsKeyManagementController> sutProvider,
RotateUserAccountKeysAndDataRequestModel data)
{
data.AccountKeys.SignatureKeyPair = null;
User? user = null;
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
sutProvider.GetDependency<IRotateUserAccountKeysCommand>().RotateUserAccountKeysAsync(Arg.Any<User>(), Arg.Any<RotateUserAccountKeysData>())
@@ -165,6 +219,7 @@ public class AccountsKeyManagementControllerTests
public async Task RotateUserKeyWrongData_Throws(SutProvider<AccountsKeyManagementController> sutProvider,
RotateUserAccountKeysAndDataRequestModel data, User user, IdentityErrorDescriber _identityErrorDescriber)
{
data.AccountKeys.SignatureKeyPair = null;
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
sutProvider.GetDependency<IRotateUserAccountKeysCommand>().RotateUserAccountKeysAsync(Arg.Any<User>(), Arg.Any<RotateUserAccountKeysData>())
.Returns(IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()));

View File

@@ -0,0 +1,112 @@
#nullable enable
using Bit.Api.KeyManagement.Controllers;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Enums;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Xunit;
namespace Bit.Api.Test.KeyManagement.Controllers;
[ControllerCustomize(typeof(UsersController))]
[SutProviderCustomize]
[JsonDocumentCustomize]
public class UsersControllerTests
{
[Theory]
[BitAutoData]
public async Task GetPublicKey_NotFound_ThrowsNotFoundException(
SutProvider<UsersController> sutProvider)
{
sutProvider.GetDependency<IUserRepository>().GetPublicKeyAsync(Arg.Any<Guid>()).ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.GetPublicKeyAsync(new Guid()));
}
[Theory]
[BitAutoData]
public async Task GetPublicKey_ReturnsUserKeyResponseModel(
SutProvider<UsersController> sutProvider,
Guid userId)
{
var publicKey = "publicKey";
sutProvider.GetDependency<IUserRepository>().GetPublicKeyAsync(userId).Returns(publicKey);
var result = await sutProvider.Sut.GetPublicKeyAsync(userId);
Assert.NotNull(result);
Assert.Equal(userId, result.UserId);
Assert.Equal(publicKey, result.PublicKey);
}
[Theory]
[BitAutoData]
public async Task GetAccountKeys_UserNotFound_ThrowsNotFoundException(
SutProvider<UsersController> sutProvider)
{
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(Arg.Any<Guid>()).ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.GetAccountKeysAsync(new Guid()));
}
[Theory]
[BitAutoData]
public async Task GetAccountKeys_ReturnsPublicUserKeysResponseModel(
SutProvider<UsersController> sutProvider,
Guid userId)
{
var user = new User
{
Id = userId,
PublicKey = "publicKey",
SignedPublicKey = "signedPublicKey",
};
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(userId).Returns(user);
sutProvider.GetDependency<IUserAccountKeysQuery>()
.Run(user)
.Returns(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData("wrappedPrivateKey", "publicKey", "signedPublicKey"),
SignatureKeyPairData = new SignatureKeyPairData(SignatureAlgorithm.Ed25519, "wrappedSigningKey", "verifyingKey"),
});
var result = await sutProvider.Sut.GetAccountKeysAsync(userId);
Assert.NotNull(result);
Assert.Equal("publicKey", result.PublicKey);
Assert.Equal("signedPublicKey", result.SignedPublicKey);
Assert.Equal("verifyingKey", result.VerifyingKey);
}
[Theory]
[BitAutoData]
public async Task GetAccountKeys_ReturnsPublicUserKeysResponseModel_WithNullVerifyingKey(
SutProvider<UsersController> sutProvider,
Guid userId)
{
var user = new User
{
Id = userId,
PublicKey = "publicKey",
SignedPublicKey = null,
};
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(userId).Returns(user);
sutProvider.GetDependency<IUserAccountKeysQuery>()
.Run(user)
.Returns(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData("wrappedPrivateKey", "publicKey", null),
SignatureKeyPairData = null,
});
var result = await sutProvider.Sut.GetAccountKeysAsync(userId);
Assert.NotNull(result);
Assert.Equal("publicKey", result.PublicKey);
Assert.Null(result.SignedPublicKey);
Assert.Null(result.VerifyingKey);
}
}

View File

@@ -0,0 +1,22 @@
#nullable enable
using Bit.Api.KeyManagement.Models.Requests;
using Xunit;
namespace Bit.Api.Test.KeyManagement.Models.Request;
public class SignatureKeyPairRequestModelTests
{
[Fact]
public void ToSignatureKeyPairData_WrongAlgorithm_Rejects()
{
var model = new SignatureKeyPairRequestModel
{
SignatureAlgorithm = "abc",
WrappedSigningKey = "wrappedKey",
VerifyingKey = "verifyingKey"
};
Assert.Throws<ArgumentException>(() => model.ToSignatureKeyPairData());
}
}

View File

@@ -75,6 +75,7 @@ public class ImportCiphersControllerTests
.With(x => x.Ciphers, fixture.Build<CipherRequestModel>()
.With(c => c.OrganizationId, Guid.NewGuid().ToString())
.With(c => c.FolderId, Guid.NewGuid().ToString())
.With(c => c.ArchivedDate, (DateTime?)null)
.CreateMany(1).ToArray())
.Create();
@@ -92,6 +93,37 @@ public class ImportCiphersControllerTests
);
}
[Theory, BitAutoData]
public async Task PostImportIndividual_WithArchivedDate_SavesArchivedDate(User user,
IFixture fixture, SutProvider<ImportCiphersController> sutProvider)
{
var archivedDate = DateTime.UtcNow;
sutProvider.GetDependency<GlobalSettings>()
.SelfHosted = false;
sutProvider.GetDependency<Core.Services.IUserService>()
.GetProperUserId(Arg.Any<ClaimsPrincipal>())
.Returns(user.Id);
var request = fixture.Build<ImportCiphersRequestModel>()
.With(x => x.Ciphers, fixture.Build<CipherRequestModel>()
.With(c => c.ArchivedDate, archivedDate)
.With(c => c.FolderId, (string)null)
.CreateMany(1).ToArray())
.Create();
await sutProvider.Sut.PostImport(request);
await sutProvider.GetDependency<IImportCiphersCommand>()
.Received()
.ImportIntoIndividualVaultAsync(
Arg.Any<List<Folder>>(),
Arg.Is<List<CipherDetails>>(ciphers => ciphers.First().ArchivedDate == archivedDate),
Arg.Any<IEnumerable<KeyValuePair<int, int>>>(),
user.Id
);
}
/****************************
* PostImport - Organization
****************************/
@@ -156,6 +188,7 @@ public class ImportCiphersControllerTests
.With(x => x.Ciphers, fixture.Build<CipherRequestModel>()
.With(c => c.OrganizationId, Guid.NewGuid().ToString())
.With(c => c.FolderId, Guid.NewGuid().ToString())
.With(c => c.ArchivedDate, (DateTime?)null)
.CreateMany(1).ToArray())
.With(y => y.Collections, fixture.Build<CollectionWithIdRequestModel>()
.With(c => c.Id, orgIdGuid)
@@ -227,6 +260,7 @@ public class ImportCiphersControllerTests
.With(x => x.Ciphers, fixture.Build<CipherRequestModel>()
.With(c => c.OrganizationId, Guid.NewGuid().ToString())
.With(c => c.FolderId, Guid.NewGuid().ToString())
.With(c => c.ArchivedDate, (DateTime?)null)
.CreateMany(1).ToArray())
.With(y => y.Collections, fixture.Build<CollectionWithIdRequestModel>()
.With(c => c.Id, orgIdGuid)
@@ -291,6 +325,7 @@ public class ImportCiphersControllerTests
.With(x => x.Ciphers, fixture.Build<CipherRequestModel>()
.With(c => c.OrganizationId, Guid.NewGuid().ToString())
.With(c => c.FolderId, Guid.NewGuid().ToString())
.With(c => c.ArchivedDate, (DateTime?)null)
.CreateMany(1).ToArray())
.With(y => y.Collections, fixture.Build<CollectionWithIdRequestModel>()
.With(c => c.Id, orgIdGuid)
@@ -354,6 +389,7 @@ public class ImportCiphersControllerTests
.With(x => x.Ciphers, fixture.Build<CipherRequestModel>()
.With(c => c.OrganizationId, Guid.NewGuid().ToString())
.With(c => c.FolderId, Guid.NewGuid().ToString())
.With(c => c.ArchivedDate, (DateTime?)null)
.CreateMany(1).ToArray())
.With(y => y.Collections, fixture.Build<CollectionWithIdRequestModel>()
.With(c => c.Id, orgIdGuid)
@@ -423,6 +459,7 @@ public class ImportCiphersControllerTests
Ciphers = fixture.Build<CipherRequestModel>()
.With(_ => _.OrganizationId, orgId.ToString())
.With(_ => _.FolderId, Guid.NewGuid().ToString())
.With(_ => _.ArchivedDate, (DateTime?)null)
.CreateMany(2).ToArray(),
CollectionRelationships = new List<KeyValuePair<int, int>>().ToArray(),
};
@@ -499,6 +536,7 @@ public class ImportCiphersControllerTests
Ciphers = fixture.Build<CipherRequestModel>()
.With(_ => _.OrganizationId, orgId.ToString())
.With(_ => _.FolderId, Guid.NewGuid().ToString())
.With(_ => _.ArchivedDate, (DateTime?)null)
.CreateMany(2).ToArray(),
CollectionRelationships = new List<KeyValuePair<int, int>>().ToArray(),
};
@@ -578,6 +616,7 @@ public class ImportCiphersControllerTests
Ciphers = fixture.Build<CipherRequestModel>()
.With(_ => _.OrganizationId, orgId.ToString())
.With(_ => _.FolderId, Guid.NewGuid().ToString())
.With(_ => _.ArchivedDate, (DateTime?)null)
.CreateMany(2).ToArray(),
CollectionRelationships = new List<KeyValuePair<int, int>>().ToArray(),
};
@@ -651,6 +690,7 @@ public class ImportCiphersControllerTests
Ciphers = fixture.Build<CipherRequestModel>()
.With(_ => _.OrganizationId, orgId.ToString())
.With(_ => _.FolderId, Guid.NewGuid().ToString())
.With(_ => _.ArchivedDate, (DateTime?)null)
.CreateMany(2).ToArray(),
CollectionRelationships = new List<KeyValuePair<int, int>>().ToArray(),
};
@@ -720,6 +760,7 @@ public class ImportCiphersControllerTests
Ciphers = fixture.Build<CipherRequestModel>()
.With(_ => _.OrganizationId, orgId.ToString())
.With(_ => _.FolderId, Guid.NewGuid().ToString())
.With(_ => _.ArchivedDate, (DateTime?)null)
.CreateMany(2).ToArray(),
CollectionRelationships = new List<KeyValuePair<int, int>>().ToArray(),
};
@@ -765,6 +806,63 @@ public class ImportCiphersControllerTests
Arg.Any<Guid>());
}
[Theory, BitAutoData]
public async Task PostImportOrganization_ThrowsException_WhenAnyCipherIsArchived(
SutProvider<ImportCiphersController> sutProvider,
IFixture fixture,
User user
)
{
var orgId = Guid.NewGuid();
sutProvider.GetDependency<GlobalSettings>()
.SelfHosted = false;
sutProvider.GetDependency<GlobalSettings>()
.ImportCiphersLimitation = _organizationCiphersLimitations;
SetupUserService(sutProvider, user);
var ciphers = fixture.Build<CipherRequestModel>()
.With(_ => _.ArchivedDate, DateTime.UtcNow)
.CreateMany(2).ToArray();
var request = new ImportOrganizationCiphersRequestModel
{
Collections = new List<CollectionWithIdRequestModel>().ToArray(),
Ciphers = ciphers,
CollectionRelationships = new List<KeyValuePair<int, int>>().ToArray(),
};
sutProvider.GetDependency<ICurrentContext>()
.AccessImportExport(Arg.Any<Guid>())
.Returns(false);
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(Arg.Any<ClaimsPrincipal>(),
Arg.Any<IEnumerable<Collection>>(),
Arg.Is<IEnumerable<IAuthorizationRequirement>>(reqs =>
reqs.Contains(BulkCollectionOperations.ImportCiphers)))
.Returns(AuthorizationResult.Failed());
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(Arg.Any<ClaimsPrincipal>(),
Arg.Any<IEnumerable<Collection>>(),
Arg.Is<IEnumerable<IAuthorizationRequirement>>(reqs =>
reqs.Contains(BulkCollectionOperations.Create)))
.Returns(AuthorizationResult.Success());
sutProvider.GetDependency<ICollectionRepository>()
.GetManyByOrganizationIdAsync(orgId)
.Returns(new List<Collection>());
var exception = await Assert.ThrowsAsync<BadRequestException>(async () =>
{
await sutProvider.Sut.PostImportOrganization(orgId.ToString(), request);
});
Assert.Equal("You cannot import archived items into an organization.", exception.Message);
}
private static void SetupUserService(SutProvider<ImportCiphersController> sutProvider, User user)
{
// This is a workaround for the NSubstitute issue with ambiguous arguments

View File

@@ -1790,6 +1790,118 @@ public class CiphersControllerTests
);
}
[Theory, BitAutoData]
public async Task PutShareMany_ArchivedCipher_ThrowsBadRequestException(
Guid organizationId,
Guid userId,
CipherWithIdRequestModel request,
SutProvider<CiphersController> sutProvider)
{
request.EncryptedFor = userId;
request.OrganizationId = organizationId.ToString();
request.ArchivedDate = DateTime.UtcNow;
var model = new CipherBulkShareRequestModel
{
Ciphers = [request],
CollectionIds = [Guid.NewGuid().ToString()]
};
sutProvider.GetDependency<ICurrentContext>()
.OrganizationUser(organizationId)
.Returns(Task.FromResult(true));
sutProvider.GetDependency<IUserService>()
.GetProperUserId(default)
.ReturnsForAnyArgs(userId);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.PutShareMany(model)
);
Assert.Equal("Cannot move archived items to an organization.", exception.Message);
}
[Theory, BitAutoData]
public async Task PutShareMany_ExistingCipherArchived_ThrowsBadRequestException(
Guid organizationId,
Guid userId,
CipherWithIdRequestModel request,
SutProvider<CiphersController> sutProvider)
{
// Request model does not have ArchivedDate (only the existing cipher does)
request.EncryptedFor = userId;
request.OrganizationId = organizationId.ToString();
request.ArchivedDate = null;
var model = new CipherBulkShareRequestModel
{
Ciphers = [request],
CollectionIds = [Guid.NewGuid().ToString()]
};
// The existing cipher from the repository IS archived
var existingCipher = new CipherDetails
{
Id = request.Id!.Value,
UserId = userId,
Type = CipherType.Login,
Data = JsonSerializer.Serialize(new CipherLoginData()),
ArchivedDate = DateTime.UtcNow
};
sutProvider.GetDependency<ICurrentContext>()
.OrganizationUser(organizationId)
.Returns(Task.FromResult(true));
sutProvider.GetDependency<IUserService>()
.GetProperUserId(default)
.ReturnsForAnyArgs(userId);
sutProvider.GetDependency<ICipherRepository>()
.GetManyByUserIdAsync(userId, withOrganizations: false)
.Returns(Task.FromResult((ICollection<CipherDetails>)[existingCipher]));
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.PutShareMany(model)
);
Assert.Equal("Cannot move archived items to an organization.", exception.Message);
}
[Theory, BitAutoData]
public async Task PutShare_ArchivedCipher_ThrowsBadRequestException(
Guid cipherId,
Guid organizationId,
User user,
CipherShareRequestModel model,
SutProvider<CiphersController> sutProvider)
{
model.Cipher.OrganizationId = organizationId.ToString();
model.Cipher.EncryptedFor = user.Id;
var cipher = new Cipher
{
Id = cipherId,
UserId = user.Id,
ArchivedDate = DateTime.UtcNow.AddDays(-1),
Type = CipherType.Login,
Data = JsonSerializer.Serialize(new CipherLoginData())
};
sutProvider.GetDependency<IUserService>()
.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>())
.Returns(user);
sutProvider.GetDependency<ICipherRepository>()
.GetByIdAsync(cipherId)
.Returns(cipher);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationUser(organizationId)
.Returns(Task.FromResult(true));
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.PutShare(cipherId, model)
);
Assert.Equal("Cannot move an archived item to an organization.", exception.Message);
}
[Theory, BitAutoData]
public async Task PostPurge_WhenUserNotFound_ThrowsUnauthorizedAccessException(
SecretVerificationRequestModel model,

View File

@@ -12,6 +12,8 @@ using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
@@ -74,6 +76,7 @@ public class SyncControllerTests
var policyRepository = sutProvider.GetDependency<IPolicyRepository>();
var collectionRepository = sutProvider.GetDependency<ICollectionRepository>();
var collectionCipherRepository = sutProvider.GetDependency<ICollectionCipherRepository>();
var userAccountKeysQuery = sutProvider.GetDependency<IUserAccountKeysQuery>();
// Adjust random data to match required formats / test intentions
user.EquivalentDomains = JsonSerializer.Serialize(userEquivalentDomains);
@@ -98,6 +101,11 @@ public class SyncControllerTests
// Setup returns
userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).ReturnsForAnyArgs(user);
userAccountKeysQuery.Run(user).Returns(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = user.GetPublicKeyEncryptionKeyPair(),
SignatureKeyPairData = null,
});
organizationUserRepository
.GetManyDetailsByUserAsync(user.Id, OrganizationUserStatusType.Confirmed).Returns(organizationUserDetails);
@@ -127,7 +135,6 @@ public class SyncControllerTests
// Execute GET
var result = await sutProvider.Sut.Get();
// Asserts
// Assert that methods are called
var hasEnabledOrgs = organizationUserDetails.Any(o => o.Enabled);
@@ -166,6 +173,7 @@ public class SyncControllerTests
var policyRepository = sutProvider.GetDependency<IPolicyRepository>();
var collectionRepository = sutProvider.GetDependency<ICollectionRepository>();
var collectionCipherRepository = sutProvider.GetDependency<ICollectionCipherRepository>();
var userAccountKeysQuery = sutProvider.GetDependency<IUserAccountKeysQuery>();
// Adjust random data to match required formats / test intentions
user.EquivalentDomains = JsonSerializer.Serialize(userEquivalentDomains);
@@ -189,6 +197,11 @@ public class SyncControllerTests
// Setup returns
userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).ReturnsForAnyArgs(user);
userAccountKeysQuery.Run(user).Returns(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = user.GetPublicKeyEncryptionKeyPair(),
SignatureKeyPairData = null,
});
organizationUserRepository
.GetManyDetailsByUserAsync(user.Id, OrganizationUserStatusType.Confirmed).Returns(organizationUserDetails);
@@ -256,6 +269,7 @@ public class SyncControllerTests
var policyRepository = sutProvider.GetDependency<IPolicyRepository>();
var collectionRepository = sutProvider.GetDependency<ICollectionRepository>();
var collectionCipherRepository = sutProvider.GetDependency<ICollectionCipherRepository>();
var userAccountKeysQuery = sutProvider.GetDependency<IUserAccountKeysQuery>();
// Adjust random data to match required formats / test intentions
user.EquivalentDomains = JsonSerializer.Serialize(userEquivalentDomains);
@@ -290,6 +304,12 @@ public class SyncControllerTests
twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(user).Returns(false);
userService.HasPremiumFromOrganization(user).Returns(false);
userAccountKeysQuery.Run(user).Returns(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = user.GetPublicKeyEncryptionKeyPair(),
SignatureKeyPairData = null,
});
// Execute GET
var result = await sutProvider.Sut.Get();
@@ -327,6 +347,13 @@ public class SyncControllerTests
user.MasterPassword = null;
var userAccountKeysQuery = sutProvider.GetDependency<IUserAccountKeysQuery>();
userAccountKeysQuery.Run(user).Returns(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = user.GetPublicKeyEncryptionKeyPair(),
SignatureKeyPairData = null,
});
var userService = sutProvider.GetDependency<IUserService>();
userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).ReturnsForAnyArgs(user);
@@ -352,6 +379,13 @@ public class SyncControllerTests
user.KdfMemory = kdfMemory;
user.KdfParallelism = kdfParallelism;
var userAccountKeysQuery = sutProvider.GetDependency<IUserAccountKeysQuery>();
userAccountKeysQuery.Run(user).Returns(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = user.GetPublicKeyEncryptionKeyPair(),
SignatureKeyPairData = null,
});
var userService = sutProvider.GetDependency<IUserService>();
userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).ReturnsForAnyArgs(user);

View File

@@ -27,24 +27,6 @@
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\Events\charge.succeeded.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</EmbeddedResource>
<EmbeddedResource Include="Resources\Events\customer.subscription.updated.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</EmbeddedResource>
<EmbeddedResource Include="Resources\Events\customer.updated.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</EmbeddedResource>
<EmbeddedResource Include="Resources\Events\invoice.created.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</EmbeddedResource>
<EmbeddedResource Include="Resources\Events\invoice.upcoming.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</EmbeddedResource>
<EmbeddedResource Include="Resources\Events\payment_method.attached.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</EmbeddedResource>
<EmbeddedResource Include="Resources\IPN\echeck-payment.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</EmbeddedResource>
@@ -73,9 +55,6 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</EmbeddedResource>
<None Remove="Resources\Events\invoice.finalized.json" />
<EmbeddedResource Include="Resources\Events\invoice.finalized.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</EmbeddedResource>
</ItemGroup>
</Project>

View File

@@ -169,7 +169,7 @@ public class FreshdeskControllerTests
[BitAutoData(WebhookKey)]
public async Task PostWebhookOnyxAi_success(
string freshdeskWebhookKey, FreshdeskOnyxAiWebhookModel model,
OnyxAnswerWithCitationResponseModel onyxResponse,
OnyxResponseModel onyxResponse,
SutProvider<FreshdeskController> sutProvider)
{
var billingSettings = sutProvider.GetDependency<IOptions<BillingSettings>>().Value;

View File

@@ -1,130 +0,0 @@
{
"id": "evt_3NvKgBIGBnsLynRr0pJJqudS",
"object": "event",
"api_version": "2024-06-20",
"created": 1695909300,
"data": {
"object": {
"id": "ch_3NvKgBIGBnsLynRr0ZyvP9AN",
"object": "charge",
"amount": 7200,
"amount_captured": 7200,
"amount_refunded": 0,
"application": null,
"application_fee": null,
"application_fee_amount": null,
"balance_transaction": "txn_3NvKgBIGBnsLynRr0KbYEz76",
"billing_details": {
"address": {
"city": null,
"country": null,
"line1": null,
"line2": null,
"postal_code": null,
"state": null
},
"email": null,
"name": null,
"phone": null
},
"calculated_statement_descriptor": "BITWARDEN",
"captured": true,
"created": 1695909299,
"currency": "usd",
"customer": "cus_OimAwOzQmThNXx",
"description": "Subscription update",
"destination": null,
"dispute": null,
"disputed": false,
"failure_balance_transaction": null,
"failure_code": null,
"failure_message": null,
"fraud_details": {
},
"invoice": "in_1NvKgBIGBnsLynRrmRFHAcoV",
"livemode": false,
"metadata": {
},
"on_behalf_of": null,
"order": null,
"outcome": {
"network_status": "approved_by_network",
"reason": null,
"risk_level": "normal",
"risk_score": 37,
"seller_message": "Payment complete.",
"type": "authorized"
},
"paid": true,
"payment_intent": "pi_3NvKgBIGBnsLynRr09Ny3Heu",
"payment_method": "pm_1NvKbpIGBnsLynRrcOwez4A1",
"payment_method_details": {
"card": {
"amount_authorized": 7200,
"brand": "visa",
"checks": {
"address_line1_check": null,
"address_postal_code_check": null,
"cvc_check": "pass"
},
"country": "US",
"exp_month": 6,
"exp_year": 2033,
"extended_authorization": {
"status": "disabled"
},
"fingerprint": "0VgUBpvqcUUnuSmK",
"funding": "credit",
"incremental_authorization": {
"status": "unavailable"
},
"installments": null,
"last4": "4242",
"mandate": null,
"multicapture": {
"status": "unavailable"
},
"network": "visa",
"network_token": {
"used": false
},
"overcapture": {
"maximum_amount_capturable": 7200,
"status": "unavailable"
},
"three_d_secure": null,
"wallet": null
},
"type": "card"
},
"receipt_email": "cturnbull@bitwarden.com",
"receipt_number": null,
"receipt_url": "https://pay.stripe.com/receipts/invoices/CAcaFwoVYWNjdF8xOXNtSVhJR0Juc0x5blJyKLSL1qgGMgYTnk_JOUA6LBY_SDEZNtuae1guQ6Dlcuev1TUHwn712t-UNnZdIc383zS15bXv_1dby8e4?s=ap",
"refunded": false,
"refunds": {
"object": "list",
"data": [
],
"has_more": false,
"total_count": 0,
"url": "/v1/charges/ch_3NvKgBIGBnsLynRr0ZyvP9AN/refunds"
},
"review": null,
"shipping": null,
"source": null,
"source_transfer": null,
"statement_descriptor": null,
"statement_descriptor_suffix": null,
"status": "succeeded",
"transfer_data": null,
"transfer_group": null
}
},
"livemode": false,
"pending_webhooks": 9,
"request": {
"id": "req_rig8N5Ca8EXYRy",
"idempotency_key": "db75068d-5d90-4c65-a410-4e2ed8347509"
},
"type": "charge.succeeded"
}

View File

@@ -1,177 +0,0 @@
{
"id": "evt_1NvLMDIGBnsLynRr6oBxebrE",
"object": "event",
"api_version": "2024-06-20",
"created": 1695911902,
"data": {
"object": {
"id": "sub_1NvKoKIGBnsLynRrcLIAUWGf",
"object": "subscription",
"application": null,
"application_fee_percent": null,
"automatic_tax": {
"enabled": false
},
"billing_cycle_anchor": 1695911900,
"billing_thresholds": null,
"cancel_at": null,
"cancel_at_period_end": false,
"canceled_at": null,
"cancellation_details": {
"comment": null,
"feedback": null,
"reason": null
},
"collection_method": "charge_automatically",
"created": 1695909804,
"currency": "usd",
"current_period_end": 1727534300,
"current_period_start": 1695911900,
"customer": "cus_OimNNCC3RiI2HQ",
"days_until_due": null,
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [
],
"description": null,
"discount": null,
"ended_at": null,
"items": {
"object": "list",
"data": [
{
"id": "si_OimNgVtrESpqus",
"object": "subscription_item",
"billing_thresholds": null,
"created": 1695909805,
"metadata": {
},
"plan": {
"id": "enterprise-org-seat-annually",
"object": "plan",
"active": true,
"aggregate_usage": null,
"amount": 3600,
"amount_decimal": "3600",
"billing_scheme": "per_unit",
"created": 1494268677,
"currency": "usd",
"interval": "year",
"interval_count": 1,
"livemode": false,
"metadata": {
},
"nickname": "2019 Enterprise Seat (Annually)",
"product": "prod_BUtogGemxnTi9z",
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"price": {
"id": "enterprise-org-seat-annually",
"object": "price",
"active": true,
"billing_scheme": "per_unit",
"created": 1494268677,
"currency": "usd",
"custom_unit_amount": null,
"livemode": false,
"lookup_key": null,
"metadata": {
},
"nickname": "2019 Enterprise Seat (Annually)",
"product": "prod_BUtogGemxnTi9z",
"recurring": {
"aggregate_usage": null,
"interval": "year",
"interval_count": 1,
"trial_period_days": null,
"usage_type": "licensed"
},
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "recurring",
"unit_amount": 3600,
"unit_amount_decimal": "3600"
},
"quantity": 1,
"subscription": "sub_1NvKoKIGBnsLynRrcLIAUWGf",
"tax_rates": [
]
}
],
"has_more": false,
"total_count": 1,
"url": "/v1/subscription_items?subscription=sub_1NvKoKIGBnsLynRrcLIAUWGf"
},
"latest_invoice": "in_1NvLM9IGBnsLynRrOysII07d",
"livemode": false,
"metadata": {
"organizationId": "84a569ea-4643-474a-83a9-b08b00e7a20d"
},
"next_pending_invoice_item_invoice": null,
"on_behalf_of": null,
"pause_collection": null,
"payment_settings": {
"payment_method_options": null,
"payment_method_types": null,
"save_default_payment_method": "off"
},
"pending_invoice_item_interval": null,
"pending_setup_intent": null,
"pending_update": null,
"plan": {
"id": "enterprise-org-seat-annually",
"object": "plan",
"active": true,
"aggregate_usage": null,
"amount": 3600,
"amount_decimal": "3600",
"billing_scheme": "per_unit",
"created": 1494268677,
"currency": "usd",
"interval": "year",
"interval_count": 1,
"livemode": false,
"metadata": {
},
"nickname": "2019 Enterprise Seat (Annually)",
"product": "prod_BUtogGemxnTi9z",
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"quantity": 1,
"schedule": null,
"start_date": 1695909804,
"status": "active",
"test_clock": null,
"transfer_data": null,
"trial_end": 1695911899,
"trial_settings": {
"end_behavior": {
"missing_payment_method": "create_invoice"
}
},
"trial_start": 1695909804
},
"previous_attributes": {
"billing_cycle_anchor": 1696514604,
"current_period_end": 1696514604,
"current_period_start": 1695909804,
"latest_invoice": "in_1NvKoKIGBnsLynRrSNRC6oYI",
"status": "trialing",
"trial_end": 1696514604
}
},
"livemode": false,
"pending_webhooks": 8,
"request": {
"id": "req_DMZPUU3BI66zAx",
"idempotency_key": "3fd8b4a5-6a20-46ab-9f45-b37b02a8017f"
},
"type": "customer.subscription.updated"
}

View File

@@ -1,311 +0,0 @@
{
"id": "evt_1NvKjSIGBnsLynRrS3MTK4DZ",
"object": "event",
"account": "acct_19smIXIGBnsLynRr",
"api_version": "2024-06-20",
"created": 1695909502,
"data": {
"object": {
"id": "cus_Of54kUr3gV88lM",
"object": "customer",
"address": {
"city": null,
"country": "US",
"line1": "",
"line2": null,
"postal_code": "33701",
"state": null
},
"balance": 0,
"created": 1695056798,
"currency": "usd",
"default_source": "src_1NtAfeIGBnsLynRrYDrceax7",
"delinquent": false,
"description": "Premium User",
"discount": null,
"email": "premium@bitwarden.com",
"invoice_prefix": "C506E8CE",
"invoice_settings": {
"custom_fields": [
{
"name": "Subscriber",
"value": "Premium User"
}
],
"default_payment_method": "pm_1Nrku9IGBnsLynRrcsQ3hy6C",
"footer": null,
"rendering_options": null
},
"livemode": false,
"metadata": {
"region": "US"
},
"name": null,
"next_invoice_sequence": 2,
"phone": null,
"preferred_locales": [
],
"shipping": null,
"tax_exempt": "none",
"test_clock": null,
"account_balance": 0,
"cards": {
"object": "list",
"data": [
],
"has_more": false,
"total_count": 0,
"url": "/v1/customers/cus_Of54kUr3gV88lM/cards"
},
"default_card": null,
"default_currency": "usd",
"sources": {
"object": "list",
"data": [
{
"id": "src_1NtAfeIGBnsLynRrYDrceax7",
"object": "source",
"ach_credit_transfer": {
"account_number": "test_b2d1c6415f6f",
"routing_number": "110000000",
"fingerprint": "ePO4hBQanSft3gvU",
"swift_code": "TSTEZ122",
"bank_name": "TEST BANK",
"refund_routing_number": null,
"refund_account_holder_type": null,
"refund_account_holder_name": null
},
"amount": null,
"client_secret": "src_client_secret_bUAP2uDRw6Pwj0xYk32LmJ3K",
"created": 1695394170,
"currency": "usd",
"customer": "cus_Of54kUr3gV88lM",
"flow": "receiver",
"livemode": false,
"metadata": {
},
"owner": {
"address": null,
"email": "amount_0@stripe.com",
"name": null,
"phone": null,
"verified_address": null,
"verified_email": null,
"verified_name": null,
"verified_phone": null
},
"receiver": {
"address": "110000000-test_b2d1c6415f6f",
"amount_charged": 0,
"amount_received": 0,
"amount_returned": 0,
"refund_attributes_method": "email",
"refund_attributes_status": "missing"
},
"statement_descriptor": null,
"status": "pending",
"type": "ach_credit_transfer",
"usage": "reusable"
}
],
"has_more": false,
"total_count": 1,
"url": "/v1/customers/cus_Of54kUr3gV88lM/sources"
},
"subscriptions": {
"object": "list",
"data": [
{
"id": "sub_1NrkuBIGBnsLynRrzjFGIjEw",
"object": "subscription",
"application": null,
"application_fee_percent": null,
"automatic_tax": {
"enabled": false
},
"billing": "charge_automatically",
"billing_cycle_anchor": 1695056799,
"billing_thresholds": null,
"cancel_at": null,
"cancel_at_period_end": false,
"canceled_at": null,
"cancellation_details": {
"comment": null,
"feedback": null,
"reason": null
},
"collection_method": "charge_automatically",
"created": 1695056799,
"currency": "usd",
"current_period_end": 1726679199,
"current_period_start": 1695056799,
"customer": "cus_Of54kUr3gV88lM",
"days_until_due": null,
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [
],
"description": null,
"discount": null,
"ended_at": null,
"invoice_customer_balance_settings": {
"consume_applied_balance_on_void": true
},
"items": {
"object": "list",
"data": [
{
"id": "si_Of54i3aK9I5Wro",
"object": "subscription_item",
"billing_thresholds": null,
"created": 1695056800,
"metadata": {
},
"plan": {
"id": "premium-annually",
"object": "plan",
"active": true,
"aggregate_usage": null,
"amount": 1000,
"amount_decimal": "1000",
"billing_scheme": "per_unit",
"created": 1499289328,
"currency": "usd",
"interval": "year",
"interval_count": 1,
"livemode": false,
"metadata": {
},
"name": "Premium (Annually)",
"nickname": "Premium (Annually)",
"product": "prod_BUqgYr48VzDuCg",
"statement_description": null,
"statement_descriptor": null,
"tiers": null,
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"price": {
"id": "premium-annually",
"object": "price",
"active": true,
"billing_scheme": "per_unit",
"created": 1499289328,
"currency": "usd",
"custom_unit_amount": null,
"livemode": false,
"lookup_key": null,
"metadata": {
},
"nickname": "Premium (Annually)",
"product": "prod_BUqgYr48VzDuCg",
"recurring": {
"aggregate_usage": null,
"interval": "year",
"interval_count": 1,
"trial_period_days": null,
"usage_type": "licensed"
},
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "recurring",
"unit_amount": 1000,
"unit_amount_decimal": "1000"
},
"quantity": 1,
"subscription": "sub_1NrkuBIGBnsLynRrzjFGIjEw",
"tax_rates": [
]
}
],
"has_more": false,
"total_count": 1,
"url": "/v1/subscription_items?subscription=sub_1NrkuBIGBnsLynRrzjFGIjEw"
},
"latest_invoice": "in_1NrkuBIGBnsLynRr40gyJTVU",
"livemode": false,
"metadata": {
"userId": "91f40b6d-ac3b-4348-804b-b0810119ac6a"
},
"next_pending_invoice_item_invoice": null,
"on_behalf_of": null,
"pause_collection": null,
"payment_settings": {
"payment_method_options": null,
"payment_method_types": null,
"save_default_payment_method": "off"
},
"pending_invoice_item_interval": null,
"pending_setup_intent": null,
"pending_update": null,
"plan": {
"id": "premium-annually",
"object": "plan",
"active": true,
"aggregate_usage": null,
"amount": 1000,
"amount_decimal": "1000",
"billing_scheme": "per_unit",
"created": 1499289328,
"currency": "usd",
"interval": "year",
"interval_count": 1,
"livemode": false,
"metadata": {
},
"name": "Premium (Annually)",
"nickname": "Premium (Annually)",
"product": "prod_BUqgYr48VzDuCg",
"statement_description": null,
"statement_descriptor": null,
"tiers": null,
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"quantity": 1,
"schedule": null,
"start": 1695056799,
"start_date": 1695056799,
"status": "active",
"tax_percent": null,
"test_clock": null,
"transfer_data": null,
"trial_end": null,
"trial_settings": {
"end_behavior": {
"missing_payment_method": "create_invoice"
}
},
"trial_start": null
}
],
"has_more": false,
"total_count": 1,
"url": "/v1/customers/cus_Of54kUr3gV88lM/subscriptions"
},
"tax_ids": {
"object": "list",
"data": [
],
"has_more": false,
"total_count": 0,
"url": "/v1/customers/cus_Of54kUr3gV88lM/tax_ids"
},
"tax_info": null,
"tax_info_verification": null
},
"previous_attributes": {
"email": "premium-new@bitwarden.com"
}
},
"livemode": false,
"pending_webhooks": 5,
"request": "req_2RtGdXCfiicFLx",
"type": "customer.updated",
"user_id": "acct_19smIXIGBnsLynRr"
}

View File

@@ -1,222 +0,0 @@
{
"id": "evt_1NvKzfIGBnsLynRr0SkwrlkE",
"object": "event",
"api_version": "2024-06-20",
"created": 1695910506,
"data": {
"object": {
"id": "in_1NvKzdIGBnsLynRr8fE8cpbg",
"object": "invoice",
"account_country": "US",
"account_name": "Bitwarden Inc.",
"account_tax_ids": null,
"amount_due": 0,
"amount_paid": 0,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": true,
"auto_advance": false,
"automatic_tax": {
"enabled": false,
"status": null
},
"billing_reason": "subscription_create",
"charge": null,
"collection_method": "charge_automatically",
"created": 1695910505,
"currency": "usd",
"custom_fields": [
{
"name": "Organization",
"value": "teams 2023 monthly - 2"
}
],
"customer": "cus_OimYrxnMTMMK1E",
"customer_address": {
"city": null,
"country": "US",
"line1": "",
"line2": null,
"postal_code": "12345",
"state": null
},
"customer_email": "cturnbull@bitwarden.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [
],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [
],
"description": null,
"discount": null,
"discounts": [
],
"due_date": null,
"effective_at": 1695910505,
"ending_balance": 0,
"footer": null,
"from_invoice": null,
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_19smIXIGBnsLynRr/test_YWNjdF8xOXNtSVhJR0Juc0x5blJyLF9PaW1ZVlo4dFRtbkNQQVY5aHNpckQxN1QzRHBPcVBOLDg2NDUxMzA30200etYRHca2?s=ap",
"invoice_pdf": "https://pay.stripe.com/invoice/acct_19smIXIGBnsLynRr/test_YWNjdF8xOXNtSVhJR0Juc0x5blJyLF9PaW1ZVlo4dFRtbkNQQVY5aHNpckQxN1QzRHBPcVBOLDg2NDUxMzA30200etYRHca2/pdf?s=ap",
"last_finalization_error": null,
"latest_revision": null,
"lines": {
"object": "list",
"data": [
{
"id": "il_1NvKzdIGBnsLynRr2pS4ZA8e",
"object": "line_item",
"amount": 0,
"amount_excluding_tax": 0,
"currency": "usd",
"description": "Trial period for Teams Organization Seat",
"discount_amounts": [
],
"discountable": true,
"discounts": [
],
"livemode": false,
"metadata": {
"organizationId": "3fbc84ce-102d-4919-b89b-b08b00ead71a"
},
"period": {
"end": 1696515305,
"start": 1695910505
},
"plan": {
"id": "2020-teams-org-seat-monthly",
"object": "plan",
"active": true,
"aggregate_usage": null,
"amount": 400,
"amount_decimal": "400",
"billing_scheme": "per_unit",
"created": 1595263113,
"currency": "usd",
"interval": "month",
"interval_count": 1,
"livemode": false,
"metadata": {
},
"nickname": "Teams Organization Seat (Monthly) 2023",
"product": "prod_HgOooYXDr2DDAA",
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"price": {
"id": "2020-teams-org-seat-monthly",
"object": "price",
"active": true,
"billing_scheme": "per_unit",
"created": 1595263113,
"currency": "usd",
"custom_unit_amount": null,
"livemode": false,
"lookup_key": null,
"metadata": {
},
"nickname": "Teams Organization Seat (Monthly) 2023",
"product": "prod_HgOooYXDr2DDAA",
"recurring": {
"aggregate_usage": null,
"interval": "month",
"interval_count": 1,
"trial_period_days": null,
"usage_type": "licensed"
},
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "recurring",
"unit_amount": 400,
"unit_amount_decimal": "400"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 1,
"subscription": "sub_1NvKzdIGBnsLynRrKIHQamZc",
"subscription_item": "si_OimYNSbvuqdtTr",
"tax_amounts": [
],
"tax_rates": [
],
"type": "subscription",
"unit_amount_excluding_tax": "0"
}
],
"has_more": false,
"total_count": 1,
"url": "/v1/invoices/in_1NvKzdIGBnsLynRr8fE8cpbg/lines"
},
"livemode": false,
"metadata": {
},
"next_payment_attempt": null,
"number": "3E96D078-0001",
"on_behalf_of": null,
"paid": true,
"paid_out_of_band": false,
"payment_intent": null,
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
"payment_method_types": null
},
"period_end": 1695910505,
"period_start": 1695910505,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": null,
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
"statement_descriptor": null,
"status": "paid",
"status_transitions": {
"finalized_at": 1695910505,
"marked_uncollectible_at": null,
"paid_at": 1695910505,
"voided_at": null
},
"subscription": "sub_1NvKzdIGBnsLynRrKIHQamZc",
"subscription_details": {
"metadata": {
"organizationId": "3fbc84ce-102d-4919-b89b-b08b00ead71a"
}
},
"subtotal": 0,
"subtotal_excluding_tax": 0,
"tax": null,
"test_clock": null,
"total": 0,
"total_discount_amounts": [
],
"total_excluding_tax": 0,
"total_tax_amounts": [
],
"transfer_data": null,
"webhooks_delivered_at": null
}
},
"livemode": false,
"pending_webhooks": 8,
"request": {
"id": "req_roIwONfgyfZdr4",
"idempotency_key": "dd2a171b-b9c7-4d2d-89d5-1ceae3c0595d"
},
"type": "invoice.created"
}

View File

@@ -1,400 +0,0 @@
{
"id": "evt_1PQaABIGBnsLynRrhoJjGnyz",
"object": "event",
"account": "acct_19smIXIGBnsLynRr",
"api_version": "2024-06-20",
"created": 1718133319,
"data": {
"object": {
"id": "in_1PQa9fIGBnsLynRraYIqTdBs",
"object": "invoice",
"account_country": "US",
"account_name": "Bitwarden Inc.",
"account_tax_ids": null,
"amount_due": 84240,
"amount_paid": 0,
"amount_remaining": 84240,
"amount_shipping": 0,
"application": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": true,
"automatic_tax": {
"enabled": true,
"liability": {
"type": "self"
},
"status": "complete"
},
"billing_reason": "subscription_update",
"charge": null,
"collection_method": "send_invoice",
"created": 1718133291,
"currency": "usd",
"custom_fields": [
{
"name": "Provider",
"value": "MSP"
}
],
"customer": "cus_QH8QVKyTh2lfcG",
"customer_address": {
"city": null,
"country": "US",
"line1": null,
"line2": null,
"postal_code": "12345",
"state": null
},
"customer_email": "billing@msp.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [
],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [
],
"description": null,
"discount": {
"id": "di_1PQa9eIGBnsLynRrwwYr2bGD",
"object": "discount",
"checkout_session": null,
"coupon": {
"id": "msp-discount-35",
"object": "coupon",
"amount_off": null,
"created": 1678805729,
"currency": null,
"duration": "forever",
"duration_in_months": null,
"livemode": false,
"max_redemptions": null,
"metadata": {
},
"name": "MSP Discount - 35%",
"percent_off": 35,
"redeem_by": null,
"times_redeemed": 515,
"valid": true,
"percent_off_precise": 35
},
"customer": "cus_QH8QVKyTh2lfcG",
"end": null,
"invoice": null,
"invoice_item": null,
"promotion_code": null,
"start": 1718133290,
"subscription": null,
"subscription_item": null
},
"discounts": [
"di_1PQa9eIGBnsLynRrwwYr2bGD"
],
"due_date": 1720725291,
"effective_at": 1718136893,
"ending_balance": 0,
"footer": null,
"from_invoice": null,
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_19smIXIGBnsLynRr/test_YWNjdF8xOXNtSVhJR0Juc0x5blJyLF9RSDhRYVNIejNDMXBMVXAzM0M3S2RwaUt1Z3NuVHVzLDEwODY3NDEyMg0200RT8cC2nw?s=ap",
"invoice_pdf": "https://pay.stripe.com/invoice/acct_19smIXIGBnsLynRr/test_YWNjdF8xOXNtSVhJR0Juc0x5blJyLF9RSDhRYVNIejNDMXBMVXAzM0M3S2RwaUt1Z3NuVHVzLDEwODY3NDEyMg0200RT8cC2nw/pdf?s=ap",
"issuer": {
"type": "self"
},
"last_finalization_error": null,
"latest_revision": null,
"lines": {
"object": "list",
"data": [
{
"id": "sub_1PQa9fIGBnsLynRr83lNrFHa",
"object": "line_item",
"amount": 50000,
"amount_excluding_tax": 50000,
"currency": "usd",
"description": null,
"discount_amounts": [
{
"amount": 17500,
"discount": "di_1PQa9eIGBnsLynRrwwYr2bGD"
}
],
"discountable": true,
"discounts": [
],
"invoice": "in_1PQa9fIGBnsLynRraYIqTdBs",
"livemode": false,
"metadata": {
},
"period": {
"end": 1720725291,
"start": 1718133291
},
"plan": {
"id": "2023-teams-org-seat-monthly",
"object": "plan",
"active": true,
"aggregate_usage": null,
"amount": 500,
"amount_decimal": "500",
"billing_scheme": "per_unit",
"created": 1695839010,
"currency": "usd",
"interval": "month",
"interval_count": 1,
"livemode": false,
"metadata": {
},
"meter": null,
"nickname": "Teams Organization Seat (Monthly)",
"product": "prod_HgOooYXDr2DDAA",
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed",
"name": "Password Manager - Teams Plan",
"statement_description": null,
"statement_descriptor": null,
"tiers": null
},
"price": {
"id": "2023-teams-org-seat-monthly",
"object": "price",
"active": true,
"billing_scheme": "per_unit",
"created": 1695839010,
"currency": "usd",
"custom_unit_amount": null,
"livemode": false,
"lookup_key": null,
"metadata": {
},
"nickname": "Teams Organization Seat (Monthly)",
"product": "prod_HgOooYXDr2DDAA",
"recurring": {
"aggregate_usage": null,
"interval": "month",
"interval_count": 1,
"meter": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"tax_behavior": "exclusive",
"tiers_mode": null,
"transform_quantity": null,
"type": "recurring",
"unit_amount": 500,
"unit_amount_decimal": "500"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 100,
"subscription": null,
"subscription_item": "si_QH8Qo4WEJxOVwx",
"tax_amounts": [
{
"amount": 2600,
"inclusive": false,
"tax_rate": "txr_1OZyBuIGBnsLynRrX0PJLuMC",
"taxability_reason": "standard_rated",
"taxable_amount": 32500
}
],
"tax_rates": [
],
"type": "subscription",
"unit_amount_excluding_tax": "500",
"unique_id": "il_1PQa9fIGBnsLynRrSJ3cxrdU",
"unique_line_item_id": "sli_1acb3eIGBnsLynRr4b9c2f48"
},
{
"id": "sub_1PQa9fIGBnsLynRr83lNrFHa",
"object": "line_item",
"amount": 70000,
"amount_excluding_tax": 70000,
"currency": "usd",
"description": null,
"discount_amounts": [
{
"amount": 24500,
"discount": "di_1PQa9eIGBnsLynRrwwYr2bGD"
}
],
"discountable": true,
"discounts": [
],
"invoice": "in_1PQa9fIGBnsLynRraYIqTdBs",
"livemode": false,
"metadata": {
},
"period": {
"end": 1720725291,
"start": 1718133291
},
"plan": {
"id": "2023-enterprise-seat-monthly",
"object": "plan",
"active": true,
"aggregate_usage": null,
"amount": 700,
"amount_decimal": "700",
"billing_scheme": "per_unit",
"created": 1695152194,
"currency": "usd",
"interval": "month",
"interval_count": 1,
"livemode": false,
"metadata": {
},
"meter": null,
"nickname": "Enterprise Organization (Monthly)",
"product": "prod_HgSOgzUlYDFOzf",
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed",
"name": "Password Manager - Enterprise Plan",
"statement_description": null,
"statement_descriptor": null,
"tiers": null
},
"price": {
"id": "2023-enterprise-seat-monthly",
"object": "price",
"active": true,
"billing_scheme": "per_unit",
"created": 1695152194,
"currency": "usd",
"custom_unit_amount": null,
"livemode": false,
"lookup_key": null,
"metadata": {
},
"nickname": "Enterprise Organization (Monthly)",
"product": "prod_HgSOgzUlYDFOzf",
"recurring": {
"aggregate_usage": null,
"interval": "month",
"interval_count": 1,
"meter": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"tax_behavior": "exclusive",
"tiers_mode": null,
"transform_quantity": null,
"type": "recurring",
"unit_amount": 700,
"unit_amount_decimal": "700"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 100,
"subscription": null,
"subscription_item": "si_QH8QUjtceXvcis",
"tax_amounts": [
{
"amount": 3640,
"inclusive": false,
"tax_rate": "txr_1OZyBuIGBnsLynRrX0PJLuMC",
"taxability_reason": "standard_rated",
"taxable_amount": 45500
}
],
"tax_rates": [
],
"type": "subscription",
"unit_amount_excluding_tax": "700",
"unique_id": "il_1PQa9fIGBnsLynRrVviet37m",
"unique_line_item_id": "sli_11b229IGBnsLynRr837b79d0"
}
],
"has_more": false,
"total_count": 2,
"url": "/v1/invoices/in_1PQa9fIGBnsLynRraYIqTdBs/lines"
},
"livemode": false,
"metadata": {
},
"next_payment_attempt": null,
"number": "525EB050-0001",
"on_behalf_of": null,
"paid": false,
"paid_out_of_band": false,
"payment_intent": "pi_3PQaA7IGBnsLynRr1swr9XJE",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
"payment_method_types": null
},
"period_end": 1718133291,
"period_start": 1718133291,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": null,
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
"statement_descriptor": null,
"status": "open",
"status_transitions": {
"finalized_at": 1718136893,
"marked_uncollectible_at": null,
"paid_at": null,
"voided_at": null
},
"subscription": "sub_1PQa9fIGBnsLynRr83lNrFHa",
"subscription_details": {
"metadata": {
"providerId": "655bc5a3-2332-4201-a9a6-b18c013d0572"
}
},
"subtotal": 120000,
"subtotal_excluding_tax": 120000,
"tax": 6240,
"test_clock": "clock_1PQaA4IGBnsLynRrptkZjgxc",
"total": 84240,
"total_discount_amounts": [
{
"amount": 42000,
"discount": "di_1PQa9eIGBnsLynRrwwYr2bGD"
}
],
"total_excluding_tax": 78000,
"total_tax_amounts": [
{
"amount": 6240,
"inclusive": false,
"tax_rate": "txr_1OZyBuIGBnsLynRrX0PJLuMC",
"taxability_reason": "standard_rated",
"taxable_amount": 78000
}
],
"transfer_data": null,
"webhooks_delivered_at": 1718133293,
"application_fee": null,
"billing": "send_invoice",
"closed": false,
"date": 1718133291,
"finalized_at": 1718136893,
"forgiven": false,
"payment": null,
"statement_description": null,
"tax_percent": 8
}
},
"livemode": false,
"pending_webhooks": 5,
"request": null,
"type": "invoice.finalized",
"user_id": "acct_19smIXIGBnsLynRr"
}

View File

@@ -1,225 +0,0 @@
{
"id": "evt_1Nv0w8IGBnsLynRrZoDVI44u",
"object": "event",
"api_version": "2024-06-20",
"created": 1695833408,
"data": {
"object": {
"object": "invoice",
"account_country": "US",
"account_name": "Bitwarden Inc.",
"account_tax_ids": null,
"amount_due": 0,
"amount_paid": 0,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"automatic_tax": {
"enabled": true,
"status": "complete"
},
"billing_reason": "upcoming",
"charge": null,
"collection_method": "charge_automatically",
"created": 1697128681,
"currency": "usd",
"custom_fields": null,
"customer": "cus_M8DV9wiyNa2JxQ",
"customer_address": {
"city": null,
"country": "US",
"line1": "",
"line2": null,
"postal_code": "90019",
"state": null
},
"customer_email": "vphan@bitwarden.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [
],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [
],
"description": null,
"discount": null,
"discounts": [
],
"due_date": null,
"effective_at": null,
"ending_balance": -6779,
"footer": null,
"from_invoice": null,
"last_finalization_error": null,
"latest_revision": null,
"lines": {
"object": "list",
"data": [
{
"id": "il_tmp_12b5e8IGBnsLynRr1996ac3a",
"object": "line_item",
"amount": 2000,
"amount_excluding_tax": 2000,
"currency": "usd",
"description": "5 × 2019 Enterprise Seat (Monthly) (at $4.00 / month)",
"discount_amounts": [
],
"discountable": true,
"discounts": [
],
"livemode": false,
"metadata": {
},
"period": {
"end": 1699807081,
"start": 1697128681
},
"plan": {
"id": "enterprise-org-seat-monthly",
"object": "plan",
"active": true,
"aggregate_usage": null,
"amount": 400,
"amount_decimal": "400",
"billing_scheme": "per_unit",
"created": 1494268635,
"currency": "usd",
"interval": "month",
"interval_count": 1,
"livemode": false,
"metadata": {
},
"nickname": "2019 Enterprise Seat (Monthly)",
"product": "prod_BVButYytPSlgs6",
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"price": {
"id": "enterprise-org-seat-monthly",
"object": "price",
"active": true,
"billing_scheme": "per_unit",
"created": 1494268635,
"currency": "usd",
"custom_unit_amount": null,
"livemode": false,
"lookup_key": null,
"metadata": {
},
"nickname": "2019 Enterprise Seat (Monthly)",
"product": "prod_BVButYytPSlgs6",
"recurring": {
"aggregate_usage": null,
"interval": "month",
"interval_count": 1,
"trial_period_days": null,
"usage_type": "licensed"
},
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "recurring",
"unit_amount": 400,
"unit_amount_decimal": "400"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 5,
"subscription": "sub_1NQxz4IGBnsLynRr1KbitG7v",
"subscription_item": "si_ODOmLnPDHBuMxX",
"tax_amounts": [
{
"amount": 0,
"inclusive": false,
"tax_rate": "txr_1N6XCyIGBnsLynRr0LHs4AUD",
"taxability_reason": "product_exempt",
"taxable_amount": 0
}
],
"tax_rates": [
],
"type": "subscription",
"unit_amount_excluding_tax": "400"
}
],
"has_more": false,
"total_count": 1,
"url": "/v1/invoices/upcoming/lines?customer=cus_M8DV9wiyNa2JxQ&subscription=sub_1NQxz4IGBnsLynRr1KbitG7v"
},
"livemode": false,
"metadata": {
},
"next_payment_attempt": 1697132281,
"number": null,
"on_behalf_of": null,
"paid": false,
"paid_out_of_band": false,
"payment_intent": null,
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
"payment_method_types": null
},
"period_end": 1697128681,
"period_start": 1694536681,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": null,
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": -8779,
"statement_descriptor": null,
"status": "draft",
"status_transitions": {
"finalized_at": null,
"marked_uncollectible_at": null,
"paid_at": null,
"voided_at": null
},
"subscription": "sub_1NQxz4IGBnsLynRr1KbitG7v",
"subscription_details": {
"metadata": {
}
},
"subtotal": 2000,
"subtotal_excluding_tax": 2000,
"tax": 0,
"test_clock": null,
"total": 2000,
"total_discount_amounts": [
],
"total_excluding_tax": 2000,
"total_tax_amounts": [
{
"amount": 0,
"inclusive": false,
"tax_rate": "txr_1N6XCyIGBnsLynRr0LHs4AUD",
"taxability_reason": "product_exempt",
"taxable_amount": 0
}
],
"transfer_data": null,
"webhooks_delivered_at": null
}
},
"livemode": false,
"pending_webhooks": 5,
"request": {
"id": null,
"idempotency_key": null
},
"type": "invoice.upcoming"
}

View File

@@ -1,63 +0,0 @@
{
"id": "evt_1NvKzcIGBnsLynRrPJ3hybkd",
"object": "event",
"api_version": "2024-06-20",
"created": 1695910504,
"data": {
"object": {
"id": "pm_1NvKzbIGBnsLynRry6x7Buvc",
"object": "payment_method",
"billing_details": {
"address": {
"city": null,
"country": null,
"line1": null,
"line2": null,
"postal_code": null,
"state": null
},
"email": null,
"name": null,
"phone": null
},
"card": {
"brand": "visa",
"checks": {
"address_line1_check": null,
"address_postal_code_check": null,
"cvc_check": "pass"
},
"country": "US",
"exp_month": 6,
"exp_year": 2033,
"fingerprint": "0VgUBpvqcUUnuSmK",
"funding": "credit",
"generated_from": null,
"last4": "4242",
"networks": {
"available": [
"visa"
],
"preferred": null
},
"three_d_secure_usage": {
"supported": true
},
"wallet": null
},
"created": 1695910503,
"customer": "cus_OimYrxnMTMMK1E",
"livemode": false,
"metadata": {
},
"type": "card"
}
},
"livemode": false,
"pending_webhooks": 7,
"request": {
"id": "req_2WslNSBD9wAV5v",
"idempotency_key": "db1a648a-3445-47b3-a403-9f3d1303a880"
},
"type": "payment_method.attached"
}

View File

@@ -1,6 +1,5 @@
using Bit.Billing.Services;
using Bit.Billing.Services.Implementations;
using Bit.Billing.Test.Utilities;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.Repositories;
@@ -59,29 +58,69 @@ public class ProviderEventServiceTests
public async Task TryRecordInvoiceLineItems_EventTypeNotInvoiceCreatedOrInvoiceFinalized_NoOp()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.PaymentMethodAttached);
var stripeEvent = new Event { Type = "payment_method.attached" };
// Act
await _providerEventService.TryRecordInvoiceLineItems(stripeEvent);
// Assert
await _stripeEventService.DidNotReceiveWithAnyArgs().GetInvoice(Arg.Any<Event>());
await _stripeEventService.DidNotReceiveWithAnyArgs().GetInvoice(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>?>());
}
[Fact]
public async Task TryRecordInvoiceLineItems_InvoiceParentTypeNotSubscriptionDetails_NoOp()
{
// Arrange
var stripeEvent = new Event
{
Type = "invoice.created"
};
var invoice = new Invoice
{
Parent = new InvoiceParent
{
Type = "credit_note",
SubscriptionDetails = new InvoiceParentSubscriptionDetails
{
SubscriptionId = "sub_1"
}
}
};
_stripeEventService.GetInvoice(stripeEvent, true, Arg.Any<List<string>?>()).Returns(invoice);
// Act
await _providerEventService.TryRecordInvoiceLineItems(stripeEvent);
// Assert
await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription(Arg.Any<string>());
}
[Fact]
public async Task TryRecordInvoiceLineItems_EventNotProviderRelated_NoOp()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated);
var stripeEvent = new Event
{
Type = "invoice.created"
};
const string subscriptionId = "sub_1";
var invoice = new Invoice
{
SubscriptionId = subscriptionId
Parent = new InvoiceParent
{
Type = "subscription_details",
SubscriptionDetails = new InvoiceParentSubscriptionDetails
{
SubscriptionId = subscriptionId
}
}
};
_stripeEventService.GetInvoice(stripeEvent).Returns(invoice);
_stripeEventService.GetInvoice(stripeEvent, true, Arg.Any<List<string>?>()).Returns(invoice);
var subscription = new Subscription
{
@@ -101,7 +140,10 @@ public class ProviderEventServiceTests
public async Task TryRecordInvoiceLineItems_InvoiceCreated_Succeeds()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated);
var stripeEvent = new Event
{
Type = "invoice.created"
};
const string subscriptionId = "sub_1";
var providerId = Guid.NewGuid();
@@ -110,17 +152,26 @@ public class ProviderEventServiceTests
{
Id = "invoice_1",
Number = "A",
SubscriptionId = subscriptionId,
Discount = new Discount
Parent = new InvoiceParent
{
Coupon = new Coupon
Type = "subscription_details",
SubscriptionDetails = new InvoiceParentSubscriptionDetails
{
PercentOff = 35
SubscriptionId = subscriptionId
}
}
},
Discounts = [
new Discount
{
Coupon = new Coupon
{
PercentOff = 35
}
}
]
};
_stripeEventService.GetInvoice(stripeEvent).Returns(invoice);
_stripeEventService.GetInvoice(stripeEvent, true, Arg.Any<List<string>?>()).Returns(invoice);
var subscription = new Subscription
{
@@ -249,7 +300,10 @@ public class ProviderEventServiceTests
public async Task TryRecordInvoiceLineItems_InvoiceFinalized_Succeeds()
{
// Arrange
var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceFinalized);
var stripeEvent = new Event
{
Type = "invoice.finalized"
};
const string subscriptionId = "sub_1";
var providerId = Guid.NewGuid();
@@ -258,10 +312,17 @@ public class ProviderEventServiceTests
{
Id = "invoice_1",
Number = "A",
SubscriptionId = subscriptionId
Parent = new InvoiceParent
{
Type = "subscription_details",
SubscriptionDetails = new InvoiceParentSubscriptionDetails
{
SubscriptionId = subscriptionId
}
},
};
_stripeEventService.GetInvoice(stripeEvent).Returns(invoice);
_stripeEventService.GetInvoice(stripeEvent, true, Arg.Any<List<string>?>()).Returns(invoice);
var subscription = new Subscription
{

View File

@@ -2,6 +2,7 @@
using Bit.Billing.Services;
using Bit.Billing.Services.Implementations;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.Billing.Extensions;
using Bit.Core.Services;
using NSubstitute;
using Stripe;
@@ -38,7 +39,13 @@ public class SubscriptionDeletedHandlerTests
var subscription = new Subscription
{
Status = "active",
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30),
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
]
},
Metadata = new Dictionary<string, string>()
};
@@ -63,11 +70,14 @@ public class SubscriptionDeletedHandlerTests
var subscription = new Subscription
{
Status = StripeSubscriptionStatus.Canceled,
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30),
Metadata = new Dictionary<string, string>
Items = new StripeList<SubscriptionItem>
{
{ "organizationId", organizationId.ToString() }
}
Data =
[
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } }
};
_stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription);
@@ -79,7 +89,7 @@ public class SubscriptionDeletedHandlerTests
// Assert
await _organizationDisableCommand.Received(1)
.DisableAsync(organizationId, subscription.CurrentPeriodEnd);
.DisableAsync(organizationId, subscription.GetCurrentPeriodEnd());
}
[Fact]
@@ -91,11 +101,14 @@ public class SubscriptionDeletedHandlerTests
var subscription = new Subscription
{
Status = StripeSubscriptionStatus.Canceled,
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30),
Metadata = new Dictionary<string, string>
Items = new StripeList<SubscriptionItem>
{
{ "userId", userId.ToString() }
}
Data =
[
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
]
},
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } }
};
_stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription);
@@ -107,7 +120,7 @@ public class SubscriptionDeletedHandlerTests
// Assert
await _userService.Received(1)
.DisablePremiumAsync(userId, subscription.CurrentPeriodEnd);
.DisablePremiumAsync(userId, subscription.GetCurrentPeriodEnd());
}
[Fact]
@@ -119,11 +132,14 @@ public class SubscriptionDeletedHandlerTests
var subscription = new Subscription
{
Status = StripeSubscriptionStatus.Canceled,
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30),
Metadata = new Dictionary<string, string>
Items = new StripeList<SubscriptionItem>
{
{ "organizationId", organizationId.ToString() }
Data =
[
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
CancellationDetails = new SubscriptionCancellationDetails
{
Comment = "Cancelled as part of provider migration to Consolidated Billing"
@@ -151,11 +167,14 @@ public class SubscriptionDeletedHandlerTests
var subscription = new Subscription
{
Status = StripeSubscriptionStatus.Canceled,
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30),
Metadata = new Dictionary<string, string>
Items = new StripeList<SubscriptionItem>
{
{ "organizationId", organizationId.ToString() }
Data =
[
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
CancellationDetails = new SubscriptionCancellationDetails
{
Comment = "Organization was added to Provider"

View File

@@ -96,7 +96,13 @@ public class SubscriptionUpdatedHandlerTests
{
Id = subscriptionId,
Status = StripeSubscriptionStatus.Unpaid,
CurrentPeriodEnd = currentPeriodEnd,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = "subscription_cycle" }
};
@@ -142,7 +148,13 @@ public class SubscriptionUpdatedHandlerTests
{
Id = subscriptionId,
Status = StripeSubscriptionStatus.Unpaid,
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30),
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
]
},
Metadata = new Dictionary<string, string>
{
["providerId"] = providerId.ToString(),
@@ -206,7 +218,13 @@ public class SubscriptionUpdatedHandlerTests
{
Id = subscriptionId,
Status = StripeSubscriptionStatus.Unpaid,
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30),
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
]
},
Metadata = new Dictionary<string, string> { ["providerId"] = providerId.ToString() },
LatestInvoice = new Invoice { BillingReason = "subscription_cycle" },
TestClock = null
@@ -257,6 +275,13 @@ public class SubscriptionUpdatedHandlerTests
var subscription = new Subscription
{
Id = subscriptionId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
]
},
Status = StripeSubscriptionStatus.Unpaid,
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } },
LatestInvoice = new Invoice { BillingReason = "subscription_cycle" }
@@ -306,6 +331,13 @@ public class SubscriptionUpdatedHandlerTests
var subscription = new Subscription
{
Id = subscriptionId,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
]
},
Status = StripeSubscriptionStatus.Unpaid,
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } },
LatestInvoice = new Invoice { BillingReason = "subscription_cycle" }
@@ -348,7 +380,13 @@ public class SubscriptionUpdatedHandlerTests
{
Id = subscriptionId,
Status = StripeSubscriptionStatus.IncompleteExpired,
CurrentPeriodEnd = currentPeriodEnd,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
]
},
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } },
LatestInvoice = new Invoice { BillingReason = "renewal" }
};
@@ -390,7 +428,13 @@ public class SubscriptionUpdatedHandlerTests
{
Id = subscriptionId,
Status = StripeSubscriptionStatus.Unpaid,
CurrentPeriodEnd = currentPeriodEnd,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
]
},
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } },
LatestInvoice = new Invoice { BillingReason = "subscription_cycle" }
};
@@ -426,7 +470,13 @@ public class SubscriptionUpdatedHandlerTests
{
Id = subscriptionId,
Status = StripeSubscriptionStatus.Unpaid,
CurrentPeriodEnd = currentPeriodEnd,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
]
},
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } },
LatestInvoice = new Invoice { BillingReason = "subscription_cycle" }
};
@@ -464,13 +514,16 @@ public class SubscriptionUpdatedHandlerTests
{
Id = subscriptionId,
Status = StripeSubscriptionStatus.Unpaid,
CurrentPeriodEnd = currentPeriodEnd,
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } },
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId } }
new SubscriptionItem
{
CurrentPeriodEnd = currentPeriodEnd,
Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId }
}
]
}
};
@@ -508,7 +561,13 @@ public class SubscriptionUpdatedHandlerTests
var subscription = new Subscription
{
Status = StripeSubscriptionStatus.Active,
CurrentPeriodEnd = currentPeriodEnd,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } }
};
@@ -552,7 +611,13 @@ public class SubscriptionUpdatedHandlerTests
var subscription = new Subscription
{
Status = StripeSubscriptionStatus.Active,
CurrentPeriodEnd = currentPeriodEnd,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
]
},
Metadata = new Dictionary<string, string> { { "userId", userId.ToString() } }
};
@@ -583,7 +648,13 @@ public class SubscriptionUpdatedHandlerTests
var subscription = new Subscription
{
Status = StripeSubscriptionStatus.Active,
CurrentPeriodEnd = currentPeriodEnd,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } }
};
@@ -616,18 +687,24 @@ public class SubscriptionUpdatedHandlerTests
{
Id = "sub_123",
Status = StripeSubscriptionStatus.Active,
CurrentPeriodEnd = DateTime.UtcNow.AddDays(10),
CustomerId = "cus_123",
Items = new StripeList<SubscriptionItem>
{
Data = [new SubscriptionItem { Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } }]
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(10),
Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
}
]
},
Customer = new Customer
{
Balance = 0,
Discount = new Discount { Coupon = new Coupon { Id = "sm-standalone" } }
},
Discount = new Discount { Coupon = new Coupon { Id = "sm-standalone" } },
Discounts = [new Discount { Coupon = new Coupon { Id = "sm-standalone" } }],
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } }
};
@@ -728,7 +805,6 @@ public class SubscriptionUpdatedHandlerTests
.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
}
[Fact]
public async Task
HandleAsync_ActiveProviderSubscriptionEvent_AndPreviousSubscriptionStatusWasCanceled_EnableProvider()
@@ -998,6 +1074,13 @@ public class SubscriptionUpdatedHandlerTests
var newSubscription = new Subscription
{
Id = previousSubscription?.Id ?? "sub_123",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
]
},
Status = StripeSubscriptionStatus.Active,
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } }
};
@@ -1021,7 +1104,10 @@ public class SubscriptionUpdatedHandlerTests
{
new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.Unpaid } },
new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.Incomplete } },
new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.IncompleteExpired } },
new object[]
{
new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.IncompleteExpired }
},
new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.Paused } }
};
}

View File

@@ -1,35 +0,0 @@
using Stripe;
namespace Bit.Billing.Test.Utilities;
public enum StripeEventType
{
ChargeSucceeded,
CustomerSubscriptionUpdated,
CustomerUpdated,
InvoiceCreated,
InvoiceFinalized,
InvoiceUpcoming,
PaymentMethodAttached
}
public static class StripeTestEvents
{
public static async Task<Event> GetAsync(StripeEventType eventType)
{
var fileName = eventType switch
{
StripeEventType.ChargeSucceeded => "charge.succeeded.json",
StripeEventType.CustomerSubscriptionUpdated => "customer.subscription.updated.json",
StripeEventType.CustomerUpdated => "customer.updated.json",
StripeEventType.InvoiceCreated => "invoice.created.json",
StripeEventType.InvoiceFinalized => "invoice.finalized.json",
StripeEventType.InvoiceUpcoming => "invoice.upcoming.json",
StripeEventType.PaymentMethodAttached => "payment_method.attached.json"
};
var resource = await EmbeddedResourceReader.ReadAsync("Events", fileName);
return EventUtility.ParseEvent(resource);
}
}

View File

@@ -8,6 +8,7 @@ namespace Bit.Core.Test.Models.Data.EventIntegrations;
public class IntegrationMessageTests
{
private const string _messageId = "TestMessageId";
private const string _organizationId = "TestOrganizationId";
[Fact]
public void ApplyRetry_IncrementsRetryCountAndSetsDelayUntilDate()
@@ -16,6 +17,7 @@ public class IntegrationMessageTests
{
Configuration = new WebhookIntegrationConfigurationDetails(new Uri("https://localhost"), "Bearer", "AUTH-TOKEN"),
MessageId = _messageId,
OrganizationId = _organizationId,
RetryCount = 2,
RenderedTemplate = string.Empty,
DelayUntilDate = null
@@ -36,6 +38,7 @@ public class IntegrationMessageTests
{
Configuration = new WebhookIntegrationConfigurationDetails(new Uri("https://localhost"), "Bearer", "AUTH-TOKEN"),
MessageId = _messageId,
OrganizationId = _organizationId,
RenderedTemplate = "This is the message",
IntegrationType = IntegrationType.Webhook,
RetryCount = 2,
@@ -48,6 +51,7 @@ public class IntegrationMessageTests
Assert.NotNull(result);
Assert.Equal(message.Configuration, result.Configuration);
Assert.Equal(message.MessageId, result.MessageId);
Assert.Equal(message.OrganizationId, result.OrganizationId);
Assert.Equal(message.RenderedTemplate, result.RenderedTemplate);
Assert.Equal(message.IntegrationType, result.IntegrationType);
Assert.Equal(message.RetryCount, result.RetryCount);
@@ -67,6 +71,7 @@ public class IntegrationMessageTests
var message = new IntegrationMessage
{
MessageId = _messageId,
OrganizationId = _organizationId,
RenderedTemplate = "This is the message",
IntegrationType = IntegrationType.Webhook,
RetryCount = 2,
@@ -77,6 +82,7 @@ public class IntegrationMessageTests
var result = JsonSerializer.Deserialize<IntegrationMessage>(json);
Assert.Equal(message.MessageId, result.MessageId);
Assert.Equal(message.OrganizationId, result.OrganizationId);
Assert.Equal(message.RenderedTemplate, result.RenderedTemplate);
Assert.Equal(message.IntegrationType, result.IntegrationType);
Assert.Equal(message.RetryCount, result.RetryCount);

View File

@@ -0,0 +1,56 @@
using Bit.Core.AdminConsole.Models.Teams;
using Microsoft.Bot.Connector.Authentication;
using Xunit;
namespace Bit.Core.Test.Models.Data.Teams;
public class TeamsBotCredentialProviderTests
{
private string _clientId = "client id";
private string _clientSecret = "client secret";
[Fact]
public async Task IsValidAppId_MustMatchClientId()
{
var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret);
Assert.True(await sut.IsValidAppIdAsync(_clientId));
Assert.False(await sut.IsValidAppIdAsync("Different id"));
}
[Fact]
public async Task GetAppPasswordAsync_MatchingClientId_ReturnsClientSecret()
{
var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret);
var password = await sut.GetAppPasswordAsync(_clientId);
Assert.Equal(_clientSecret, password);
}
[Fact]
public async Task GetAppPasswordAsync_NotMatchingClientId_ReturnsNull()
{
var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret);
Assert.Null(await sut.GetAppPasswordAsync("Different id"));
}
[Fact]
public async Task IsAuthenticationDisabledAsync_ReturnsFalse()
{
var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret);
Assert.False(await sut.IsAuthenticationDisabledAsync());
}
[Fact]
public async Task ValidateIssuerAsync_ExpectedIssuer_ReturnsTrue()
{
var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret);
Assert.True(await sut.ValidateIssuerAsync(AuthenticationConstants.ToBotFromChannelTokenIssuer));
}
[Fact]
public async Task ValidateIssuerAsync_UnexpectedIssuer_ReturnsFalse()
{
var sut = new TeamsBotCredentialProvider(_clientId, _clientSecret);
Assert.False(await sut.ValidateIssuerAsync("unexpected issuer"));
}
}

View File

@@ -0,0 +1,124 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using OneOf.Types;
using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies;
public class PolicyEventHandlerHandlerFactoryTests
{
[Fact]
public void GetHandler_ReturnsHandler_WhenHandlerExists()
{
// Arrange
var expectedHandler = new FakeSingleOrgDependencyEvent();
var factory = new PolicyEventHandlerHandlerFactory([expectedHandler]);
// Act
var result = factory.GetHandler<IEnforceDependentPoliciesEvent>(PolicyType.SingleOrg);
// Assert
Assert.True(result.IsT0);
Assert.Equal(expectedHandler, result.AsT0);
}
[Fact]
public void GetHandler_ReturnsNone_WhenHandlerDoesNotExist()
{
// Arrange
var factory = new PolicyEventHandlerHandlerFactory([new FakeSingleOrgDependencyEvent()]);
// Act
var result = factory.GetHandler<IEnforceDependentPoliciesEvent>(PolicyType.RequireSso);
// Assert
Assert.True(result.IsT1);
Assert.IsType<None>(result.AsT1);
}
[Fact]
public void GetHandler_ReturnsNone_WhenHandlerTypeDoesNotMatch()
{
// Arrange
var factory = new PolicyEventHandlerHandlerFactory([new FakeSingleOrgDependencyEvent()]);
// Act
var result = factory.GetHandler<IPolicyValidationEvent>(PolicyType.SingleOrg);
// Assert
Assert.True(result.IsT1);
Assert.IsType<None>(result.AsT1);
}
[Fact]
public void GetHandler_ReturnsCorrectHandler_WhenMultipleHandlerTypesExist()
{
// Arrange
var dependencyEvent = new FakeSingleOrgDependencyEvent();
var validationEvent = new FakeSingleOrgValidationEvent();
var factory = new PolicyEventHandlerHandlerFactory([dependencyEvent, validationEvent]);
// Act
var dependencyResult = factory.GetHandler<IEnforceDependentPoliciesEvent>(PolicyType.SingleOrg);
var validationResult = factory.GetHandler<IPolicyValidationEvent>(PolicyType.SingleOrg);
// Assert
Assert.True(dependencyResult.IsT0);
Assert.Equal(dependencyEvent, dependencyResult.AsT0);
Assert.True(validationResult.IsT0);
Assert.Equal(validationEvent, validationResult.AsT0);
}
[Fact]
public void GetHandler_ReturnsCorrectHandler_WhenMultiplePolicyTypesExist()
{
// Arrange
var singleOrgEvent = new FakeSingleOrgDependencyEvent();
var requireSsoEvent = new FakeRequireSsoDependencyEvent();
var factory = new PolicyEventHandlerHandlerFactory([singleOrgEvent, requireSsoEvent]);
// Act
var singleOrgResult = factory.GetHandler<IEnforceDependentPoliciesEvent>(PolicyType.SingleOrg);
var requireSsoResult = factory.GetHandler<IEnforceDependentPoliciesEvent>(PolicyType.RequireSso);
// Assert
Assert.True(singleOrgResult.IsT0);
Assert.Equal(singleOrgEvent, singleOrgResult.AsT0);
Assert.True(requireSsoResult.IsT0);
Assert.Equal(requireSsoEvent, requireSsoResult.AsT0);
}
[Fact]
public void GetHandler_Throws_WhenDuplicateHandlersExist()
{
// Arrange
var factory = new PolicyEventHandlerHandlerFactory([
new FakeSingleOrgDependencyEvent(),
new FakeSingleOrgDependencyEvent()
]);
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() =>
factory.GetHandler<IEnforceDependentPoliciesEvent>(PolicyType.SingleOrg));
Assert.Contains("Multiple IPolicyUpdateEvent handlers of type IEnforceDependentPoliciesEvent found for PolicyType SingleOrg", exception.Message);
Assert.Contains("Expected one IEnforceDependentPoliciesEvent handler per PolicyType", exception.Message);
}
[Fact]
public void GetHandler_ReturnsNone_WhenNoHandlersProvided()
{
// Arrange
var factory = new PolicyEventHandlerHandlerFactory([]);
// Act
var result = factory.GetHandler<IEnforceDependentPoliciesEvent>(PolicyType.SingleOrg);
// Assert
Assert.True(result.IsT1);
Assert.IsType<None>(result.AsT1);
}
}

View File

@@ -14,10 +14,12 @@ public class PolicyRequirementQueryTests
[Theory, BitAutoData]
public async Task GetAsync_IgnoresOtherPolicyTypes(Guid userId)
{
var thisPolicy = new PolicyDetails { PolicyType = PolicyType.SingleOrg };
var otherPolicy = new PolicyDetails { PolicyType = PolicyType.RequireSso };
var thisPolicy = new OrganizationPolicyDetails { PolicyType = PolicyType.SingleOrg, UserId = userId };
var otherPolicy = new OrganizationPolicyDetails { PolicyType = PolicyType.RequireSso, UserId = userId };
var policyRepository = Substitute.For<IPolicyRepository>();
policyRepository.GetPolicyDetailsByUserId(userId).Returns([otherPolicy, thisPolicy]);
policyRepository.GetPolicyDetailsByUserIdsAndPolicyType(
Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(userId)), PolicyType.SingleOrg)
.Returns([otherPolicy, thisPolicy]);
var factory = new TestPolicyRequirementFactory(_ => true);
var sut = new PolicyRequirementQuery(policyRepository, [factory]);
@@ -33,9 +35,11 @@ public class PolicyRequirementQueryTests
{
// Arrange policies
var policyRepository = Substitute.For<IPolicyRepository>();
var thisPolicy = new PolicyDetails { PolicyType = PolicyType.SingleOrg };
var otherPolicy = new PolicyDetails { PolicyType = PolicyType.SingleOrg };
policyRepository.GetPolicyDetailsByUserId(userId).Returns([thisPolicy, otherPolicy]);
var thisPolicy = new OrganizationPolicyDetails { PolicyType = PolicyType.SingleOrg, UserId = userId };
var otherPolicy = new OrganizationPolicyDetails { PolicyType = PolicyType.SingleOrg, UserId = userId };
policyRepository.GetPolicyDetailsByUserIdsAndPolicyType(
Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(userId)), PolicyType.SingleOrg)
.Returns([thisPolicy, otherPolicy]);
// Arrange a substitute Enforce function so that we can inspect the received calls
var callback = Substitute.For<Func<PolicyDetails, bool>>();
@@ -70,7 +74,9 @@ public class PolicyRequirementQueryTests
public async Task GetAsync_HandlesNoPolicies(Guid userId)
{
var policyRepository = Substitute.For<IPolicyRepository>();
policyRepository.GetPolicyDetailsByUserId(userId).Returns([]);
policyRepository.GetPolicyDetailsByUserIdsAndPolicyType(
Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(userId)), PolicyType.SingleOrg)
.Returns([]);
var factory = new TestPolicyRequirementFactory(x => x.IsProvider);
var sut = new PolicyRequirementQuery(policyRepository, [factory]);

View File

@@ -0,0 +1,37 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using NSubstitute;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies;
public class FakeSingleOrgDependencyEvent : IEnforceDependentPoliciesEvent
{
public PolicyType Type => PolicyType.SingleOrg;
public IEnumerable<PolicyType> RequiredPolicies => [];
}
public class FakeRequireSsoDependencyEvent : IEnforceDependentPoliciesEvent
{
public PolicyType Type => PolicyType.RequireSso;
public IEnumerable<PolicyType> RequiredPolicies => [PolicyType.SingleOrg];
}
public class FakeVaultTimeoutDependencyEvent : IEnforceDependentPoliciesEvent
{
public PolicyType Type => PolicyType.MaximumVaultTimeout;
public IEnumerable<PolicyType> RequiredPolicies => [PolicyType.SingleOrg];
}
public class FakeSingleOrgValidationEvent : IPolicyValidationEvent
{
public PolicyType Type => PolicyType.SingleOrg;
public readonly Func<SavePolicyModel, Policy?, Task<string>> ValidateAsyncMock = Substitute.For<Func<SavePolicyModel, Policy?, Task<string>>>();
public Task<string> ValidateAsync(SavePolicyModel policyRequest, Policy? currentPolicy)
{
return ValidateAsyncMock(policyRequest, currentPolicy);
}
}

View File

@@ -72,4 +72,65 @@ public class FreeFamiliesForEnterprisePolicyValidatorTests
organizationSponsorships[0].SponsoredOrganizationId.ToString(), organization.Name);
}
[Theory, BitAutoData]
public async Task ExecutePreUpsertSideEffectAsync_DoesNotNotifyUserWhenPolicyDisabled(
Organization organization,
List<OrganizationSponsorship> organizationSponsorships,
[PolicyUpdate(PolicyType.FreeFamiliesSponsorshipPolicy)] PolicyUpdate policyUpdate,
[Policy(PolicyType.FreeFamiliesSponsorshipPolicy, true)] Policy policy,
SutProvider<FreeFamiliesForEnterprisePolicyValidator> sutProvider)
{
policy.Enabled = true;
policyUpdate.Enabled = false;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
sutProvider.GetDependency<IOrganizationSponsorshipRepository>()
.GetManyBySponsoringOrganizationAsync(policyUpdate.OrganizationId)
.Returns(organizationSponsorships);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, policy);
await sutProvider.GetDependency<IMailService>()
.DidNotReceiveWithAnyArgs()
.SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(default, default, default, default);
}
[Theory, BitAutoData]
public async Task ExecutePreUpsertSideEffectAsync_DoesNotifyUserWhenPolicyEnabled(
Organization organization,
List<OrganizationSponsorship> organizationSponsorships,
[PolicyUpdate(PolicyType.FreeFamiliesSponsorshipPolicy)] PolicyUpdate policyUpdate,
[Policy(PolicyType.FreeFamiliesSponsorshipPolicy, false)] Policy policy,
SutProvider<FreeFamiliesForEnterprisePolicyValidator> sutProvider)
{
policy.Enabled = false;
policyUpdate.Enabled = true;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(policyUpdate.OrganizationId)
.Returns(organization);
sutProvider.GetDependency<IOrganizationSponsorshipRepository>()
.GetManyBySponsoringOrganizationAsync(policyUpdate.OrganizationId)
.Returns(organizationSponsorships);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, policy);
var offerAcceptanceDate = organizationSponsorships[0].ValidUntil!.Value.AddDays(-7).ToString("MM/dd/yyyy");
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendFamiliesForEnterpriseRemoveSponsorshipsEmailAsync(
organizationSponsorships[0].FriendlyName,
offerAcceptanceDate,
organizationSponsorships[0].SponsoredOrganizationId.ToString(),
organization.Name);
}
}

View File

@@ -274,4 +274,176 @@ public class OrganizationDataOwnershipPolicyValidatorTests
return sut;
}
[Theory, BitAutoData]
public async Task ExecutePostUpsertSideEffectAsync_FeatureFlagDisabled_DoesNothing(
[PolicyUpdate(PolicyType.OrganizationDataOwnership, false)] PolicyUpdate policyUpdate,
[Policy(PolicyType.OrganizationDataOwnership, false)] Policy postUpdatedPolicy,
[Policy(PolicyType.OrganizationDataOwnership, false)] Policy previousPolicyState,
SutProvider<OrganizationDataOwnershipPolicyValidator> sutProvider)
{
// Arrange
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
.Returns(false);
var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
// Act
await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
// Assert
await sutProvider.GetDependency<ICollectionRepository>()
.DidNotReceiveWithAnyArgs()
.UpsertDefaultCollectionsAsync(default, default, default);
}
[Theory, BitAutoData]
public async Task ExecutePostUpsertSideEffectAsync_PolicyAlreadyEnabled_DoesNothing(
[PolicyUpdate(PolicyType.OrganizationDataOwnership, true)] PolicyUpdate policyUpdate,
[Policy(PolicyType.OrganizationDataOwnership, true)] Policy postUpdatedPolicy,
[Policy(PolicyType.OrganizationDataOwnership, true)] Policy previousPolicyState,
SutProvider<OrganizationDataOwnershipPolicyValidator> sutProvider)
{
// Arrange
postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId;
previousPolicyState.OrganizationId = policyUpdate.OrganizationId;
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
.Returns(true);
var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
// Act
await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
// Assert
await sutProvider.GetDependency<ICollectionRepository>()
.DidNotReceiveWithAnyArgs()
.UpsertDefaultCollectionsAsync(default, default, default);
}
[Theory, BitAutoData]
public async Task ExecutePostUpsertSideEffectAsync_PolicyBeingDisabled_DoesNothing(
[PolicyUpdate(PolicyType.OrganizationDataOwnership, false)] PolicyUpdate policyUpdate,
[Policy(PolicyType.OrganizationDataOwnership, false)] Policy postUpdatedPolicy,
[Policy(PolicyType.OrganizationDataOwnership)] Policy previousPolicyState,
SutProvider<OrganizationDataOwnershipPolicyValidator> sutProvider)
{
// Arrange
previousPolicyState.OrganizationId = policyUpdate.OrganizationId;
postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId;
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
.Returns(true);
var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
// Act
await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
// Assert
await sutProvider.GetDependency<ICollectionRepository>()
.DidNotReceiveWithAnyArgs()
.UpsertDefaultCollectionsAsync(default, default, default);
}
[Theory, BitAutoData]
public async Task ExecutePostUpsertSideEffectAsync_WhenNoUsersExist_DoNothing(
[PolicyUpdate(PolicyType.OrganizationDataOwnership, true)] PolicyUpdate policyUpdate,
[Policy(PolicyType.OrganizationDataOwnership, true)] Policy postUpdatedPolicy,
[Policy(PolicyType.OrganizationDataOwnership, false)] Policy previousPolicyState,
OrganizationDataOwnershipPolicyRequirementFactory factory)
{
// Arrange
postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId;
previousPolicyState.OrganizationId = policyUpdate.OrganizationId;
var policyRepository = ArrangePolicyRepository([]);
var collectionRepository = Substitute.For<ICollectionRepository>();
var sut = ArrangeSut(factory, policyRepository, collectionRepository);
var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
// Act
await sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
// Assert
await collectionRepository
.DidNotReceiveWithAnyArgs()
.UpsertDefaultCollectionsAsync(
default,
default,
default);
await policyRepository
.Received(1)
.GetPolicyDetailsByOrganizationIdAsync(
policyUpdate.OrganizationId,
PolicyType.OrganizationDataOwnership);
}
[Theory]
[BitMemberAutoData(nameof(ShouldUpsertDefaultCollectionsTestCases))]
public async Task ExecutePostUpsertSideEffectAsync_WithRequirements_ShouldUpsertDefaultCollections(
Policy postUpdatedPolicy,
Policy? previousPolicyState,
[PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate,
[OrganizationPolicyDetails(PolicyType.OrganizationDataOwnership)] IEnumerable<OrganizationPolicyDetails> orgPolicyDetails,
OrganizationDataOwnershipPolicyRequirementFactory factory)
{
// Arrange
var orgPolicyDetailsList = orgPolicyDetails.ToList();
foreach (var policyDetail in orgPolicyDetailsList)
{
policyDetail.OrganizationId = policyUpdate.OrganizationId;
}
var policyRepository = ArrangePolicyRepository(orgPolicyDetailsList);
var collectionRepository = Substitute.For<ICollectionRepository>();
var sut = ArrangeSut(factory, policyRepository, collectionRepository);
var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName));
// Act
await sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
// Assert
await collectionRepository
.Received(1)
.UpsertDefaultCollectionsAsync(
policyUpdate.OrganizationId,
Arg.Is<IEnumerable<Guid>>(ids => ids.Count() == 3),
_defaultUserCollectionName);
}
[Theory]
[BitMemberAutoData(nameof(WhenDefaultCollectionsDoesNotExistTestCases))]
public async Task ExecutePostUpsertSideEffectAsync_WhenDefaultCollectionNameIsInvalid_DoesNothing(
IPolicyMetadataModel metadata,
[PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate,
[Policy(PolicyType.OrganizationDataOwnership, true)] Policy postUpdatedPolicy,
[Policy(PolicyType.OrganizationDataOwnership, false)] Policy previousPolicyState,
SutProvider<OrganizationDataOwnershipPolicyValidator> sutProvider)
{
// Arrange
postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId;
previousPolicyState.OrganizationId = policyUpdate.OrganizationId;
policyUpdate.Enabled = true;
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)
.Returns(true);
var policyRequest = new SavePolicyModel(policyUpdate, null, metadata);
// Act
await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
// Assert
await sutProvider.GetDependency<ICollectionRepository>()
.DidNotReceiveWithAnyArgs()
.UpsertDefaultCollectionsAsync(default, default, default);
}
}

View File

@@ -72,4 +72,66 @@ public class RequireSsoPolicyValidatorTests
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, policy);
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task ValidateAsync_WithSavePolicyModel_DisablingPolicy_KeyConnectorEnabled_ValidationError(
[PolicyUpdate(PolicyType.RequireSso, false)] PolicyUpdate policyUpdate,
[Policy(PolicyType.RequireSso)] Policy policy,
SutProvider<RequireSsoPolicyValidator> sutProvider)
{
policy.OrganizationId = policyUpdate.OrganizationId;
var ssoConfig = new SsoConfig { Enabled = true };
ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.KeyConnector });
sutProvider.GetDependency<ISsoConfigRepository>()
.GetByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns(ssoConfig);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy);
Assert.Contains("Key Connector is enabled", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_WithSavePolicyModel_DisablingPolicy_TdeEnabled_ValidationError(
[PolicyUpdate(PolicyType.RequireSso, false)] PolicyUpdate policyUpdate,
[Policy(PolicyType.RequireSso)] Policy policy,
SutProvider<RequireSsoPolicyValidator> sutProvider)
{
policy.OrganizationId = policyUpdate.OrganizationId;
var ssoConfig = new SsoConfig { Enabled = true };
ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption });
sutProvider.GetDependency<ISsoConfigRepository>()
.GetByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns(ssoConfig);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy);
Assert.Contains("Trusted device encryption is on", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_WithSavePolicyModel_DisablingPolicy_DecryptionOptionsNotEnabled_Success(
[PolicyUpdate(PolicyType.RequireSso, false)] PolicyUpdate policyUpdate,
[Policy(PolicyType.RequireSso)] Policy policy,
SutProvider<RequireSsoPolicyValidator> sutProvider)
{
policy.OrganizationId = policyUpdate.OrganizationId;
var ssoConfig = new SsoConfig { Enabled = false };
sutProvider.GetDependency<ISsoConfigRepository>()
.GetByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns(ssoConfig);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy);
Assert.True(string.IsNullOrEmpty(result));
}
}

View File

@@ -68,4 +68,59 @@ public class ResetPasswordPolicyValidatorTests
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, policy);
Assert.True(string.IsNullOrEmpty(result));
}
[Theory]
[BitAutoData(true, false)]
[BitAutoData(false, true)]
[BitAutoData(false, false)]
public async Task ValidateAsync_WithSavePolicyModel_DisablingPolicy_TdeEnabled_ValidationError(
bool policyEnabled,
bool autoEnrollEnabled,
[PolicyUpdate(PolicyType.ResetPassword)] PolicyUpdate policyUpdate,
[Policy(PolicyType.ResetPassword)] Policy policy,
SutProvider<ResetPasswordPolicyValidator> sutProvider)
{
policyUpdate.Enabled = policyEnabled;
policyUpdate.SetDataModel(new ResetPasswordDataModel
{
AutoEnrollEnabled = autoEnrollEnabled
});
policy.OrganizationId = policyUpdate.OrganizationId;
var ssoConfig = new SsoConfig { Enabled = true };
ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption });
sutProvider.GetDependency<ISsoConfigRepository>()
.GetByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns(ssoConfig);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy);
Assert.Contains("Trusted device encryption is on and requires this policy.", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_WithSavePolicyModel_DisablingPolicy_TdeNotEnabled_Success(
[PolicyUpdate(PolicyType.ResetPassword, false)] PolicyUpdate policyUpdate,
[Policy(PolicyType.ResetPassword)] Policy policy,
SutProvider<ResetPasswordPolicyValidator> sutProvider)
{
policyUpdate.SetDataModel(new ResetPasswordDataModel
{
AutoEnrollEnabled = false
});
policy.OrganizationId = policyUpdate.OrganizationId;
var ssoConfig = new SsoConfig { Enabled = false };
sutProvider.GetDependency<ISsoConfigRepository>()
.GetByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns(ssoConfig);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy);
Assert.True(string.IsNullOrEmpty(result));
}
}

View File

@@ -1,5 +1,6 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
@@ -145,4 +146,135 @@ public class SingleOrgPolicyValidatorTests
.Received(1)
.SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), nonCompliantUser.Email);
}
[Theory, BitAutoData]
public async Task ValidateAsync_WithSavePolicyModel_DisablingPolicy_KeyConnectorEnabled_ValidationError(
[PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy policy,
SutProvider<SingleOrgPolicyValidator> sutProvider)
{
policy.OrganizationId = policyUpdate.OrganizationId;
var ssoConfig = new SsoConfig { Enabled = true };
ssoConfig.SetData(new SsoConfigurationData { MemberDecryptionType = MemberDecryptionType.KeyConnector });
sutProvider.GetDependency<ISsoConfigRepository>()
.GetByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns(ssoConfig);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy);
Assert.Contains("Key Connector is enabled", result, StringComparison.OrdinalIgnoreCase);
}
[Theory, BitAutoData]
public async Task ValidateAsync_WithSavePolicyModel_DisablingPolicy_KeyConnectorNotEnabled_Success(
[PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy policy,
SutProvider<SingleOrgPolicyValidator> sutProvider)
{
policy.OrganizationId = policyUpdate.OrganizationId;
var ssoConfig = new SsoConfig { Enabled = false };
sutProvider.GetDependency<ISsoConfigRepository>()
.GetByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns(ssoConfig);
sutProvider.GetDependency<IOrganizationHasVerifiedDomainsQuery>()
.HasVerifiedDomainsAsync(policyUpdate.OrganizationId)
.Returns(false);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy);
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task ExecutePreUpsertSideEffectAsync_RevokesNonCompliantUsers(
[PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg, false)] Policy policy,
Guid savingUserId,
Guid nonCompliantUserId,
Organization organization,
SutProvider<SingleOrgPolicyValidator> sutProvider)
{
policy.OrganizationId = organization.Id = policyUpdate.OrganizationId;
var compliantUser1 = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = organization.Id,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Confirmed,
UserId = new Guid(),
Email = "user1@example.com"
};
var compliantUser2 = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = organization.Id,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Confirmed,
UserId = new Guid(),
Email = "user2@example.com"
};
var nonCompliantUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = organization.Id,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Confirmed,
UserId = nonCompliantUserId,
Email = "user3@example.com"
};
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([compliantUser1, compliantUser2, nonCompliantUser]);
var otherOrganizationUser = new OrganizationUser
{
Id = Guid.NewGuid(),
OrganizationId = new Guid(),
UserId = nonCompliantUserId,
Status = OrganizationUserStatusType.Confirmed
};
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByManyUsersAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Contains(nonCompliantUserId)))
.Returns([otherOrganizationUser]);
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(savingUserId);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(policyUpdate.OrganizationId).Returns(organization);
sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
.RevokeNonCompliantOrganizationUsersAsync(Arg.Any<RevokeOrganizationUsersRequest>())
.Returns(new CommandResult());
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, policy);
await sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
.Received(1)
.RevokeNonCompliantOrganizationUsersAsync(
Arg.Is<RevokeOrganizationUsersRequest>(r =>
r.OrganizationId == organization.Id &&
r.OrganizationUsers.Count() == 1 &&
r.OrganizationUsers.First().Id == nonCompliantUser.Id));
await sutProvider.GetDependency<IMailService>()
.DidNotReceive()
.SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), compliantUser1.Email);
await sutProvider.GetDependency<IMailService>()
.DidNotReceive()
.SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), compliantUser2.Email);
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendOrganizationUserRevokedForPolicySingleOrgEmailAsync(organization.DisplayName(), nonCompliantUser.Email);
}
}

View File

@@ -136,4 +136,124 @@ public class TwoFactorAuthenticationPolicyValidatorTests
.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(),
compliantUser.Email);
}
[Theory, BitAutoData]
public async Task ExecutePreUpsertSideEffectAsync_GivenNonCompliantUsersWithoutMasterPassword_Throws(
Organization organization,
[PolicyUpdate(PolicyType.TwoFactorAuthentication)] PolicyUpdate policyUpdate,
[Policy(PolicyType.TwoFactorAuthentication, false)] Policy policy,
SutProvider<TwoFactorAuthenticationPolicyValidator> sutProvider)
{
policy.OrganizationId = organization.Id = policyUpdate.OrganizationId;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
var orgUserDetailUserWithout2Fa = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.User,
Email = "user3@test.com",
Name = "TEST",
UserId = Guid.NewGuid(),
HasMasterPassword = false
};
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([orgUserDetailUserWithout2Fa]);
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
.TwoFactorIsEnabledAsync(Arg.Any<IEnumerable<OrganizationUserUserDetails>>())
.Returns(new List<(OrganizationUserUserDetails user, bool hasTwoFactor)>()
{
(orgUserDetailUserWithout2Fa, false),
});
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, policy));
Assert.Equal(TwoFactorAuthenticationPolicyValidator.NonCompliantMembersWillLoseAccessMessage, exception.Message);
}
[Theory, BitAutoData]
public async Task ExecutePreUpsertSideEffectAsync_RevokesOnlyNonCompliantUsers(
Organization organization,
[PolicyUpdate(PolicyType.TwoFactorAuthentication)] PolicyUpdate policyUpdate,
[Policy(PolicyType.TwoFactorAuthentication, false)] Policy policy,
SutProvider<TwoFactorAuthenticationPolicyValidator> sutProvider)
{
// Arrange
policy.OrganizationId = policyUpdate.OrganizationId;
organization.Id = policyUpdate.OrganizationId;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
var nonCompliantUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.User,
Email = "user3@test.com",
Name = "TEST",
UserId = Guid.NewGuid(),
HasMasterPassword = true
};
var compliantUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.User,
Email = "user4@test.com",
Name = "TEST",
UserId = Guid.NewGuid(),
HasMasterPassword = true
};
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([nonCompliantUser, compliantUser]);
sutProvider.GetDependency<ITwoFactorIsEnabledQuery>()
.TwoFactorIsEnabledAsync(Arg.Any<IEnumerable<OrganizationUserUserDetails>>())
.Returns(new List<(OrganizationUserUserDetails user, bool hasTwoFactor)>()
{
(nonCompliantUser, false),
(compliantUser, true)
});
sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
.RevokeNonCompliantOrganizationUsersAsync(Arg.Any<RevokeOrganizationUsersRequest>())
.Returns(new CommandResult());
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
// Act
await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, policy);
// Assert
await sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
.Received(1)
.RevokeNonCompliantOrganizationUsersAsync(Arg.Any<RevokeOrganizationUsersRequest>());
await sutProvider.GetDependency<IRevokeNonCompliantOrganizationUserCommand>()
.Received(1)
.RevokeNonCompliantOrganizationUsersAsync(Arg.Is<RevokeOrganizationUsersRequest>(req =>
req.OrganizationId == policyUpdate.OrganizationId &&
req.OrganizationUsers.SequenceEqual(new[] { nonCompliantUser })
));
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(),
nonCompliantUser.Email);
// Did not send out an email for compliantUser
await sutProvider.GetDependency<IMailService>()
.Received(0)
.SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(organization.DisplayName(),
compliantUser.Email);
}
}

View File

@@ -0,0 +1,457 @@
#nullable enable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Services;
using Bit.Core.Test.AdminConsole.AutoFixture;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Time.Testing;
using NSubstitute;
using OneOf.Types;
using Xunit;
using EventType = Bit.Core.Enums.EventType;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies;
public class VNextSavePolicyCommandTests
{
[Theory, BitAutoData]
public async Task SaveAsync_NewPolicy_Success([PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate)
{
// Arrange
var fakePolicyValidationEvent = new FakeSingleOrgValidationEvent();
fakePolicyValidationEvent.ValidateAsyncMock(Arg.Any<SavePolicyModel>(), Arg.Any<Policy>()).Returns("");
var sutProvider = SutProviderFactory([
new FakeSingleOrgDependencyEvent(),
fakePolicyValidationEvent
]);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
var newPolicy = new Policy
{
Type = policyUpdate.Type,
OrganizationId = policyUpdate.OrganizationId,
Enabled = false
};
ArrangeOrganization(sutProvider, policyUpdate);
sutProvider.GetDependency<IPolicyRepository>().GetManyByOrganizationIdAsync(policyUpdate.OrganizationId).Returns([newPolicy]);
var creationDate = sutProvider.GetDependency<FakeTimeProvider>().Start;
// Act
await sutProvider.Sut.SaveAsync(savePolicyModel);
// Assert
await fakePolicyValidationEvent.ValidateAsyncMock
.Received(1)
.Invoke(Arg.Any<SavePolicyModel>(), Arg.Any<Policy>());
await AssertPolicySavedAsync(sutProvider, policyUpdate);
await sutProvider.GetDependency<IPolicyRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Policy>(p =>
p.CreationDate == creationDate &&
p.RevisionDate == creationDate));
}
[Theory, BitAutoData]
public async Task SaveAsync_ExistingPolicy_Success(
[PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg, false)] Policy currentPolicy)
{
// Arrange
var fakePolicyValidationEvent = new FakeSingleOrgValidationEvent();
fakePolicyValidationEvent.ValidateAsyncMock(Arg.Any<SavePolicyModel>(), Arg.Any<Policy>()).Returns("");
var sutProvider = SutProviderFactory([
new FakeSingleOrgDependencyEvent(),
fakePolicyValidationEvent
]);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type)
.Returns(currentPolicy);
ArrangeOrganization(sutProvider, policyUpdate);
sutProvider.GetDependency<IPolicyRepository>()
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns([currentPolicy]);
// Act
await sutProvider.Sut.SaveAsync(savePolicyModel);
// Assert
await fakePolicyValidationEvent.ValidateAsyncMock
.Received(1)
.Invoke(Arg.Any<SavePolicyModel>(), currentPolicy);
await AssertPolicySavedAsync(sutProvider, policyUpdate);
var revisionDate = sutProvider.GetDependency<FakeTimeProvider>().Start;
await sutProvider.GetDependency<IPolicyRepository>()
.Received(1)
.UpsertAsync(Arg.Is<Policy>(p =>
p.Id == currentPolicy.Id &&
p.OrganizationId == currentPolicy.OrganizationId &&
p.Type == currentPolicy.Type &&
p.CreationDate == currentPolicy.CreationDate &&
p.RevisionDate == revisionDate));
}
[Theory, BitAutoData]
public async Task SaveAsync_OrganizationDoesNotExist_ThrowsBadRequest([PolicyUpdate(PolicyType.ActivateAutofill)] PolicyUpdate policyUpdate)
{
// Arrange
var sutProvider = SutProviderFactory();
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
sutProvider.GetDependency<IApplicationCacheService>()
.GetOrganizationAbilityAsync(policyUpdate.OrganizationId)
.Returns(Task.FromResult<OrganizationAbility?>(null));
// Act
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SaveAsync(savePolicyModel));
// Assert
Assert.Contains("Organization not found", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
await AssertPolicyNotSavedAsync(sutProvider);
}
[Theory, BitAutoData]
public async Task SaveAsync_OrganizationCannotUsePolicies_ThrowsBadRequest([PolicyUpdate(PolicyType.ActivateAutofill)] PolicyUpdate policyUpdate)
{
// Arrange
var sutProvider = SutProviderFactory();
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
sutProvider.GetDependency<IApplicationCacheService>()
.GetOrganizationAbilityAsync(policyUpdate.OrganizationId)
.Returns(new OrganizationAbility
{
Id = policyUpdate.OrganizationId,
UsePolicies = false
});
// Act
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SaveAsync(savePolicyModel));
// Assert
Assert.Contains("cannot use policies", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
await AssertPolicyNotSavedAsync(sutProvider);
}
[Theory, BitAutoData]
public async Task SaveAsync_RequiredPolicyIsNull_Throws(
[PolicyUpdate(PolicyType.RequireSso)] PolicyUpdate policyUpdate)
{
// Arrange
var sutProvider = SutProviderFactory(
[
new FakeRequireSsoDependencyEvent(),
new FakeSingleOrgDependencyEvent()
]);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
var requireSsoPolicy = new Policy
{
Type = PolicyType.RequireSso,
OrganizationId = policyUpdate.OrganizationId,
Enabled = false
};
ArrangeOrganization(sutProvider, policyUpdate);
sutProvider.GetDependency<IPolicyRepository>()
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns([requireSsoPolicy]);
// Act
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SaveAsync(savePolicyModel));
// Assert
Assert.Contains("Turn on the Single organization policy because it is required for the Require single sign-on authentication policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
await AssertPolicyNotSavedAsync(sutProvider);
}
[Theory, BitAutoData]
public async Task SaveAsync_RequiredPolicyNotEnabled_Throws(
[PolicyUpdate(PolicyType.RequireSso)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg, false)] Policy singleOrgPolicy)
{
// Arrange
var sutProvider = SutProviderFactory(
[
new FakeRequireSsoDependencyEvent(),
new FakeSingleOrgDependencyEvent()
]);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
var requireSsoPolicy = new Policy
{
Type = PolicyType.RequireSso,
OrganizationId = policyUpdate.OrganizationId,
Enabled = false
};
ArrangeOrganization(sutProvider, policyUpdate);
sutProvider.GetDependency<IPolicyRepository>()
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns([singleOrgPolicy, requireSsoPolicy]);
// Act
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SaveAsync(savePolicyModel));
// Assert
Assert.Contains("Turn on the Single organization policy because it is required for the Require single sign-on authentication policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
await AssertPolicyNotSavedAsync(sutProvider);
}
[Theory, BitAutoData]
public async Task SaveAsync_RequiredPolicyEnabled_Success(
[PolicyUpdate(PolicyType.RequireSso)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy)
{
// Arrange
var sutProvider = SutProviderFactory(
[
new FakeRequireSsoDependencyEvent(),
new FakeSingleOrgDependencyEvent()
]);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
var requireSsoPolicy = new Policy
{
Type = PolicyType.RequireSso,
OrganizationId = policyUpdate.OrganizationId,
Enabled = false
};
ArrangeOrganization(sutProvider, policyUpdate);
sutProvider.GetDependency<IPolicyRepository>()
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns([singleOrgPolicy, requireSsoPolicy]);
// Act
await sutProvider.Sut.SaveAsync(savePolicyModel);
// Assert
await AssertPolicySavedAsync(sutProvider, policyUpdate);
}
[Theory, BitAutoData]
public async Task SaveAsync_DependentPolicyIsEnabled_Throws(
[PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy currentPolicy,
[Policy(PolicyType.RequireSso)] Policy requireSsoPolicy)
{
// Arrange
var sutProvider = SutProviderFactory(
[
new FakeRequireSsoDependencyEvent(),
new FakeSingleOrgDependencyEvent()
]);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
ArrangeOrganization(sutProvider, policyUpdate);
sutProvider.GetDependency<IPolicyRepository>()
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns([currentPolicy, requireSsoPolicy]);
// Act
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SaveAsync(savePolicyModel));
// Assert
Assert.Contains("Turn off the Require single sign-on authentication policy because it requires the Single organization policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
await AssertPolicyNotSavedAsync(sutProvider);
}
[Theory, BitAutoData]
public async Task SaveAsync_MultipleDependentPoliciesAreEnabled_Throws(
[PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy currentPolicy,
[Policy(PolicyType.RequireSso)] Policy requireSsoPolicy,
[Policy(PolicyType.MaximumVaultTimeout)] Policy vaultTimeoutPolicy)
{
// Arrange
var sutProvider = SutProviderFactory(
[
new FakeRequireSsoDependencyEvent(),
new FakeSingleOrgDependencyEvent(),
new FakeVaultTimeoutDependencyEvent()
]);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
ArrangeOrganization(sutProvider, policyUpdate);
sutProvider.GetDependency<IPolicyRepository>()
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns([currentPolicy, requireSsoPolicy, vaultTimeoutPolicy]);
// Act
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SaveAsync(savePolicyModel));
// Assert
Assert.Contains("Turn off all of the policies that require the Single organization policy", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
await AssertPolicyNotSavedAsync(sutProvider);
}
[Theory, BitAutoData]
public async Task SaveAsync_DependentPolicyNotEnabled_Success(
[PolicyUpdate(PolicyType.SingleOrg, false)] PolicyUpdate policyUpdate,
[Policy(PolicyType.SingleOrg)] Policy currentPolicy,
[Policy(PolicyType.RequireSso, false)] Policy requireSsoPolicy)
{
// Arrange
var sutProvider = SutProviderFactory(
[
new FakeRequireSsoDependencyEvent(),
new FakeSingleOrgDependencyEvent()
]);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
ArrangeOrganization(sutProvider, policyUpdate);
sutProvider.GetDependency<IPolicyRepository>()
.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId)
.Returns([currentPolicy, requireSsoPolicy]);
// Act
await sutProvider.Sut.SaveAsync(savePolicyModel);
// Assert
await AssertPolicySavedAsync(sutProvider, policyUpdate);
}
[Theory, BitAutoData]
public async Task SaveAsync_ThrowsOnValidationError([PolicyUpdate(PolicyType.SingleOrg)] PolicyUpdate policyUpdate)
{
// Arrange
var fakePolicyValidationEvent = new FakeSingleOrgValidationEvent();
fakePolicyValidationEvent.ValidateAsyncMock(Arg.Any<SavePolicyModel>(), Arg.Any<Policy>()).Returns("Validation error!");
var sutProvider = SutProviderFactory([
new FakeSingleOrgDependencyEvent(),
fakePolicyValidationEvent
]);
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
var singleOrgPolicy = new Policy
{
Type = PolicyType.SingleOrg,
OrganizationId = policyUpdate.OrganizationId,
Enabled = false
};
ArrangeOrganization(sutProvider, policyUpdate);
sutProvider.GetDependency<IPolicyRepository>().GetManyByOrganizationIdAsync(policyUpdate.OrganizationId).Returns([singleOrgPolicy]);
// Act
var badRequestException = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SaveAsync(savePolicyModel));
// Assert
Assert.Contains("Validation error!", badRequestException.Message, StringComparison.OrdinalIgnoreCase);
await AssertPolicyNotSavedAsync(sutProvider);
}
/// <summary>
/// Returns a new SutProvider with the PolicyUpdateEvents registered in the Sut.
/// </summary>
private static SutProvider<VNextSavePolicyCommand> SutProviderFactory(
IEnumerable<IPolicyUpdateEvent>? policyUpdateEvents = null)
{
var policyEventHandlerFactory = Substitute.For<IPolicyEventHandlerFactory>();
var handlers = policyUpdateEvents ?? [];
// Setup factory to return handlers based on type
policyEventHandlerFactory.GetHandler<IEnforceDependentPoliciesEvent>(Arg.Any<PolicyType>())
.Returns(callInfo =>
{
var policyType = callInfo.Arg<PolicyType>();
var handler = handlers.OfType<IEnforceDependentPoliciesEvent>().FirstOrDefault(e => e.Type == policyType);
return handler != null ? OneOf.OneOf<IEnforceDependentPoliciesEvent, None>.FromT0(handler) : OneOf.OneOf<IEnforceDependentPoliciesEvent, None>.FromT1(new None());
});
policyEventHandlerFactory.GetHandler<IPolicyValidationEvent>(Arg.Any<PolicyType>())
.Returns(callInfo =>
{
var policyType = callInfo.Arg<PolicyType>();
var handler = handlers.OfType<IPolicyValidationEvent>().FirstOrDefault(e => e.Type == policyType);
return handler != null ? OneOf.OneOf<IPolicyValidationEvent, None>.FromT0(handler) : OneOf.OneOf<IPolicyValidationEvent, None>.FromT1(new None());
});
policyEventHandlerFactory.GetHandler<IOnPolicyPreUpdateEvent>(Arg.Any<PolicyType>())
.Returns(new None());
policyEventHandlerFactory.GetHandler<IOnPolicyPostUpdateEvent>(Arg.Any<PolicyType>())
.Returns(new None());
return new SutProvider<VNextSavePolicyCommand>()
.WithFakeTimeProvider()
.SetDependency(handlers)
.SetDependency(policyEventHandlerFactory)
.Create();
}
private static void ArrangeOrganization(SutProvider<VNextSavePolicyCommand> sutProvider, PolicyUpdate policyUpdate)
{
sutProvider.GetDependency<IApplicationCacheService>()
.GetOrganizationAbilityAsync(policyUpdate.OrganizationId)
.Returns(new OrganizationAbility
{
Id = policyUpdate.OrganizationId,
UsePolicies = true
});
}
private static async Task AssertPolicyNotSavedAsync(SutProvider<VNextSavePolicyCommand> sutProvider)
{
await sutProvider.GetDependency<IPolicyRepository>()
.DidNotReceiveWithAnyArgs()
.UpsertAsync(default!);
await sutProvider.GetDependency<IEventService>()
.DidNotReceiveWithAnyArgs()
.LogPolicyEventAsync(default, default);
}
private static async Task AssertPolicySavedAsync(SutProvider<VNextSavePolicyCommand> sutProvider, PolicyUpdate policyUpdate)
{
await sutProvider.GetDependency<IPolicyRepository>().Received(1).UpsertAsync(ExpectedPolicy());
await sutProvider.GetDependency<IEventService>().Received(1)
.LogPolicyEventAsync(ExpectedPolicy(), EventType.Policy_Updated);
return;
Policy ExpectedPolicy() => Arg.Is<Policy>(
p =>
p.Type == policyUpdate.Type
&& p.OrganizationId == policyUpdate.OrganizationId
&& p.Enabled == policyUpdate.Enabled
&& p.Data == policyUpdate.Data);
}
}

View File

@@ -22,18 +22,20 @@ public class EventIntegrationEventWriteServiceTests
[Theory, BitAutoData]
public async Task CreateAsync_EventPublishedToEventQueue(EventMessage eventMessage)
{
var expected = JsonSerializer.Serialize(eventMessage);
await Subject.CreateAsync(eventMessage);
await _eventIntegrationPublisher.Received(1).PublishEventAsync(
Arg.Is<string>(body => AssertJsonStringsMatch(eventMessage, body)));
body: Arg.Is<string>(body => AssertJsonStringsMatch(eventMessage, body)),
organizationId: Arg.Is<string>(orgId => eventMessage.OrganizationId.ToString().Equals(orgId)));
}
[Theory, BitAutoData]
public async Task CreateManyAsync_EventsPublishedToEventQueue(IEnumerable<EventMessage> eventMessages)
{
var eventMessage = eventMessages.First();
await Subject.CreateManyAsync(eventMessages);
await _eventIntegrationPublisher.Received(1).PublishEventAsync(
Arg.Is<string>(body => AssertJsonStringsMatch(eventMessages, body)));
body: Arg.Is<string>(body => AssertJsonStringsMatch(eventMessages, body)),
organizationId: Arg.Is<string>(orgId => eventMessage.OrganizationId.ToString().Equals(orgId)));
}
private static bool AssertJsonStringsMatch(EventMessage expected, string body)

View File

@@ -23,6 +23,7 @@ public class EventIntegrationHandlerTests
private const string _templateWithOrganization = "Org: #OrganizationName#";
private const string _templateWithUser = "#UserName#, #UserEmail#";
private const string _templateWithActingUser = "#ActingUserName#, #ActingUserEmail#";
private static readonly Guid _organizationId = Guid.NewGuid();
private static readonly Uri _uri = new Uri("https://localhost");
private static readonly Uri _uri2 = new Uri("https://example.com");
private readonly IEventIntegrationPublisher _eventIntegrationPublisher = Substitute.For<IEventIntegrationPublisher>();
@@ -50,6 +51,7 @@ public class EventIntegrationHandlerTests
{
IntegrationType = IntegrationType.Webhook,
MessageId = "TestMessageId",
OrganizationId = _organizationId.ToString(),
Configuration = new WebhookIntegrationConfigurationDetails(_uri),
RenderedTemplate = template,
RetryCount = 0,
@@ -122,6 +124,7 @@ public class EventIntegrationHandlerTests
public async Task HandleEventAsync_BaseTemplateOneConfiguration_PublishesIntegrationMessage(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(OneConfiguration(_templateBase));
eventMessage.OrganizationId = _organizationId;
await sutProvider.Sut.HandleEventAsync(eventMessage);
@@ -140,6 +143,7 @@ public class EventIntegrationHandlerTests
public async Task HandleEventAsync_BaseTemplateTwoConfigurations_PublishesIntegrationMessages(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(TwoConfigurations(_templateBase));
eventMessage.OrganizationId = _organizationId;
await sutProvider.Sut.HandleEventAsync(eventMessage);
@@ -164,6 +168,7 @@ public class EventIntegrationHandlerTests
var user = Substitute.For<User>();
user.Email = "test@example.com";
user.Name = "Test";
eventMessage.OrganizationId = _organizationId;
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(Arg.Any<Guid>()).Returns(user);
await sutProvider.Sut.HandleEventAsync(eventMessage);
@@ -183,6 +188,7 @@ public class EventIntegrationHandlerTests
var sutProvider = GetSutProvider(OneConfiguration(_templateWithOrganization));
var organization = Substitute.For<Organization>();
organization.Name = "Test";
eventMessage.OrganizationId = _organizationId;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(Arg.Any<Guid>()).Returns(organization);
await sutProvider.Sut.HandleEventAsync(eventMessage);
@@ -205,6 +211,7 @@ public class EventIntegrationHandlerTests
var user = Substitute.For<User>();
user.Email = "test@example.com";
user.Name = "Test";
eventMessage.OrganizationId = _organizationId;
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(Arg.Any<Guid>()).Returns(user);
await sutProvider.Sut.HandleEventAsync(eventMessage);
@@ -235,6 +242,7 @@ public class EventIntegrationHandlerTests
var sutProvider = GetSutProvider(ValidFilterConfiguration());
sutProvider.GetDependency<IIntegrationFilterService>().EvaluateFilterGroup(
Arg.Any<IntegrationFilterGroup>(), Arg.Any<EventMessage>()).Returns(true);
eventMessage.OrganizationId = _organizationId;
await sutProvider.Sut.HandleEventAsync(eventMessage);
@@ -284,7 +292,7 @@ public class EventIntegrationHandlerTests
$"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}"
);
await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is(
AssertHelper.AssertPropertyEqual(expectedMessage, new[] { "MessageId" })));
AssertHelper.AssertPropertyEqual(expectedMessage, new[] { "MessageId", "OrganizationId" })));
}
}
@@ -301,12 +309,12 @@ public class EventIntegrationHandlerTests
var expectedMessage = EventIntegrationHandlerTests.expectedMessage(
$"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}"
);
await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is(
AssertHelper.AssertPropertyEqual(expectedMessage, new[] { "MessageId" })));
await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is(AssertHelper.AssertPropertyEqual(
expectedMessage, new[] { "MessageId", "OrganizationId" })));
expectedMessage.Configuration = new WebhookIntegrationConfigurationDetails(_uri2);
await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is(
AssertHelper.AssertPropertyEqual(expectedMessage, new[] { "MessageId" })));
await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is(AssertHelper.AssertPropertyEqual(
expectedMessage, new[] { "MessageId", "OrganizationId" })));
}
}
}

View File

@@ -16,6 +16,7 @@ public class IntegrationHandlerTests
{
Configuration = new WebhookIntegrationConfigurationDetails(new Uri("https://localhost"), "Bearer", "AUTH-TOKEN"),
MessageId = "TestMessageId",
OrganizationId = "TestOrganizationId",
IntegrationType = IntegrationType.Webhook,
RenderedTemplate = "Template",
DelayUntilDate = null,
@@ -25,6 +26,8 @@ public class IntegrationHandlerTests
var result = await sut.HandleAsync(expected.ToJson());
var typedResult = Assert.IsType<IntegrationMessage<WebhookIntegrationConfigurationDetails>>(result.Message);
Assert.Equal(expected.MessageId, typedResult.MessageId);
Assert.Equal(expected.OrganizationId, typedResult.OrganizationId);
Assert.Equal(expected.Configuration, typedResult.Configuration);
Assert.Equal(expected.RenderedTemplate, typedResult.RenderedTemplate);
Assert.Equal(expected.IntegrationType, typedResult.IntegrationType);

View File

@@ -5,17 +5,6 @@ namespace Bit.Core.Test.Services;
public class IntegrationTypeTests
{
[Fact]
public void ToRoutingKey_Slack_Succeeds()
{
Assert.Equal("slack", IntegrationType.Slack.ToRoutingKey());
}
[Fact]
public void ToRoutingKey_Webhook_Succeeds()
{
Assert.Equal("webhook", IntegrationType.Webhook.ToRoutingKey());
}
[Fact]
public void ToRoutingKey_CloudBillingSync_ThrowsException()
{
@@ -27,4 +16,34 @@ public class IntegrationTypeTests
{
Assert.Throws<ArgumentOutOfRangeException>(() => IntegrationType.Scim.ToRoutingKey());
}
[Fact]
public void ToRoutingKey_Slack_Succeeds()
{
Assert.Equal("slack", IntegrationType.Slack.ToRoutingKey());
}
[Fact]
public void ToRoutingKey_Webhook_Succeeds()
{
Assert.Equal("webhook", IntegrationType.Webhook.ToRoutingKey());
}
[Fact]
public void ToRoutingKey_Hec_Succeeds()
{
Assert.Equal("hec", IntegrationType.Hec.ToRoutingKey());
}
[Fact]
public void ToRoutingKey_Datadog_Succeeds()
{
Assert.Equal("datadog", IntegrationType.Datadog.ToRoutingKey());
}
[Fact]
public void ToRoutingKey_Teams_Succeeds()
{
Assert.Equal("teams", IntegrationType.Teams.ToRoutingKey());
}
}

View File

@@ -296,6 +296,18 @@ public class SlackServiceTests
Assert.Equal("test-access-token", result);
}
[Theory]
[InlineData("test-code", "")]
[InlineData("", "https://example.com/callback")]
[InlineData("", "")]
public async Task ObtainTokenViaOAuth_ReturnsEmptyString_WhenCodeOrRedirectUrlIsEmpty(string code, string redirectUrl)
{
var sutProvider = GetSutProvider();
var result = await sutProvider.Sut.ObtainTokenViaOAuth(code, redirectUrl);
Assert.Equal(string.Empty, result);
}
[Fact]
public async Task ObtainTokenViaOAuth_ReturnsEmptyString_WhenErrorResponse()
{

View File

@@ -0,0 +1,126 @@
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
using Microsoft.Rest;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Bit.Core.Test.Services;
[SutProviderCustomize]
public class TeamsIntegrationHandlerTests
{
private readonly ITeamsService _teamsService = Substitute.For<ITeamsService>();
private readonly string _channelId = "C12345";
private readonly Uri _serviceUrl = new Uri("http://localhost");
private SutProvider<TeamsIntegrationHandler> GetSutProvider()
{
return new SutProvider<TeamsIntegrationHandler>()
.SetDependency(_teamsService)
.Create();
}
[Theory, BitAutoData]
public async Task HandleAsync_SuccessfulRequest_ReturnsSuccess(IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
{
var sutProvider = GetSutProvider();
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
var result = await sutProvider.Sut.HandleAsync(message);
Assert.True(result.Success);
Assert.Equal(result.Message, message);
await sutProvider.GetDependency<ITeamsService>().Received(1).SendMessageToChannelAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)),
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate))
);
}
[Theory, BitAutoData]
public async Task HandleAsync_HttpExceptionNonRetryable_ReturnsFalseAndNotRetryable(IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
{
var sutProvider = GetSutProvider();
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
sutProvider.GetDependency<ITeamsService>()
.SendMessageToChannelAsync(Arg.Any<Uri>(), Arg.Any<string>(), Arg.Any<string>())
.ThrowsAsync(new HttpOperationException("Server error")
{
Response = new HttpResponseMessageWrapper(
new HttpResponseMessage(System.Net.HttpStatusCode.Forbidden),
"Forbidden"
)
}
);
var result = await sutProvider.Sut.HandleAsync(message);
Assert.False(result.Success);
Assert.False(result.Retryable);
Assert.Equal(result.Message, message);
await sutProvider.GetDependency<ITeamsService>().Received(1).SendMessageToChannelAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)),
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate))
);
}
[Theory, BitAutoData]
public async Task HandleAsync_HttpExceptionRetryable_ReturnsFalseAndRetryable(IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
{
var sutProvider = GetSutProvider();
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
sutProvider.GetDependency<ITeamsService>()
.SendMessageToChannelAsync(Arg.Any<Uri>(), Arg.Any<string>(), Arg.Any<string>())
.ThrowsAsync(new HttpOperationException("Server error")
{
Response = new HttpResponseMessageWrapper(
new HttpResponseMessage(System.Net.HttpStatusCode.TooManyRequests),
"Too Many Requests"
)
}
);
var result = await sutProvider.Sut.HandleAsync(message);
Assert.False(result.Success);
Assert.True(result.Retryable);
Assert.Equal(result.Message, message);
await sutProvider.GetDependency<ITeamsService>().Received(1).SendMessageToChannelAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)),
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate))
);
}
[Theory, BitAutoData]
public async Task HandleAsync_UnknownException_ReturnsFalseAndNotRetryable(IntegrationMessage<TeamsIntegrationConfigurationDetails> message)
{
var sutProvider = GetSutProvider();
message.Configuration = new TeamsIntegrationConfigurationDetails(_channelId, _serviceUrl);
sutProvider.GetDependency<ITeamsService>()
.SendMessageToChannelAsync(Arg.Any<Uri>(), Arg.Any<string>(), Arg.Any<string>())
.ThrowsAsync(new Exception("Unknown error"));
var result = await sutProvider.Sut.HandleAsync(message);
Assert.False(result.Success);
Assert.False(result.Retryable);
Assert.Equal(result.Message, message);
await sutProvider.GetDependency<ITeamsService>().Received(1).SendMessageToChannelAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_serviceUrl)),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)),
Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate))
);
}
}

View File

@@ -0,0 +1,289 @@
#nullable enable
using System.Net;
using System.Text.Json;
using System.Web;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Models.Teams;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.MockedHttpClient;
using NSubstitute;
using Xunit;
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
namespace Bit.Core.Test.Services;
[SutProviderCustomize]
public class TeamsServiceTests
{
private readonly MockedHttpMessageHandler _handler;
private readonly HttpClient _httpClient;
public TeamsServiceTests()
{
_handler = new MockedHttpMessageHandler();
_httpClient = _handler.ToHttpClient();
}
private SutProvider<TeamsService> GetSutProvider()
{
var clientFactory = Substitute.For<IHttpClientFactory>();
clientFactory.CreateClient(TeamsService.HttpClientName).Returns(_httpClient);
var globalSettings = Substitute.For<GlobalSettings>();
globalSettings.Teams.LoginBaseUrl.Returns("https://login.example.com");
globalSettings.Teams.GraphBaseUrl.Returns("https://graph.example.com");
return new SutProvider<TeamsService>()
.SetDependency(clientFactory)
.SetDependency(globalSettings)
.Create();
}
[Fact]
public void GetRedirectUrl_ReturnsCorrectUrl()
{
var sutProvider = GetSutProvider();
var clientId = sutProvider.GetDependency<GlobalSettings>().Teams.ClientId;
var scopes = sutProvider.GetDependency<GlobalSettings>().Teams.Scopes;
var callbackUrl = "https://example.com/callback";
var state = Guid.NewGuid().ToString();
var result = sutProvider.Sut.GetRedirectUrl(callbackUrl, state);
var uri = new Uri(result);
var query = HttpUtility.ParseQueryString(uri.Query);
Assert.Equal(clientId, query["client_id"]);
Assert.Equal(scopes, query["scope"]);
Assert.Equal(callbackUrl, query["redirect_uri"]);
Assert.Equal(state, query["state"]);
Assert.Equal("login.example.com", uri.Host);
Assert.Equal("/common/oauth2/v2.0/authorize", uri.AbsolutePath);
}
[Fact]
public async Task ObtainTokenViaOAuth_Success_ReturnsAccessToken()
{
var sutProvider = GetSutProvider();
var jsonResponse = JsonSerializer.Serialize(new
{
access_token = "test-access-token"
});
_handler.When("https://login.example.com/common/oauth2/v2.0/token")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(jsonResponse));
var result = await sutProvider.Sut.ObtainTokenViaOAuth("test-code", "https://example.com/callback");
Assert.Equal("test-access-token", result);
}
[Theory]
[InlineData("test-code", "")]
[InlineData("", "https://example.com/callback")]
[InlineData("", "")]
public async Task ObtainTokenViaOAuth_CodeOrRedirectUrlIsEmpty_ReturnsEmptyString(string code, string redirectUrl)
{
var sutProvider = GetSutProvider();
var result = await sutProvider.Sut.ObtainTokenViaOAuth(code, redirectUrl);
Assert.Equal(string.Empty, result);
}
[Fact]
public async Task ObtainTokenViaOAuth_HttpFailure_ReturnsEmptyString()
{
var sutProvider = GetSutProvider();
_handler.When("https://login.example.com/common/oauth2/v2.0/token")
.RespondWith(HttpStatusCode.InternalServerError)
.WithContent(new StringContent(string.Empty));
var result = await sutProvider.Sut.ObtainTokenViaOAuth("test-code", "https://example.com/callback");
Assert.Equal(string.Empty, result);
}
[Fact]
public async Task ObtainTokenViaOAuth_UnknownResponse_ReturnsEmptyString()
{
var sutProvider = GetSutProvider();
_handler.When("https://login.example.com/common/oauth2/v2.0/token")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent("Not an expected response"));
var result = await sutProvider.Sut.ObtainTokenViaOAuth("test-code", "https://example.com/callback");
Assert.Equal(string.Empty, result);
}
[Fact]
public async Task GetJoinedTeamsAsync_Success_ReturnsTeams()
{
var sutProvider = GetSutProvider();
var jsonResponse = JsonSerializer.Serialize(new
{
value = new[]
{
new { id = "team1", displayName = "Team One" },
new { id = "team2", displayName = "Team Two" }
}
});
_handler.When("https://graph.example.com/me/joinedTeams")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(jsonResponse));
var result = await sutProvider.Sut.GetJoinedTeamsAsync("fake-access-token");
Assert.Equal(2, result.Count);
Assert.Contains(result, t => t is { Id: "team1", DisplayName: "Team One" });
Assert.Contains(result, t => t is { Id: "team2", DisplayName: "Team Two" });
}
[Fact]
public async Task GetJoinedTeamsAsync_ServerReturnsEmpty_ReturnsEmptyList()
{
var sutProvider = GetSutProvider();
var jsonResponse = JsonSerializer.Serialize(new { value = (object?)null });
_handler.When("https://graph.example.com/me/joinedTeams")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(jsonResponse));
var result = await sutProvider.Sut.GetJoinedTeamsAsync("fake-access-token");
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public async Task GetJoinedTeamsAsync_ServerErrorCode_ReturnsEmptyList()
{
var sutProvider = GetSutProvider();
_handler.When("https://graph.example.com/me/joinedTeams")
.RespondWith(HttpStatusCode.Unauthorized)
.WithContent(new StringContent("Unauthorized"));
var result = await sutProvider.Sut.GetJoinedTeamsAsync("fake-access-token");
Assert.NotNull(result);
Assert.Empty(result);
}
[Theory, BitAutoData]
public async Task HandleIncomingAppInstall_Success_UpdatesTeamsIntegration(
OrganizationIntegration integration)
{
var sutProvider = GetSutProvider();
var tenantId = Guid.NewGuid().ToString();
var teamId = Guid.NewGuid().ToString();
var conversationId = Guid.NewGuid().ToString();
var serviceUrl = new Uri("https://localhost");
var initiatedConfiguration = new TeamsIntegration(TenantId: tenantId, Teams:
[
new TeamInfo() { Id = teamId, DisplayName = "test team", TenantId = tenantId },
new TeamInfo() { Id = Guid.NewGuid().ToString(), DisplayName = "other team", TenantId = tenantId },
new TeamInfo() { Id = Guid.NewGuid().ToString(), DisplayName = "third team", TenantId = tenantId }
]);
integration.Configuration = JsonSerializer.Serialize(initiatedConfiguration);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId)
.Returns(integration);
OrganizationIntegration? capturedIntegration = null;
await sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.UpsertAsync(Arg.Do<OrganizationIntegration>(x => capturedIntegration = x));
await sutProvider.Sut.HandleIncomingAppInstallAsync(
conversationId: conversationId,
serviceUrl: serviceUrl,
teamId: teamId,
tenantId: tenantId
);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1).GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId);
Assert.NotNull(capturedIntegration);
var configuration = JsonSerializer.Deserialize<TeamsIntegration>(capturedIntegration.Configuration ?? string.Empty);
Assert.NotNull(configuration);
Assert.NotNull(configuration.ServiceUrl);
Assert.Equal(serviceUrl, configuration.ServiceUrl);
Assert.Equal(conversationId, configuration.ChannelId);
}
[Fact]
public async Task HandleIncomingAppInstall_NoIntegrationMatched_DoesNothing()
{
var sutProvider = GetSutProvider();
await sutProvider.Sut.HandleIncomingAppInstallAsync(
conversationId: "conversationId",
serviceUrl: new Uri("https://localhost"),
teamId: "teamId",
tenantId: "tenantId"
);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1).GetByTeamsConfigurationTenantIdTeamId("tenantId", "teamId");
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive().UpsertAsync(Arg.Any<OrganizationIntegration>());
}
[Theory, BitAutoData]
public async Task HandleIncomingAppInstall_MatchedIntegrationAlreadySetup_DoesNothing(
OrganizationIntegration integration)
{
var sutProvider = GetSutProvider();
var tenantId = Guid.NewGuid().ToString();
var teamId = Guid.NewGuid().ToString();
var initiatedConfiguration = new TeamsIntegration(
TenantId: tenantId,
Teams: [new TeamInfo() { Id = teamId, DisplayName = "test team", TenantId = tenantId }],
ChannelId: "ChannelId",
ServiceUrl: new Uri("https://localhost")
);
integration.Configuration = JsonSerializer.Serialize(initiatedConfiguration);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId)
.Returns(integration);
await sutProvider.Sut.HandleIncomingAppInstallAsync(
conversationId: "conversationId",
serviceUrl: new Uri("https://localhost"),
teamId: teamId,
tenantId: tenantId
);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1).GetByTeamsConfigurationTenantIdTeamId(tenantId, teamId);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive().UpsertAsync(Arg.Any<OrganizationIntegration>());
}
[Theory, BitAutoData]
public async Task HandleIncomingAppInstall_MatchedIntegrationWithMissingConfiguration_DoesNothing(
OrganizationIntegration integration)
{
var sutProvider = GetSutProvider();
integration.Configuration = null;
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByTeamsConfigurationTenantIdTeamId("tenantId", "teamId")
.Returns(integration);
await sutProvider.Sut.HandleIncomingAppInstallAsync(
conversationId: "conversationId",
serviceUrl: new Uri("https://localhost"),
teamId: "teamId",
tenantId: "tenantId"
);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1).GetByTeamsConfigurationTenantIdTeamId("tenantId", "teamId");
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().DidNotReceive().UpsertAsync(Arg.Any<OrganizationIntegration>());
}
}

View File

@@ -467,10 +467,9 @@ public class AuthRequestServiceTests
Arg.Any<string>(),
Arg.Any<string>());
var expectedLogMessage = "There are no admin emails to send to.";
sutProvider.GetDependency<ILogger<AuthRequestService>>()
.Received(1)
.LogWarning(expectedLogMessage);
.LogWarning("There are no admin emails to send to.");
}
/// <summary>

View File

@@ -294,7 +294,8 @@ public class InvoiceExtensionsTests
Amount = 600
}
);
invoice.Tax = 120; // $1.20 in cents
invoice.TotalTaxes = [new InvoiceTotalTax { Amount = 120 }]; // $1.20 in cents
var subscription = new Subscription();
// Act
@@ -318,7 +319,7 @@ public class InvoiceExtensionsTests
Amount = 600
}
);
invoice.Tax = null;
invoice.TotalTaxes = [];
var subscription = new Subscription();
// Act
@@ -341,7 +342,7 @@ public class InvoiceExtensionsTests
Amount = 600
}
);
invoice.Tax = 0;
invoice.TotalTaxes = [new InvoiceTotalTax { Amount = 0 }];
var subscription = new Subscription();
// Act
@@ -374,7 +375,7 @@ public class InvoiceExtensionsTests
var invoice = new Invoice
{
Lines = lineItems,
Tax = 200 // Additional $2.00 tax
TotalTaxes = [new InvoiceTotalTax { Amount = 200 }] // Additional $2.00 tax
};
var subscription = new Subscription();

View File

@@ -227,8 +227,16 @@ If you believe you need to change the version for a valid reason, please discuss
Status = "active",
TrialStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
TrialEnd = new DateTime(2024, 2, 1, 0, 0, 0, DateTimeKind.Utc),
CurrentPeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
CurrentPeriodEnd = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc)
Items = new StripeList<SubscriptionItem>
{
Data = [
new SubscriptionItem
{
CurrentPeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
CurrentPeriodEnd = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc)
}
]
}
};
return new SubscriptionInfo

View File

@@ -141,8 +141,16 @@ If you believe you need to change the version for a valid reason, please discuss
Status = "active",
TrialStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
TrialEnd = new DateTime(2024, 2, 1, 0, 0, 0, DateTimeKind.Utc),
CurrentPeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
CurrentPeriodEnd = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc)
Items = new StripeList<SubscriptionItem>
{
Data = [
new SubscriptionItem
{
CurrentPeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
CurrentPeriodEnd = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc)
}
]
}
};
return new SubscriptionInfo

View File

@@ -54,7 +54,7 @@ public class PreviewOrganizationTaxCommandTests
var invoice = new Invoice
{
Tax = 500,
TotalTaxes = [new InvoiceTotalTax { Amount = 500 }],
Total = 5500
};
@@ -77,7 +77,7 @@ public class PreviewOrganizationTaxCommandTests
options.SubscriptionDetails.Items.Count == 1 &&
options.SubscriptionDetails.Items[0].Price == "2021-family-for-enterprise-annually" &&
options.SubscriptionDetails.Items[0].Quantity == 1 &&
options.Coupon == null));
options.Discounts == null));
}
[Fact]
@@ -112,7 +112,7 @@ public class PreviewOrganizationTaxCommandTests
var invoice = new Invoice
{
Tax = 750,
TotalTaxes = [new InvoiceTotalTax { Amount = 750 }],
Total = 8250
};
@@ -137,7 +137,9 @@ public class PreviewOrganizationTaxCommandTests
item.Price == "2023-teams-org-seat-monthly" && item.Quantity == 5) &&
options.SubscriptionDetails.Items.Any(item =>
item.Price == "secrets-manager-teams-seat-monthly" && item.Quantity == 3) &&
options.Coupon == CouponIDs.SecretsManagerStandalone));
options.Discounts != null &&
options.Discounts.Count == 1 &&
options.Discounts[0].Coupon == CouponIDs.SecretsManagerStandalone));
}
[Fact]
@@ -173,7 +175,7 @@ public class PreviewOrganizationTaxCommandTests
var invoice = new Invoice
{
Tax = 1200,
TotalTaxes = [new InvoiceTotalTax { Amount = 1200 }],
Total = 12200
};
@@ -205,7 +207,7 @@ public class PreviewOrganizationTaxCommandTests
item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 8) &&
options.SubscriptionDetails.Items.Any(item =>
item.Price == "secrets-manager-service-account-2024-annually" && item.Quantity == 3) &&
options.Coupon == null));
options.Discounts == null));
}
[Fact]
@@ -234,7 +236,7 @@ public class PreviewOrganizationTaxCommandTests
var invoice = new Invoice
{
Tax = 300,
TotalTaxes = [new InvoiceTotalTax { Amount = 300 }],
Total = 3300
};
@@ -257,7 +259,7 @@ public class PreviewOrganizationTaxCommandTests
options.SubscriptionDetails.Items.Count == 1 &&
options.SubscriptionDetails.Items[0].Price == "2020-families-org-annually" &&
options.SubscriptionDetails.Items[0].Quantity == 6 &&
options.Coupon == null));
options.Discounts == null));
}
[Fact]
@@ -286,7 +288,7 @@ public class PreviewOrganizationTaxCommandTests
var invoice = new Invoice
{
Tax = 0,
TotalTaxes = [new InvoiceTotalTax { Amount = 0 }],
Total = 2700
};
@@ -309,7 +311,7 @@ public class PreviewOrganizationTaxCommandTests
options.SubscriptionDetails.Items.Count == 1 &&
options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" &&
options.SubscriptionDetails.Items[0].Quantity == 3 &&
options.Coupon == null));
options.Discounts == null));
}
[Fact]
@@ -339,7 +341,7 @@ public class PreviewOrganizationTaxCommandTests
var invoice = new Invoice
{
Tax = 2100,
TotalTaxes = [new InvoiceTotalTax { Amount = 2100 }],
Total = 12100
};
@@ -365,7 +367,7 @@ public class PreviewOrganizationTaxCommandTests
options.SubscriptionDetails.Items.Count == 1 &&
options.SubscriptionDetails.Items[0].Price == "2023-enterprise-seat-monthly" &&
options.SubscriptionDetails.Items[0].Quantity == 15 &&
options.Coupon == null));
options.Discounts == null));
}
#endregion
@@ -399,7 +401,7 @@ public class PreviewOrganizationTaxCommandTests
var invoice = new Invoice
{
Tax = 120,
TotalTaxes = [new InvoiceTotalTax { Amount = 120 }],
Total = 1320
};
@@ -422,7 +424,7 @@ public class PreviewOrganizationTaxCommandTests
options.SubscriptionDetails.Items.Count == 1 &&
options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" &&
options.SubscriptionDetails.Items[0].Quantity == 2 &&
options.Coupon == null));
options.Discounts == null));
}
[Fact]
@@ -452,7 +454,7 @@ public class PreviewOrganizationTaxCommandTests
var invoice = new Invoice
{
Tax = 400,
TotalTaxes = [new InvoiceTotalTax { Amount = 400 }],
Total = 4400
};
@@ -474,8 +476,158 @@ public class PreviewOrganizationTaxCommandTests
options.CustomerDetails.TaxExempt == TaxExempt.None &&
options.SubscriptionDetails.Items.Count == 1 &&
options.SubscriptionDetails.Items[0].Price == "2020-families-org-annually" &&
options.SubscriptionDetails.Items[0].Quantity == 2 &&
options.Coupon == null));
options.SubscriptionDetails.Items[0].Quantity == 1 &&
options.Discounts == null));
}
[Fact]
public async Task Run_OrganizationPlanChange_FamiliesOrganizationToTeams_UsesOrganizationSeats()
{
var organization = new Organization
{
Id = Guid.NewGuid(),
PlanType = PlanType.FamiliesAnnually,
GatewayCustomerId = "cus_test123",
GatewaySubscriptionId = "sub_test123",
UseSecretsManager = false,
Seats = 6
};
var planChange = new OrganizationSubscriptionPlanChange
{
Tier = ProductTierType.Teams,
Cadence = PlanCadenceType.Annually
};
var billingAddress = new BillingAddress
{
Country = "US",
PostalCode = "10012"
};
var currentPlan = new FamiliesPlan();
var newPlan = new TeamsPlan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan);
_pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(newPlan);
var subscriptionItems = new List<SubscriptionItem>
{
new() { Price = new Price { Id = "2020-families-org-annually" }, Quantity = 1 }
};
var subscription = new Subscription
{
Id = "sub_test123",
Items = new StripeList<SubscriptionItem> { Data = subscriptionItems },
Customer = new Customer { Discount = null }
};
_stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any<SubscriptionGetOptions>()).Returns(subscription);
var invoice = new Invoice
{
TotalTaxes = [new InvoiceTotalTax
{
Amount = 900
}
],
Total = 9900
};
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>()).Returns(invoice);
var result = await _command.Run(organization, planChange, billingAddress);
Assert.True(result.IsT0);
var (tax, total) = result.AsT0;
Assert.Equal(9.00m, tax);
Assert.Equal(99.00m, total);
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
options.AutomaticTax.Enabled == true &&
options.Currency == "usd" &&
options.CustomerDetails.Address.Country == "US" &&
options.CustomerDetails.Address.PostalCode == "10012" &&
options.CustomerDetails.TaxExempt == TaxExempt.None &&
options.SubscriptionDetails.Items.Count == 1 &&
options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-annually" &&
options.SubscriptionDetails.Items[0].Quantity == 6 &&
options.Discounts == null));
}
[Fact]
public async Task Run_OrganizationPlanChange_FamiliesOrganizationToEnterprise_UsesOrganizationSeats()
{
var organization = new Organization
{
Id = Guid.NewGuid(),
PlanType = PlanType.FamiliesAnnually,
GatewayCustomerId = "cus_test123",
GatewaySubscriptionId = "sub_test123",
UseSecretsManager = false,
Seats = 6
};
var planChange = new OrganizationSubscriptionPlanChange
{
Tier = ProductTierType.Enterprise,
Cadence = PlanCadenceType.Annually
};
var billingAddress = new BillingAddress
{
Country = "US",
PostalCode = "10012"
};
var currentPlan = new FamiliesPlan();
var newPlan = new EnterprisePlan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan);
_pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(newPlan);
var subscriptionItems = new List<SubscriptionItem>
{
new() { Price = new Price { Id = "2020-families-org-annually" }, Quantity = 1 }
};
var subscription = new Subscription
{
Id = "sub_test123",
Items = new StripeList<SubscriptionItem> { Data = subscriptionItems },
Customer = new Customer { Discount = null }
};
_stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any<SubscriptionGetOptions>()).Returns(subscription);
var invoice = new Invoice
{
TotalTaxes = [new InvoiceTotalTax
{
Amount = 1200
}
],
Total = 13200
};
_stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>()).Returns(invoice);
var result = await _command.Run(organization, planChange, billingAddress);
Assert.True(result.IsT0);
var (tax, total) = result.AsT0;
Assert.Equal(12.00m, tax);
Assert.Equal(132.00m, total);
await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is<InvoiceCreatePreviewOptions>(options =>
options.AutomaticTax.Enabled == true &&
options.Currency == "usd" &&
options.CustomerDetails.Address.Country == "US" &&
options.CustomerDetails.Address.PostalCode == "10012" &&
options.CustomerDetails.TaxExempt == TaxExempt.None &&
options.SubscriptionDetails.Items.Count == 1 &&
options.SubscriptionDetails.Items[0].Price == "2023-enterprise-org-seat-annually" &&
options.SubscriptionDetails.Items[0].Quantity == 6 &&
options.Discounts == null));
}
[Fact]
@@ -505,7 +657,7 @@ public class PreviewOrganizationTaxCommandTests
var invoice = new Invoice
{
Tax = 800,
TotalTaxes = [new InvoiceTotalTax { Amount = 800 }],
Total = 8800
};
@@ -530,7 +682,7 @@ public class PreviewOrganizationTaxCommandTests
item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 2) &&
options.SubscriptionDetails.Items.Any(item =>
item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 2) &&
options.Coupon == null));
options.Discounts == null));
}
[Fact]
@@ -582,7 +734,7 @@ public class PreviewOrganizationTaxCommandTests
var invoice = new Invoice
{
Tax = 1500,
TotalTaxes = [new InvoiceTotalTax { Amount = 1500 }],
Total = 16500
};
@@ -611,7 +763,7 @@ public class PreviewOrganizationTaxCommandTests
item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 5) &&
options.SubscriptionDetails.Items.Any(item =>
item.Price == "secrets-manager-service-account-2024-annually" && item.Quantity == 10) &&
options.Coupon == null));
options.Discounts == null));
}
[Fact]
@@ -666,7 +818,7 @@ public class PreviewOrganizationTaxCommandTests
var invoice = new Invoice
{
Tax = 600,
TotalTaxes = [new InvoiceTotalTax { Amount = 600 }],
Total = 6600
};
@@ -689,7 +841,9 @@ public class PreviewOrganizationTaxCommandTests
options.SubscriptionDetails.Items.Count == 1 &&
options.SubscriptionDetails.Items[0].Price == "2023-enterprise-org-seat-annually" &&
options.SubscriptionDetails.Items[0].Quantity == 5 &&
options.Coupon == "EXISTING_DISCOUNT_50"));
options.Discounts != null &&
options.Discounts.Count == 1 &&
options.Discounts[0].Coupon == "EXISTING_DISCOUNT_50"));
}
[Fact]
@@ -769,7 +923,7 @@ public class PreviewOrganizationTaxCommandTests
var invoice = new Invoice
{
Tax = 600,
TotalTaxes = [new InvoiceTotalTax { Amount = 600 }],
Total = 6600
};
@@ -792,7 +946,7 @@ public class PreviewOrganizationTaxCommandTests
options.SubscriptionDetails.Items.Count == 1 &&
options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" &&
options.SubscriptionDetails.Items[0].Quantity == 10 &&
options.Coupon == null));
options.Discounts == null));
}
[Fact]
@@ -834,7 +988,7 @@ public class PreviewOrganizationTaxCommandTests
var invoice = new Invoice
{
Tax = 1200,
TotalTaxes = [new InvoiceTotalTax { Amount = 1200 }],
Total = 13200
};
@@ -859,7 +1013,7 @@ public class PreviewOrganizationTaxCommandTests
item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 15) &&
options.SubscriptionDetails.Items.Any(item =>
item.Price == "storage-gb-annually" && item.Quantity == 5) &&
options.Coupon == null));
options.Discounts == null));
}
[Fact]
@@ -901,7 +1055,7 @@ public class PreviewOrganizationTaxCommandTests
var invoice = new Invoice
{
Tax = 800,
TotalTaxes = [new InvoiceTotalTax { Amount = 800 }],
Total = 8800
};
@@ -924,7 +1078,7 @@ public class PreviewOrganizationTaxCommandTests
options.SubscriptionDetails.Items.Count == 1 &&
options.SubscriptionDetails.Items[0].Price == "secrets-manager-teams-seat-annually" &&
options.SubscriptionDetails.Items[0].Quantity == 8 &&
options.Coupon == null));
options.Discounts == null));
}
[Fact]
@@ -956,10 +1110,7 @@ public class PreviewOrganizationTaxCommandTests
Discount = null,
TaxIds = new StripeList<TaxId>
{
Data = new List<TaxId>
{
new() { Type = "gb_vat", Value = "GB123456789" }
}
Data = [new TaxId { Type = "gb_vat", Value = "GB123456789" }]
}
};
@@ -972,7 +1123,7 @@ public class PreviewOrganizationTaxCommandTests
var invoice = new Invoice
{
Tax = 1500,
TotalTaxes = [new InvoiceTotalTax { Amount = 1500 }],
Total = 16500
};
@@ -1000,7 +1151,7 @@ public class PreviewOrganizationTaxCommandTests
item.Price == "secrets-manager-enterprise-seat-monthly" && item.Quantity == 12) &&
options.SubscriptionDetails.Items.Any(item =>
item.Price == "secrets-manager-service-account-2024-monthly" && item.Quantity == 20) &&
options.Coupon == null));
options.Discounts == null));
}
[Fact]
@@ -1040,10 +1191,7 @@ public class PreviewOrganizationTaxCommandTests
},
TaxIds = new StripeList<TaxId>
{
Data = new List<TaxId>
{
new() { Type = TaxIdType.SpanishNIF, Value = "12345678Z" }
}
Data = [new TaxId { Type = TaxIdType.SpanishNIF, Value = "12345678Z" }]
}
};
@@ -1056,7 +1204,7 @@ public class PreviewOrganizationTaxCommandTests
var invoice = new Invoice
{
Tax = 2500,
TotalTaxes = [new InvoiceTotalTax { Amount = 2500 }],
Total = 27500
};
@@ -1088,7 +1236,9 @@ public class PreviewOrganizationTaxCommandTests
item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 15) &&
options.SubscriptionDetails.Items.Any(item =>
item.Price == "secrets-manager-service-account-2024-annually" && item.Quantity == 30) &&
options.Coupon == "ENTERPRISE_DISCOUNT_20"));
options.Discounts != null &&
options.Discounts.Count == 1 &&
options.Discounts[0].Coupon == "ENTERPRISE_DISCOUNT_20"));
}
[Fact]
@@ -1130,7 +1280,7 @@ public class PreviewOrganizationTaxCommandTests
var invoice = new Invoice
{
Tax = 500,
TotalTaxes = [new InvoiceTotalTax { Amount = 500 }],
Total = 5500
};
@@ -1155,7 +1305,7 @@ public class PreviewOrganizationTaxCommandTests
item.Price == "2020-families-org-annually" && item.Quantity == 6) &&
options.SubscriptionDetails.Items.Any(item =>
item.Price == "personal-storage-gb-annually" && item.Quantity == 2) &&
options.Coupon == null));
options.Discounts == null));
}
[Fact]
@@ -1232,7 +1382,7 @@ public class PreviewOrganizationTaxCommandTests
var invoice = new Invoice
{
Tax = 300,
TotalTaxes = [new InvoiceTotalTax { Amount = 300 }],
Total = 3300
};
@@ -1255,7 +1405,7 @@ public class PreviewOrganizationTaxCommandTests
options.SubscriptionDetails.Items.Count == 1 &&
options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" &&
options.SubscriptionDetails.Items[0].Quantity == 5 &&
options.Coupon == null));
options.Discounts == null));
}
#endregion

View File

@@ -88,7 +88,7 @@ public class UpdateOrganizationLicenseCommandTests
"Hash", "Signature", "SignatureBytes", "InstallationId", "Expires",
"ExpirationWithoutGracePeriod", "Token", "LimitCollectionCreationDeletion",
"LimitCollectionCreation", "LimitCollectionDeletion", "AllowAdminAccessToAllCollectionItems",
"UseOrganizationDomains", "UseAdminSponsoredFamilies") &&
"UseOrganizationDomains", "UseAdminSponsoredFamilies", "UseAutomaticUserConfirmation") &&
// Same property but different name, use explicit mapping
org.ExpirationDate == license.Expires));
}

View File

@@ -27,25 +27,27 @@ public class GetCloudOrganizationLicenseQueryTests
{
[Theory]
[BitAutoData]
public async Task GetLicenseAsync_InvalidInstallationId_Throws(SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
public async Task GetLicenseAsync_InvalidInstallationId_Throws(
SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
Organization organization, Guid installationId, int version)
{
sutProvider.GetDependency<IInstallationRepository>().GetByIdAsync(installationId).ReturnsNull();
var exception = await Assert.ThrowsAsync<BadRequestException>(
async () => await sutProvider.Sut.GetLicenseAsync(organization, installationId, version));
var exception = await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.GetLicenseAsync(organization, installationId, version));
Assert.Contains("Invalid installation id", exception.Message);
}
[Theory]
[BitAutoData]
public async Task GetLicenseAsync_DisabledOrganization_Throws(SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
public async Task GetLicenseAsync_DisabledOrganization_Throws(
SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
Organization organization, Guid installationId, Installation installation)
{
installation.Enabled = false;
sutProvider.GetDependency<IInstallationRepository>().GetByIdAsync(installationId).Returns(installation);
var exception = await Assert.ThrowsAsync<BadRequestException>(
async () => await sutProvider.Sut.GetLicenseAsync(organization, installationId));
var exception = await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.GetLicenseAsync(organization, installationId));
Assert.Contains("Invalid installation id", exception.Message);
}
@@ -71,7 +73,8 @@ public class GetCloudOrganizationLicenseQueryTests
[Theory]
[BitAutoData]
public async Task GetLicenseAsync_WhenFeatureFlagEnabled_CreatesToken(SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
public async Task GetLicenseAsync_WhenFeatureFlagEnabled_CreatesToken(
SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
Organization organization, Guid installationId, Installation installation, SubscriptionInfo subInfo,
byte[] licenseSignature, string token)
{
@@ -90,7 +93,8 @@ public class GetCloudOrganizationLicenseQueryTests
[Theory]
[BitAutoData]
public async Task GetLicenseAsync_MSPManagedOrganization_UsesProviderSubscription(SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
public async Task GetLicenseAsync_MSPManagedOrganization_UsesProviderSubscription(
SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
Organization organization, Guid installationId, Installation installation, SubscriptionInfo subInfo,
byte[] licenseSignature, Provider provider)
{
@@ -99,8 +103,17 @@ public class GetCloudOrganizationLicenseQueryTests
subInfo.Subscription = new SubscriptionInfo.BillingSubscription(new Subscription
{
CurrentPeriodStart = DateTime.UtcNow,
CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1)
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodStart = DateTime.UtcNow,
CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1)
}
]
}
});
installation.Enabled = true;

View File

@@ -0,0 +1,369 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Billing.Organizations.Queries;
[SutProviderCustomize]
public class GetOrganizationMetadataQueryTests
{
[Theory, BitAutoData]
public async Task Run_NullOrganization_ReturnsNull(
SutProvider<GetOrganizationMetadataQuery> sutProvider)
{
var result = await sutProvider.Sut.Run(null);
Assert.Null(result);
}
[Theory, BitAutoData]
public async Task Run_SelfHosted_ReturnsDefault(
Organization organization,
SutProvider<GetOrganizationMetadataQuery> sutProvider)
{
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(true);
var result = await sutProvider.Sut.Run(organization);
Assert.Equal(OrganizationMetadata.Default, result);
}
[Theory, BitAutoData]
public async Task Run_NoGatewaySubscriptionId_ReturnsDefaultWithOccupiedSeats(
Organization organization,
SutProvider<GetOrganizationMetadataQuery> sutProvider)
{
organization.GatewaySubscriptionId = null;
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
sutProvider.GetDependency<IOrganizationRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
.Returns(new OrganizationSeatCounts { Users = 10, Sponsored = 0 });
var result = await sutProvider.Sut.Run(organization);
Assert.NotNull(result);
Assert.False(result.IsOnSecretsManagerStandalone);
Assert.Equal(10, result.OrganizationOccupiedSeats);
}
[Theory, BitAutoData]
public async Task Run_NullCustomer_ReturnsDefaultWithOccupiedSeats(
Organization organization,
SutProvider<GetOrganizationMetadataQuery> sutProvider)
{
organization.GatewaySubscriptionId = "sub_123";
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
sutProvider.GetDependency<IOrganizationRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
.Returns(new OrganizationSeatCounts { Users = 5, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.ReturnsNull();
var result = await sutProvider.Sut.Run(organization);
Assert.NotNull(result);
Assert.False(result.IsOnSecretsManagerStandalone);
Assert.Equal(5, result.OrganizationOccupiedSeats);
}
[Theory, BitAutoData]
public async Task Run_NullSubscription_ReturnsDefaultWithOccupiedSeats(
Organization organization,
SutProvider<GetOrganizationMetadataQuery> sutProvider)
{
organization.GatewaySubscriptionId = "sub_123";
var customer = new Customer();
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
sutProvider.GetDependency<IOrganizationRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
.Returns(new OrganizationSeatCounts { Users = 7, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.Returns(customer);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization)
.ReturnsNull();
var result = await sutProvider.Sut.Run(organization);
Assert.NotNull(result);
Assert.False(result.IsOnSecretsManagerStandalone);
Assert.Equal(7, result.OrganizationOccupiedSeats);
}
[Theory, BitAutoData]
public async Task Run_WithSecretsManagerStandaloneCoupon_ReturnsMetadataWithFlag(
Organization organization,
SutProvider<GetOrganizationMetadataQuery> sutProvider)
{
organization.GatewaySubscriptionId = "sub_123";
organization.PlanType = PlanType.EnterpriseAnnually;
var productId = "product_123";
var customer = new Customer
{
Discount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
AppliesTo = new CouponAppliesTo
{
Products = [productId]
}
}
}
};
var subscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Plan = new Plan
{
ProductId = productId
}
}
]
}
};
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
sutProvider.GetDependency<IOrganizationRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
.Returns(new OrganizationSeatCounts { Users = 15, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.Returns(customer);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization)
.Returns(subscription);
sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType));
var result = await sutProvider.Sut.Run(organization);
Assert.NotNull(result);
Assert.True(result.IsOnSecretsManagerStandalone);
Assert.Equal(15, result.OrganizationOccupiedSeats);
}
[Theory, BitAutoData]
public async Task Run_WithoutSecretsManagerStandaloneCoupon_ReturnsMetadataWithoutFlag(
Organization organization,
SutProvider<GetOrganizationMetadataQuery> sutProvider)
{
organization.GatewaySubscriptionId = "sub_123";
organization.PlanType = PlanType.TeamsAnnually;
var customer = new Customer
{
Discount = null
};
var subscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Plan = new Plan
{
ProductId = "product_123"
}
}
]
}
};
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
sutProvider.GetDependency<IOrganizationRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
.Returns(new OrganizationSeatCounts { Users = 20, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.Returns(customer);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization)
.Returns(subscription);
sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType));
var result = await sutProvider.Sut.Run(organization);
Assert.NotNull(result);
Assert.False(result.IsOnSecretsManagerStandalone);
Assert.Equal(20, result.OrganizationOccupiedSeats);
}
[Theory, BitAutoData]
public async Task Run_CouponDoesNotApplyToSubscriptionProducts_ReturnsFalseForStandaloneFlag(
Organization organization,
SutProvider<GetOrganizationMetadataQuery> sutProvider)
{
organization.GatewaySubscriptionId = "sub_123";
organization.PlanType = PlanType.EnterpriseAnnually;
var customer = new Customer
{
Discount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
AppliesTo = new CouponAppliesTo
{
Products = ["different_product_id"]
}
}
}
};
var subscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Plan = new Plan
{
ProductId = "product_123"
}
}
]
}
};
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
sutProvider.GetDependency<IOrganizationRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
.Returns(new OrganizationSeatCounts { Users = 12, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.Returns(customer);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization)
.Returns(subscription);
sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType));
var result = await sutProvider.Sut.Run(organization);
Assert.NotNull(result);
Assert.False(result.IsOnSecretsManagerStandalone);
Assert.Equal(12, result.OrganizationOccupiedSeats);
}
[Theory, BitAutoData]
public async Task Run_PlanDoesNotSupportSecretsManager_ReturnsFalseForStandaloneFlag(
Organization organization,
SutProvider<GetOrganizationMetadataQuery> sutProvider)
{
organization.GatewaySubscriptionId = "sub_123";
organization.PlanType = PlanType.FamiliesAnnually;
var productId = "product_123";
var customer = new Customer
{
Discount = new Discount
{
Coupon = new Coupon
{
Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
AppliesTo = new CouponAppliesTo
{
Products = [productId]
}
}
}
};
var subscription = new Subscription
{
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
Plan = new Plan
{
ProductId = productId
}
}
]
}
};
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(false);
sutProvider.GetDependency<IOrganizationRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
.Returns(new OrganizationSeatCounts { Users = 8, Sponsored = 0 });
sutProvider.GetDependency<ISubscriberService>()
.GetCustomer(organization, Arg.Is<CustomerGetOptions>(options =>
options.Expand.Contains("discount.coupon.applies_to")))
.Returns(customer);
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization)
.Returns(subscription);
sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType));
var result = await sutProvider.Sut.Run(organization);
Assert.NotNull(result);
Assert.False(result.IsOnSecretsManagerStandalone);
Assert.Equal(8, result.OrganizationOccupiedSeats);
}
}

View File

@@ -2,10 +2,10 @@
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Services;
@@ -75,7 +75,7 @@ public class GetOrganizationWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().EditSubscription(organization.Id).Returns(true);
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(organization.Id).Returns((string?)null);
sutProvider.GetDependency<IHasPaymentMethodQuery>().Run(organization).Returns(false);
var response = await sutProvider.Sut.Run(organization);
@@ -86,12 +86,11 @@ public class GetOrganizationWarningsQueryTests
}
[Theory, BitAutoData]
public async Task Run_Has_FreeTrialWarning_WithUnverifiedBankAccount_NoWarning(
public async Task Run_Has_FreeTrialWarning_WithPaymentMethod_NoWarning(
Organization organization,
SutProvider<GetOrganizationWarningsQuery> sutProvider)
{
var now = DateTime.UtcNow;
const string setupIntentId = "setup_intent_id";
sutProvider.GetDependency<ISubscriberService>()
.GetSubscription(organization, Arg.Is<SubscriptionGetOptions>(options =>
@@ -113,20 +112,7 @@ public class GetOrganizationWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().EditSubscription(organization.Id).Returns(true);
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(organization.Id).Returns(setupIntentId);
sutProvider.GetDependency<IStripeAdapter>().SetupIntentGet(setupIntentId, Arg.Is<SetupIntentGetOptions>(
options => options.Expand.Contains("payment_method"))).Returns(new SetupIntent
{
Status = "requires_action",
NextAction = new SetupIntentNextAction
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
},
PaymentMethod = new PaymentMethod
{
UsBankAccount = new PaymentMethodUsBankAccount()
}
});
sutProvider.GetDependency<IHasPaymentMethodQuery>().Run(organization).Returns(true);
var response = await sutProvider.Sut.Run(organization);
@@ -286,7 +272,16 @@ public class GetOrganizationWarningsQueryTests
CollectionMethod = CollectionMethod.SendInvoice,
Customer = new Customer(),
Status = SubscriptionStatus.Active,
CurrentPeriodEnd = now.AddDays(10),
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = now.AddDays(10)
}
]
},
TestClock = new TestClock
{
FrozenTime = now

View File

@@ -0,0 +1,112 @@
using System.Text.Json;
using Bit.Core.Billing.Payment.Models;
using Xunit;
namespace Bit.Core.Test.Billing.Payment.Models;
public class PaymentMethodTests
{
[Theory]
[InlineData("{\"cardNumber\":\"1234\"}")]
[InlineData("{\"type\":\"unknown_type\",\"data\":\"value\"}")]
[InlineData("{\"type\":\"invalid\",\"token\":\"test-token\"}")]
[InlineData("{\"type\":\"invalid\"}")]
public void Read_ShouldThrowJsonException_OnInvalidOrMissingType(string json)
{
// Arrange
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act & Assert
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<PaymentMethod>(json, options));
}
[Theory]
[InlineData("{\"type\":\"card\"}")]
[InlineData("{\"type\":\"card\",\"token\":\"\"}")]
[InlineData("{\"type\":\"card\",\"token\":null}")]
public void Read_ShouldThrowJsonException_OnInvalidTokenizedPaymentMethodToken(string json)
{
// Arrange
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act & Assert
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<PaymentMethod>(json, options));
}
// Tokenized payment method deserialization
[Theory]
[InlineData("bankAccount", TokenizablePaymentMethodType.BankAccount)]
[InlineData("card", TokenizablePaymentMethodType.Card)]
[InlineData("payPal", TokenizablePaymentMethodType.PayPal)]
public void Read_ShouldDeserializeTokenizedPaymentMethods(string typeString, TokenizablePaymentMethodType expectedType)
{
// Arrange
var json = $"{{\"type\":\"{typeString}\",\"token\":\"test-token\"}}";
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act
var result = JsonSerializer.Deserialize<PaymentMethod>(json, options);
// Assert
Assert.True(result.IsTokenized);
Assert.Equal(expectedType, result.AsT0.Type);
Assert.Equal("test-token", result.AsT0.Token);
}
// Non-tokenized payment method deserialization
[Theory]
[InlineData("accountcredit", NonTokenizablePaymentMethodType.AccountCredit)]
public void Read_ShouldDeserializeNonTokenizedPaymentMethods(string typeString, NonTokenizablePaymentMethodType expectedType)
{
// Arrange
var json = $"{{\"type\":\"{typeString}\"}}";
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act
var result = JsonSerializer.Deserialize<PaymentMethod>(json, options);
// Assert
Assert.True(result.IsNonTokenized);
Assert.Equal(expectedType, result.AsT1.Type);
}
// Tokenized payment method serialization
[Theory]
[InlineData(TokenizablePaymentMethodType.BankAccount, "bankaccount")]
[InlineData(TokenizablePaymentMethodType.Card, "card")]
[InlineData(TokenizablePaymentMethodType.PayPal, "paypal")]
public void Write_ShouldSerializeTokenizedPaymentMethods(TokenizablePaymentMethodType type, string expectedTypeString)
{
// Arrange
var paymentMethod = new PaymentMethod(new TokenizedPaymentMethod
{
Type = type,
Token = "test-token"
});
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act
var json = JsonSerializer.Serialize(paymentMethod, options);
// Assert
Assert.Contains($"\"type\":\"{expectedTypeString}\"", json);
Assert.Contains("\"token\":\"test-token\"", json);
}
// Non-tokenized payment method serialization
[Theory]
[InlineData(NonTokenizablePaymentMethodType.AccountCredit, "accountcredit")]
public void Write_ShouldSerializeNonTokenizedPaymentMethods(NonTokenizablePaymentMethodType type, string expectedTypeString)
{
// Arrange
var paymentMethod = new PaymentMethod(new NonTokenizedPaymentMethod { Type = type });
var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } };
// Act
var json = JsonSerializer.Serialize(paymentMethod, options);
// Assert
Assert.Contains($"\"type\":\"{expectedTypeString}\"", json);
Assert.DoesNotContain("token", json);
}
}

View File

@@ -0,0 +1,264 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Services;
using Bit.Core.Test.Billing.Extensions;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Billing.Payment.Queries;
using static StripeConstants;
public class HasPaymentMethodQueryTests
{
private readonly ISetupIntentCache _setupIntentCache = Substitute.For<ISetupIntentCache>();
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
private readonly HasPaymentMethodQuery _query;
public HasPaymentMethodQueryTests()
{
_query = new HasPaymentMethodQuery(
_setupIntentCache,
_stripeAdapter,
_subscriberService);
}
[Fact]
public async Task Run_NoCustomer_ReturnsFalse()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
_subscriberService.GetCustomer(organization).ReturnsNull();
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns((string)null);
var hasPaymentMethod = await _query.Run(organization);
Assert.False(hasPaymentMethod);
}
[Fact]
public async Task Run_NoCustomer_WithUnverifiedBankAccount_ReturnsTrue()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
_subscriberService.GetCustomer(organization).ReturnsNull();
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123");
_stripeAdapter
.SetupIntentGet("seti_123",
Arg.Is<SetupIntentGetOptions>(options => options.HasExpansions("payment_method")))
.Returns(new SetupIntent
{
Status = "requires_action",
NextAction = new SetupIntentNextAction
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
},
PaymentMethod = new PaymentMethod
{
UsBankAccount = new PaymentMethodUsBankAccount()
}
});
var hasPaymentMethod = await _query.Run(organization);
Assert.True(hasPaymentMethod);
}
[Fact]
public async Task Run_NoPaymentMethod_ReturnsFalse()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
var hasPaymentMethod = await _query.Run(organization);
Assert.False(hasPaymentMethod);
}
[Fact]
public async Task Run_HasDefaultPaymentMethodId_ReturnsTrue()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings
{
DefaultPaymentMethodId = "pm_123"
},
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
var hasPaymentMethod = await _query.Run(organization);
Assert.True(hasPaymentMethod);
}
[Fact]
public async Task Run_HasDefaultSourceId_ReturnsTrue()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
DefaultSourceId = "card_123",
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
var hasPaymentMethod = await _query.Run(organization);
Assert.True(hasPaymentMethod);
}
[Fact]
public async Task Run_HasUnverifiedBankAccount_ReturnsTrue()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123");
_stripeAdapter
.SetupIntentGet("seti_123",
Arg.Is<SetupIntentGetOptions>(options => options.HasExpansions("payment_method")))
.Returns(new SetupIntent
{
Status = "requires_action",
NextAction = new SetupIntentNextAction
{
VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits()
},
PaymentMethod = new PaymentMethod
{
UsBankAccount = new PaymentMethodUsBankAccount()
}
});
var hasPaymentMethod = await _query.Run(organization);
Assert.True(hasPaymentMethod);
}
[Fact]
public async Task Run_HasBraintreeCustomerId_ReturnsTrue()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>
{
[MetadataKeys.BraintreeCustomerId] = "braintree_customer_id"
}
};
_subscriberService.GetCustomer(organization).Returns(customer);
var hasPaymentMethod = await _query.Run(organization);
Assert.True(hasPaymentMethod);
}
[Fact]
public async Task Run_NoSetupIntentId_ReturnsFalse()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns((string)null);
var hasPaymentMethod = await _query.Run(organization);
Assert.False(hasPaymentMethod);
}
[Fact]
public async Task Run_SetupIntentNotBankAccount_ReturnsFalse()
{
var organization = new Organization
{
Id = Guid.NewGuid()
};
var customer = new Customer
{
InvoiceSettings = new CustomerInvoiceSettings(),
Metadata = new Dictionary<string, string>()
};
_subscriberService.GetCustomer(organization).Returns(customer);
_setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123");
_stripeAdapter
.SetupIntentGet("seti_123",
Arg.Is<SetupIntentGetOptions>(options => options.HasExpansions("payment_method")))
.Returns(new SetupIntent
{
PaymentMethod = new PaymentMethod
{
Type = "card"
},
Status = "succeeded"
});
var hasPaymentMethod = await _query.Run(organization);
Assert.False(hasPaymentMethod);
}
}

View File

@@ -1,6 +1,10 @@
using Bit.Core.Billing.Caches;
using Bit.Core.Billing;
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Platform.Push;
@@ -13,6 +17,8 @@ using NSubstitute;
using Stripe;
using Xunit;
using Address = Stripe.Address;
using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan;
using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable;
using StripeCustomer = Stripe.Customer;
using StripeSubscription = Stripe.Subscription;
@@ -27,6 +33,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
private readonly ISubscriberService _subscriberService = Substitute.For<ISubscriberService>();
private readonly IUserService _userService = Substitute.For<IUserService>();
private readonly IPushNotificationService _pushNotificationService = Substitute.For<IPushNotificationService>();
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
private readonly CreatePremiumCloudHostedSubscriptionCommand _command;
public CreatePremiumCloudHostedSubscriptionCommandTests()
@@ -35,6 +42,17 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
baseServiceUri.CloudRegion.Returns("US");
_globalSettings.BaseServiceUri.Returns(baseServiceUri);
// Setup default premium plan with standard pricing
var premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = StripeConstants.Prices.PremiumAnnually },
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal }
};
_pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan);
_command = new CreatePremiumCloudHostedSubscriptionCommand(
_braintreeGateway,
_globalSettings,
@@ -43,7 +61,8 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
_subscriberService,
_userService,
_pushNotificationService,
Substitute.For<ILogger<CreatePremiumCloudHostedSubscriptionCommand>>());
Substitute.For<ILogger<CreatePremiumCloudHostedSubscriptionCommand>>(),
_pricingClient);
}
[Theory, BitAutoData]
@@ -105,6 +124,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "active";
mockSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
var mockInvoice = Substitute.For<Invoice>();
@@ -152,6 +181,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "active";
mockSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
var mockInvoice = Substitute.For<Invoice>();
@@ -241,7 +280,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "active";
mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30);
mockSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
var mockInvoice = Substitute.For<Invoice>();
@@ -286,6 +334,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "active";
mockSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
var mockInvoice = Substitute.For<Invoice>();
@@ -326,7 +384,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "incomplete";
mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30);
mockSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
var mockInvoice = Substitute.For<Invoice>();
@@ -342,7 +409,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
// Assert
Assert.True(result.IsT0);
Assert.True(user.Premium);
Assert.Equal(mockSubscription.CurrentPeriodEnd, user.PremiumExpirationDate);
Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate);
}
[Theory, BitAutoData]
@@ -368,7 +435,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "active";
mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30);
mockSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
var mockInvoice = Substitute.For<Invoice>();
@@ -384,7 +460,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
// Assert
Assert.True(result.IsT0);
Assert.True(user.Premium);
Assert.Equal(mockSubscription.CurrentPeriodEnd, user.PremiumExpirationDate);
Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate);
}
[Theory, BitAutoData]
@@ -411,7 +487,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "active"; // PayPal + active doesn't match pattern
mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30);
mockSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
var mockInvoice = Substitute.For<Invoice>();
@@ -453,7 +538,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "incomplete";
mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30);
mockSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
var mockInvoice = Substitute.For<Invoice>();
@@ -474,4 +568,79 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
var unhandled = result.AsT3;
Assert.Equal("Something went wrong with your request. Please contact support for assistance.", unhandled.Response);
}
[Theory, BitAutoData]
public async Task Run_AccountCredit_WithExistingCustomer_Success(
User user,
NonTokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = false;
user.GatewayCustomerId = "existing_customer_123";
paymentMethod.Type = NonTokenizablePaymentMethodType.AccountCredit;
billingAddress.Country = "US";
billingAddress.PostalCode = "12345";
var mockCustomer = Substitute.For<StripeCustomer>();
mockCustomer.Id = "existing_customer_123";
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
mockCustomer.Metadata = new Dictionary<string, string>();
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "active";
mockSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
var mockInvoice = Substitute.For<Invoice>();
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
_stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(mockSubscription);
_stripeAdapter.InvoiceUpdateAsync(Arg.Any<string>(), Arg.Any<InvoiceUpdateOptions>()).Returns(mockInvoice);
// Act
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
// Assert
Assert.True(result.IsT0);
await _subscriberService.Received(1).GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>());
await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any<CustomerCreateOptions>());
Assert.True(user.Premium);
Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate);
}
[Theory, BitAutoData]
public async Task Run_NonTokenizedPaymentWithoutExistingCustomer_ThrowsBillingException(
User user,
NonTokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = false;
// No existing gateway customer ID
user.GatewayCustomerId = null;
paymentMethod.Type = NonTokenizablePaymentMethodType.AccountCredit;
billingAddress.Country = "US";
billingAddress.PostalCode = "12345";
// Act
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
//Assert
Assert.True(result.IsT3); // Assuming T3 is the Unhandled result
Assert.IsType<BillingException>(result.AsT3.Exception);
// Verify no customer was created or subscription attempted
await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any<CustomerCreateOptions>());
await _stripeAdapter.DidNotReceive().SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
await _userService.DidNotReceive().SaveUserAsync(Arg.Any<User>());
}
}

View File

@@ -1,23 +1,38 @@
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Billing.Pricing;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Stripe;
using Xunit;
using static Bit.Core.Billing.Constants.StripeConstants;
using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan;
using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable;
namespace Bit.Core.Test.Billing.Premium.Commands;
public class PreviewPremiumTaxCommandTests
{
private readonly ILogger<PreviewPremiumTaxCommand> _logger = Substitute.For<ILogger<PreviewPremiumTaxCommand>>();
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
private readonly PreviewPremiumTaxCommand _command;
public PreviewPremiumTaxCommandTests()
{
_command = new PreviewPremiumTaxCommand(_logger, _stripeAdapter);
// Setup default premium plan with standard pricing
var premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
LegacyYear = null,
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually },
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal }
};
_pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan);
_command = new PreviewPremiumTaxCommand(_logger, _pricingClient, _stripeAdapter);
}
[Fact]
@@ -31,7 +46,7 @@ public class PreviewPremiumTaxCommandTests
var invoice = new Invoice
{
Tax = 300,
TotalTaxes = [new InvoiceTotalTax { Amount = 300 }],
Total = 3300
};
@@ -65,7 +80,7 @@ public class PreviewPremiumTaxCommandTests
var invoice = new Invoice
{
Tax = 500,
TotalTaxes = [new InvoiceTotalTax { Amount = 500 }],
Total = 5500
};
@@ -101,7 +116,7 @@ public class PreviewPremiumTaxCommandTests
var invoice = new Invoice
{
Tax = 250,
TotalTaxes = [new InvoiceTotalTax { Amount = 250 }],
Total = 2750
};
@@ -135,7 +150,7 @@ public class PreviewPremiumTaxCommandTests
var invoice = new Invoice
{
Tax = 800,
TotalTaxes = [new InvoiceTotalTax { Amount = 800 }],
Total = 8800
};
@@ -171,7 +186,7 @@ public class PreviewPremiumTaxCommandTests
var invoice = new Invoice
{
Tax = 450,
TotalTaxes = [new InvoiceTotalTax { Amount = 450 }],
Total = 4950
};
@@ -207,7 +222,7 @@ public class PreviewPremiumTaxCommandTests
var invoice = new Invoice
{
Tax = 0,
TotalTaxes = [new InvoiceTotalTax { Amount = 0 }],
Total = 3000
};
@@ -241,7 +256,7 @@ public class PreviewPremiumTaxCommandTests
var invoice = new Invoice
{
Tax = 600,
TotalTaxes = [new InvoiceTotalTax { Amount = 600 }],
Total = 6600
};
@@ -276,7 +291,7 @@ public class PreviewPremiumTaxCommandTests
// Stripe amounts are in cents
var invoice = new Invoice
{
Tax = 123, // $1.23
TotalTaxes = [new InvoiceTotalTax { Amount = 123 }], // $1.23
Total = 3123 // $31.23
};

View File

@@ -1,10 +1,15 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Organizations.Services;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@@ -96,6 +101,10 @@ public class OrganizationBillingServiceTests
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType));
sutProvider.GetDependency<IOrganizationRepository>()
.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
.Returns(new OrganizationSeatCounts { Users = 1, Sponsored = 0 });
var subscriberService = sutProvider.GetDependency<ISubscriberService>();
// Set up subscriber service to return null for customer
@@ -110,13 +119,235 @@ public class OrganizationBillingServiceTests
Assert.NotNull(metadata);
Assert.False(metadata!.IsOnSecretsManagerStandalone);
Assert.False(metadata.HasSubscription);
Assert.False(metadata.IsSubscriptionUnpaid);
Assert.False(metadata.HasOpenInvoice);
Assert.False(metadata.IsSubscriptionCanceled);
Assert.Null(metadata.InvoiceDueDate);
Assert.Null(metadata.InvoiceCreatedDate);
Assert.Null(metadata.SubPeriodEndDate);
Assert.Equal(1, metadata.OrganizationOccupiedSeats);
}
#endregion
#region Finalize - Trial Settings
[Theory, BitAutoData]
public async Task NoPaymentMethodAndTrialPeriod_SetsMissingPaymentMethodCancelBehavior(
Organization organization,
SutProvider<OrganizationBillingService> sutProvider)
{
// Arrange
var plan = StaticStore.GetPlan(PlanType.TeamsAnnually);
organization.PlanType = PlanType.TeamsAnnually;
organization.GatewayCustomerId = "cus_test123";
organization.GatewaySubscriptionId = null;
var subscriptionSetup = new SubscriptionSetup
{
PlanType = PlanType.TeamsAnnually,
PasswordManagerOptions = new SubscriptionSetup.PasswordManager
{
Seats = 5,
Storage = null,
PremiumAccess = false
},
SecretsManagerOptions = null,
SkipTrial = false
};
var sale = new OrganizationSale
{
Organization = organization,
SubscriptionSetup = subscriptionSetup
};
sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(PlanType.TeamsAnnually)
.Returns(plan);
sutProvider.GetDependency<IHasPaymentMethodQuery>()
.Run(organization)
.Returns(false);
var customer = new Customer
{
Id = "cus_test123",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
};
sutProvider.GetDependency<ISubscriberService>()
.GetCustomerOrThrow(organization, Arg.Any<CustomerGetOptions>())
.Returns(customer);
SubscriptionCreateOptions capturedOptions = null;
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionCreateAsync(Arg.Do<SubscriptionCreateOptions>(options => capturedOptions = options))
.Returns(new Subscription
{
Id = "sub_test123",
Status = StripeConstants.SubscriptionStatus.Trialing
});
sutProvider.GetDependency<IOrganizationRepository>()
.ReplaceAsync(organization)
.Returns(Task.CompletedTask);
// Act
await sutProvider.Sut.Finalize(sale);
// Assert
await sutProvider.GetDependency<IStripeAdapter>()
.Received(1)
.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
Assert.NotNull(capturedOptions);
Assert.Equal(7, capturedOptions.TrialPeriodDays);
Assert.NotNull(capturedOptions.TrialSettings);
Assert.NotNull(capturedOptions.TrialSettings.EndBehavior);
Assert.Equal("cancel", capturedOptions.TrialSettings.EndBehavior.MissingPaymentMethod);
}
[Theory, BitAutoData]
public async Task NoPaymentMethodButNoTrial_DoesNotSetMissingPaymentMethodBehavior(
Organization organization,
SutProvider<OrganizationBillingService> sutProvider)
{
// Arrange
var plan = StaticStore.GetPlan(PlanType.TeamsAnnually);
organization.PlanType = PlanType.TeamsAnnually;
organization.GatewayCustomerId = "cus_test123";
organization.GatewaySubscriptionId = null;
var subscriptionSetup = new SubscriptionSetup
{
PlanType = PlanType.TeamsAnnually,
PasswordManagerOptions = new SubscriptionSetup.PasswordManager
{
Seats = 5,
Storage = null,
PremiumAccess = false
},
SecretsManagerOptions = null,
SkipTrial = true // This will result in TrialPeriodDays = 0
};
var sale = new OrganizationSale
{
Organization = organization,
SubscriptionSetup = subscriptionSetup
};
sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(PlanType.TeamsAnnually)
.Returns(plan);
sutProvider.GetDependency<IHasPaymentMethodQuery>()
.Run(organization)
.Returns(false);
var customer = new Customer
{
Id = "cus_test123",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
};
sutProvider.GetDependency<ISubscriberService>()
.GetCustomerOrThrow(organization, Arg.Any<CustomerGetOptions>())
.Returns(customer);
SubscriptionCreateOptions capturedOptions = null;
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionCreateAsync(Arg.Do<SubscriptionCreateOptions>(options => capturedOptions = options))
.Returns(new Subscription
{
Id = "sub_test123",
Status = StripeConstants.SubscriptionStatus.Active
});
sutProvider.GetDependency<IOrganizationRepository>()
.ReplaceAsync(organization)
.Returns(Task.CompletedTask);
// Act
await sutProvider.Sut.Finalize(sale);
// Assert
await sutProvider.GetDependency<IStripeAdapter>()
.Received(1)
.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
Assert.NotNull(capturedOptions);
Assert.Equal(0, capturedOptions.TrialPeriodDays);
Assert.Null(capturedOptions.TrialSettings);
}
[Theory, BitAutoData]
public async Task HasPaymentMethodAndTrialPeriod_DoesNotSetMissingPaymentMethodBehavior(
Organization organization,
SutProvider<OrganizationBillingService> sutProvider)
{
// Arrange
var plan = StaticStore.GetPlan(PlanType.TeamsAnnually);
organization.PlanType = PlanType.TeamsAnnually;
organization.GatewayCustomerId = "cus_test123";
organization.GatewaySubscriptionId = null;
var subscriptionSetup = new SubscriptionSetup
{
PlanType = PlanType.TeamsAnnually,
PasswordManagerOptions = new SubscriptionSetup.PasswordManager
{
Seats = 5,
Storage = null,
PremiumAccess = false
},
SecretsManagerOptions = null,
SkipTrial = false
};
var sale = new OrganizationSale
{
Organization = organization,
SubscriptionSetup = subscriptionSetup
};
sutProvider.GetDependency<IPricingClient>()
.GetPlanOrThrow(PlanType.TeamsAnnually)
.Returns(plan);
sutProvider.GetDependency<IHasPaymentMethodQuery>()
.Run(organization)
.Returns(true); // Has payment method
var customer = new Customer
{
Id = "cus_test123",
Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported }
};
sutProvider.GetDependency<ISubscriberService>()
.GetCustomerOrThrow(organization, Arg.Any<CustomerGetOptions>())
.Returns(customer);
SubscriptionCreateOptions capturedOptions = null;
sutProvider.GetDependency<IStripeAdapter>()
.SubscriptionCreateAsync(Arg.Do<SubscriptionCreateOptions>(options => capturedOptions = options))
.Returns(new Subscription
{
Id = "sub_test123",
Status = StripeConstants.SubscriptionStatus.Trialing
});
sutProvider.GetDependency<IOrganizationRepository>()
.ReplaceAsync(organization)
.Returns(Task.CompletedTask);
// Act
await sutProvider.Sut.Finalize(sale);
// Assert
await sutProvider.GetDependency<IStripeAdapter>()
.Received(1)
.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
Assert.NotNull(capturedOptions);
Assert.Equal(7, capturedOptions.TrialPeriodDays);
Assert.Null(capturedOptions.TrialSettings);
}
#endregion

View File

@@ -88,7 +88,13 @@ public class RestartSubscriptionCommandTests
var newSubscription = new Subscription
{
Id = "sub_new",
CurrentPeriodEnd = currentPeriodEnd
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
]
}
};
_subscriberService.GetSubscription(organization).Returns(existingSubscription);
@@ -138,7 +144,13 @@ public class RestartSubscriptionCommandTests
var newSubscription = new Subscription
{
Id = "sub_new",
CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1)
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) }
]
}
};
_subscriberService.GetSubscription(provider).Returns(existingSubscription);
@@ -177,7 +189,13 @@ public class RestartSubscriptionCommandTests
var newSubscription = new Subscription
{
Id = "sub_new",
CurrentPeriodEnd = currentPeriodEnd
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd }
]
}
};
_subscriberService.GetSubscription(user).Returns(existingSubscription);

View File

@@ -1,9 +1,11 @@
#nullable enable
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Kdf.Implementations;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
@@ -21,16 +23,12 @@ public class ChangeKdfCommandTests
[BitAutoData]
public async Task ChangeKdfAsync_ChangesKdfAsync(SutProvider<ChangeKdfCommand> sutProvider, User user)
{
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(IdentityResult.Success));
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
.Returns(Task.FromResult(true));
sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>())
.Returns(Task.FromResult(IdentityResult.Success));
var kdf = new KdfSettings
{
KdfType = Enums.KdfType.Argon2id,
Iterations = 4,
Memory = 512,
Parallelism = 4
};
var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 };
var authenticationData = new MasterPasswordAuthenticationData
{
Kdf = kdf,
@@ -59,13 +57,7 @@ public class ChangeKdfCommandTests
[BitAutoData]
public async Task ChangeKdfAsync_UserIsNull_ThrowsArgumentNullException(SutProvider<ChangeKdfCommand> sutProvider)
{
var kdf = new KdfSettings
{
KdfType = Enums.KdfType.Argon2id,
Iterations = 4,
Memory = 512,
Parallelism = 4
};
var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 };
var authenticationData = new MasterPasswordAuthenticationData
{
Kdf = kdf,
@@ -85,17 +77,13 @@ public class ChangeKdfCommandTests
[Theory]
[BitAutoData]
public async Task ChangeKdfAsync_WrongPassword_ReturnsPasswordMismatch(SutProvider<ChangeKdfCommand> sutProvider, User user)
public async Task ChangeKdfAsync_WrongPassword_ReturnsPasswordMismatch(SutProvider<ChangeKdfCommand> sutProvider,
User user)
{
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(false));
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
.Returns(Task.FromResult(false));
var kdf = new KdfSettings
{
KdfType = Enums.KdfType.Argon2id,
Iterations = 4,
Memory = 512,
Parallelism = 4
};
var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 };
var authenticationData = new MasterPasswordAuthenticationData
{
Kdf = kdf,
@@ -116,7 +104,9 @@ public class ChangeKdfCommandTests
[Theory]
[BitAutoData]
public async Task ChangeKdfAsync_WithAuthenticationAndUnlockData_UpdatesUserCorrectly(SutProvider<ChangeKdfCommand> sutProvider, User user)
public async Task
ChangeKdfAsync_WithAuthenticationAndUnlockDataAndNoLogoutOnKdfChangeFeatureFlagOff_UpdatesUserCorrectlyAndLogsOut(
SutProvider<ChangeKdfCommand> sutProvider, User user)
{
var constantKdf = new KdfSettings
{
@@ -137,8 +127,12 @@ public class ChangeKdfCommandTests
MasterKeyWrappedUserKey = "new-wrapped-key",
Salt = user.GetMasterPasswordSalt()
};
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(IdentityResult.Success));
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
.Returns(Task.FromResult(true));
sutProvider.GetDependency<IUserService>()
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<bool>())
.Returns(Task.FromResult(IdentityResult.Success));
sutProvider.GetDependency<IFeatureService>().IsEnabled(Arg.Any<string>()).Returns(false);
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData);
@@ -150,17 +144,79 @@ public class ChangeKdfCommandTests
&& u.KdfParallelism == constantKdf.Parallelism
&& u.Key == "new-wrapped-key"
));
await sutProvider.GetDependency<IUserService>().Received(1).UpdatePasswordHash(user,
authenticationData.MasterPasswordAuthenticationHash, validatePassword: true, refreshStamp: true);
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushLogOutAsync(user.Id);
sutProvider.GetDependency<IFeatureService>().Received(1).IsEnabled(FeatureFlagKeys.NoLogoutOnKdfChange);
}
[Theory]
[BitAutoData]
public async Task ChangeKdfAsync_KdfNotEqualBetweenAuthAndUnlock_ThrowsBadRequestException(SutProvider<ChangeKdfCommand> sutProvider, User user)
public async Task
ChangeKdfAsync_WithAuthenticationAndUnlockDataAndNoLogoutOnKdfChangeFeatureFlagOn_UpdatesUserCorrectlyAndDoesNotLogOut(
SutProvider<ChangeKdfCommand> sutProvider, User user)
{
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
var constantKdf = new KdfSettings
{
KdfType = Enums.KdfType.Argon2id,
Iterations = 5,
Memory = 1024,
Parallelism = 4
};
var authenticationData = new MasterPasswordAuthenticationData
{
Kdf = constantKdf,
MasterPasswordAuthenticationHash = "new-auth-hash",
Salt = user.GetMasterPasswordSalt()
};
var unlockData = new MasterPasswordUnlockData
{
Kdf = constantKdf,
MasterKeyWrappedUserKey = "new-wrapped-key",
Salt = user.GetMasterPasswordSalt()
};
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
.Returns(Task.FromResult(true));
sutProvider.GetDependency<IUserService>()
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<bool>())
.Returns(Task.FromResult(IdentityResult.Success));
sutProvider.GetDependency<IFeatureService>().IsEnabled(Arg.Any<string>()).Returns(true);
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData);
await sutProvider.GetDependency<IUserRepository>().Received(1).ReplaceAsync(Arg.Is<User>(u =>
u.Id == user.Id
&& u.Kdf == constantKdf.KdfType
&& u.KdfIterations == constantKdf.Iterations
&& u.KdfMemory == constantKdf.Memory
&& u.KdfParallelism == constantKdf.Parallelism
&& u.Key == "new-wrapped-key"
));
await sutProvider.GetDependency<IUserService>().Received(1).UpdatePasswordHash(user,
authenticationData.MasterPasswordAuthenticationHash, validatePassword: true, refreshStamp: false);
await sutProvider.GetDependency<IPushNotificationService>().Received(1)
.PushLogOutAsync(user.Id, false, PushNotificationLogOutReason.KdfChange);
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncSettingsAsync(user.Id);
sutProvider.GetDependency<IFeatureService>().Received(1).IsEnabled(FeatureFlagKeys.NoLogoutOnKdfChange);
}
[Theory]
[BitAutoData]
public async Task ChangeKdfAsync_KdfNotEqualBetweenAuthAndUnlock_ThrowsBadRequestException(
SutProvider<ChangeKdfCommand> sutProvider, User user)
{
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
.Returns(Task.FromResult(true));
var authenticationData = new MasterPasswordAuthenticationData
{
Kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 },
Kdf = new KdfSettings
{
KdfType = Enums.KdfType.Argon2id,
Iterations = 4,
Memory = 512,
Parallelism = 4
},
MasterPasswordAuthenticationHash = "new-auth-hash",
Salt = user.GetMasterPasswordSalt()
};
@@ -176,9 +232,11 @@ public class ChangeKdfCommandTests
[Theory]
[BitAutoData]
public async Task ChangeKdfAsync_AuthDataSaltMismatch_Throws(SutProvider<ChangeKdfCommand> sutProvider, User user, KdfSettings kdf)
public async Task ChangeKdfAsync_AuthDataSaltMismatch_Throws(SutProvider<ChangeKdfCommand> sutProvider, User user,
KdfSettings kdf)
{
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
.Returns(Task.FromResult(true));
var authenticationData = new MasterPasswordAuthenticationData
{
@@ -192,15 +250,17 @@ public class ChangeKdfCommandTests
MasterKeyWrappedUserKey = "new-wrapped-key",
Salt = user.GetMasterPasswordSalt()
};
await Assert.ThrowsAsync<ArgumentException>(async () =>
await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData));
}
[Theory]
[BitAutoData]
public async Task ChangeKdfAsync_UnlockDataSaltMismatch_Throws(SutProvider<ChangeKdfCommand> sutProvider, User user, KdfSettings kdf)
public async Task ChangeKdfAsync_UnlockDataSaltMismatch_Throws(SutProvider<ChangeKdfCommand> sutProvider, User user,
KdfSettings kdf)
{
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
.Returns(Task.FromResult(true));
var authenticationData = new MasterPasswordAuthenticationData
{
@@ -214,25 +274,22 @@ public class ChangeKdfCommandTests
MasterKeyWrappedUserKey = "new-wrapped-key",
Salt = "different-salt"
};
await Assert.ThrowsAsync<ArgumentException>(async () =>
await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData));
}
[Theory]
[BitAutoData]
public async Task ChangeKdfAsync_UpdatePasswordHashFails_ReturnsFailure(SutProvider<ChangeKdfCommand> sutProvider, User user)
public async Task ChangeKdfAsync_UpdatePasswordHashFails_ReturnsFailure(SutProvider<ChangeKdfCommand> sutProvider,
User user)
{
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
.Returns(Task.FromResult(true));
var failedResult = IdentityResult.Failed(new IdentityError { Code = "TestFail", Description = "Test fail" });
sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(failedResult));
sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>())
.Returns(Task.FromResult(failedResult));
var kdf = new KdfSettings
{
KdfType = Enums.KdfType.Argon2id,
Iterations = 4,
Memory = 512,
Parallelism = 4
};
var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 };
var authenticationData = new MasterPasswordAuthenticationData
{
Kdf = kdf,
@@ -253,9 +310,11 @@ public class ChangeKdfCommandTests
[Theory]
[BitAutoData]
public async Task ChangeKdfAsync_InvalidKdfSettings_ThrowsBadRequestException(SutProvider<ChangeKdfCommand> sutProvider, User user)
public async Task ChangeKdfAsync_InvalidKdfSettings_ThrowsBadRequestException(
SutProvider<ChangeKdfCommand> sutProvider, User user)
{
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
.Returns(Task.FromResult(true));
// Create invalid KDF settings (iterations too low for PBKDF2)
var invalidKdf = new KdfSettings
@@ -287,9 +346,11 @@ public class ChangeKdfCommandTests
[Theory]
[BitAutoData]
public async Task ChangeKdfAsync_InvalidArgon2Settings_ThrowsBadRequestException(SutProvider<ChangeKdfCommand> sutProvider, User user)
public async Task ChangeKdfAsync_InvalidArgon2Settings_ThrowsBadRequestException(
SutProvider<ChangeKdfCommand> sutProvider, User user)
{
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
.Returns(Task.FromResult(true));
// Create invalid Argon2 KDF settings (memory too high)
var invalidKdf = new KdfSettings
@@ -318,5 +379,4 @@ public class ChangeKdfCommandTests
Assert.Equal("KDF settings are invalid.", exception.Message);
}
}

View File

@@ -0,0 +1,43 @@
using Bit.Core.Entities;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.Queries;
using Bit.Core.KeyManagement.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.KeyManagement.Queries;
[SutProviderCustomize]
public class UserAccountKeysQueryTests
{
[Theory, BitAutoData]
public async Task V1User_Success(SutProvider<UserAccountKeysQuery> sutProvider, User user)
{
var result = await sutProvider.Sut.Run(user);
Assert.Equal(user.GetPublicKeyEncryptionKeyPair().PublicKey, result.PublicKeyEncryptionKeyPairData.PublicKey);
Assert.Equal(user.GetPublicKeyEncryptionKeyPair().WrappedPrivateKey, result.PublicKeyEncryptionKeyPairData.WrappedPrivateKey);
}
[Theory, BitAutoData]
public async Task V2User_Success(SutProvider<UserAccountKeysQuery> sutProvider, User user)
{
user.SecurityState = "v2";
user.SecurityVersion = 2;
var signatureKeyPairRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
signatureKeyPairRepository.GetByUserIdAsync(user.Id).Returns(new SignatureKeyPairData(Core.KeyManagement.Enums.SignatureAlgorithm.Ed25519, "wrappedSigningKey", "verifyingKey"));
var result = await sutProvider.Sut.Run(user);
Assert.Equal(user.GetPublicKeyEncryptionKeyPair().PublicKey, result.PublicKeyEncryptionKeyPairData.PublicKey);
Assert.Equal(user.GetPublicKeyEncryptionKeyPair().WrappedPrivateKey, result.PublicKeyEncryptionKeyPairData.WrappedPrivateKey);
Assert.Equal(user.GetPublicKeyEncryptionKeyPair().SignedPublicKey, result.PublicKeyEncryptionKeyPairData.SignedPublicKey);
Assert.NotNull(result.SignatureKeyPairData);
Assert.Equal("wrappedSigningKey", result.SignatureKeyPairData.WrappedSigningKey);
Assert.Equal("verifyingKey", result.SignatureKeyPairData.VerifyingKey);
Assert.Equal(user.SecurityState, result.SecurityStateData.SecurityState);
Assert.Equal(user.GetSecurityVersion(), result.SecurityStateData.SecurityVersion);
}
}

View File

@@ -1,11 +1,18 @@
using Bit.Core.Entities;
using Bit.Core.KeyManagement.Enums;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.Repositories;
using Bit.Core.KeyManagement.UserKey.Implementations;
using Bit.Core.Services;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Repositories;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Identity;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Xunit;
namespace Bit.Core.Test.KeyManagement.UserKey;
@@ -14,7 +21,7 @@ namespace Bit.Core.Test.KeyManagement.UserKey;
public class RotateUserAccountKeysCommandTests
{
[Theory, BitAutoData]
public async Task RejectsWrongOldMasterPassword(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
public async Task RotateUserAccountKeysAsync_WrongOldMasterPassword_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
RotateUserAccountKeysData model)
{
user.Email = model.MasterPasswordUnlockData.Email;
@@ -25,41 +32,38 @@ public class RotateUserAccountKeysCommandTests
Assert.NotEqual(IdentityResult.Success, result);
}
[Theory, BitAutoData]
public async Task ThrowsWhenUserIsNull(SutProvider<RotateUserAccountKeysCommand> sutProvider,
public async Task RotateUserAccountKeysAsync_UserIsNull_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider,
RotateUserAccountKeysData model)
{
await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.RotateUserAccountKeysAsync(null, model));
}
[Theory, BitAutoData]
public async Task RejectsEmailChange(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
public async Task RotateUserAccountKeysAsync_EmailChange_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
RotateUserAccountKeysData model)
{
user.Kdf = Enums.KdfType.Argon2id;
user.KdfIterations = 3;
user.KdfMemory = 64;
user.KdfParallelism = 4;
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV1ExistingUser(user, signatureRepository);
SetV1ModelUser(model);
model.MasterPasswordUnlockData.Email = user.Email + ".different-domain";
model.MasterPasswordUnlockData.KdfType = Enums.KdfType.Argon2id;
model.MasterPasswordUnlockData.KdfIterations = 3;
model.MasterPasswordUnlockData.KdfMemory = 64;
model.MasterPasswordUnlockData.KdfParallelism = 4;
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)
.Returns(true);
await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.RotateUserAccountKeysAsync(user, model));
}
[Theory, BitAutoData]
public async Task RejectsKdfChange(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
public async Task RotateUserAccountKeysAsync_KdfChange_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
RotateUserAccountKeysData model)
{
user.Kdf = Enums.KdfType.Argon2id;
user.KdfIterations = 3;
user.KdfMemory = 64;
user.KdfParallelism = 4;
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV1ExistingUser(user, signatureRepository);
SetV1ModelUser(model);
model.MasterPasswordUnlockData.Email = user.Email;
model.MasterPasswordUnlockData.KdfType = Enums.KdfType.PBKDF2_SHA256;
model.MasterPasswordUnlockData.KdfIterations = 600000;
model.MasterPasswordUnlockData.KdfMemory = null;
@@ -71,22 +75,15 @@ public class RotateUserAccountKeysCommandTests
[Theory, BitAutoData]
public async Task RejectsPublicKeyChange(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
public async Task RotateUserAccountKeysAsync_PublicKeyChange_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
RotateUserAccountKeysData model)
{
user.PublicKey = "old-public";
user.Kdf = Enums.KdfType.Argon2id;
user.KdfIterations = 3;
user.KdfMemory = 64;
user.KdfParallelism = 4;
model.AccountPublicKey = "new-public";
model.MasterPasswordUnlockData.Email = user.Email;
model.MasterPasswordUnlockData.KdfType = Enums.KdfType.Argon2id;
model.MasterPasswordUnlockData.KdfIterations = 3;
model.MasterPasswordUnlockData.KdfMemory = 64;
model.MasterPasswordUnlockData.KdfParallelism = 4;
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV1ExistingUser(user, signatureRepository);
SetV1ModelUser(model);
model.AccountKeys.PublicKeyEncryptionKeyPairData.PublicKey = "new-public";
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)
.Returns(true);
@@ -94,27 +91,350 @@ public class RotateUserAccountKeysCommandTests
}
[Theory, BitAutoData]
public async Task RotatesCorrectly(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
public async Task RotateUserAccountKeysAsync_V1_Success(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
RotateUserAccountKeysData model)
{
user.Kdf = Enums.KdfType.Argon2id;
user.KdfIterations = 3;
user.KdfMemory = 64;
user.KdfParallelism = 4;
model.MasterPasswordUnlockData.Email = user.Email;
model.MasterPasswordUnlockData.KdfType = Enums.KdfType.Argon2id;
model.MasterPasswordUnlockData.KdfIterations = 3;
model.MasterPasswordUnlockData.KdfMemory = 64;
model.MasterPasswordUnlockData.KdfParallelism = 4;
model.AccountPublicKey = user.PublicKey;
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV1ExistingUser(user, signatureRepository);
SetV1ModelUser(model);
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)
.Returns(true);
var result = await sutProvider.Sut.RotateUserAccountKeysAsync(user, model);
Assert.Equal(IdentityResult.Success, result);
}
[Theory, BitAutoData]
public async Task RotateUserAccountKeysAsync_UpgradeV1ToV2_Success(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
RotateUserAccountKeysData model)
{
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV1ExistingUser(user, signatureRepository);
SetV2ModelUser(model);
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)
.Returns(true);
var result = await sutProvider.Sut.RotateUserAccountKeysAsync(user, model);
Assert.Equal(IdentityResult.Success, result);
Assert.Equal(user.SecurityState, model.AccountKeys.SecurityStateData!.SecurityState);
}
[Theory, BitAutoData]
public async Task UpdateAccountKeysAsync_PublicKeyChange_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
{
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV1ExistingUser(user, signatureRepository);
SetV1ModelUser(model);
model.AccountKeys.PublicKeyEncryptionKeyPairData.PublicKey = "new-public";
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
}
[Theory, BitAutoData]
public async Task UpdateAccountKeysAsync_V2User_PrivateKeyNotXChaCha20_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
{
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV2ExistingUser(user, signatureRepository);
SetV2ModelUser(model);
model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey = "2.xxx";
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
}
[Theory, BitAutoData]
public async Task UpdateAccountKeysAsync_V1User_PrivateKeyNotAesCbcHmac_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
{
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV1ExistingUser(user, signatureRepository);
SetV1ModelUser(model);
model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey = "7.xxx";
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
Assert.Equal("The provided account private key was not wrapped with AES-256-CBC-HMAC", ex.Message);
}
[Theory, BitAutoData]
public async Task UpdateAccountKeysAsync_V1_Success(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
{
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV1ExistingUser(user, signatureRepository);
SetV1ModelUser(model);
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions);
Assert.Empty(saveEncryptedDataActions);
}
[Theory, BitAutoData]
public async Task UpdateAccountKeysAsync_V2_Success(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
{
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV2ExistingUser(user, signatureRepository);
SetV2ModelUser(model);
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions);
Assert.NotEmpty(saveEncryptedDataActions);
Assert.Equal(user.SecurityState, model.AccountKeys.SecurityStateData!.SecurityState);
}
[Theory, BitAutoData]
public async Task UpdateAccountKeysAsync_V2User_VerifyingKeyMismatch_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
{
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV2ExistingUser(user, signatureRepository);
SetV2ModelUser(model);
model.AccountKeys.SignatureKeyPairData.VerifyingKey = "different-verifying-key";
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
Assert.Equal("The provided verifying key does not match the user's current verifying key.", ex.Message);
}
[Theory, BitAutoData]
public async Task UpdateAccountKeysAsync_V2User_SignedPublicKeyNullOrEmpty_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
{
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV2ExistingUser(user, signatureRepository);
SetV2ModelUser(model);
model.AccountKeys.PublicKeyEncryptionKeyPairData.SignedPublicKey = null;
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
Assert.Equal("No signed public key provided, but the user already has a signature key pair.", ex.Message);
}
[Theory, BitAutoData]
public async Task UpdateAccountKeysAsync_V2User_WrappedSigningKeyNotXChaCha20_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
{
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV2ExistingUser(user, signatureRepository);
SetV2ModelUser(model);
model.AccountKeys.SignatureKeyPairData.WrappedSigningKey = "2.xxx";
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
Assert.Equal("The provided signing key data is not wrapped with XChaCha20-Poly1305.", ex.Message);
}
[Theory, BitAutoData]
public async Task UpdateAccountKeys_UpgradeToV2_InvalidVerifyingKey_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
{
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV1ExistingUser(user, signatureRepository);
SetV2ModelUser(model);
model.AccountKeys.SignatureKeyPairData.VerifyingKey = "";
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
Assert.Equal("The provided signature key pair data does not contain a valid verifying key.", ex.Message);
}
[Theory, BitAutoData]
public async Task UpdateAccountKeysAsync_UpgradeToV2_IncorrectlyWrappedPrivateKey_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
{
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV1ExistingUser(user, signatureRepository);
SetV2ModelUser(model);
model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey = "2.abc";
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
Assert.Equal("The provided private key encryption key is not wrapped with XChaCha20-Poly1305.", ex.Message);
}
[Theory, BitAutoData]
public async Task UpdateAccountKeysAsync_UpgradeToV2_NoSignedPublicKey_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
{
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV1ExistingUser(user, signatureRepository);
SetV2ModelUser(model);
model.AccountKeys.PublicKeyEncryptionKeyPairData.SignedPublicKey = null;
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
Assert.Equal("No signed public key provided, but the user already has a signature key pair.", ex.Message);
}
[Theory, BitAutoData]
public async Task UpdateAccountKeysAsync_UpgradeToV2_NoSecurityState_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
{
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV1ExistingUser(user, signatureRepository);
SetV2ModelUser(model);
model.AccountKeys.SecurityStateData = null;
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
Assert.Equal("No signed security state provider for V2 user", ex.Message);
}
[Theory, BitAutoData]
public async Task UpdateAccountKeysAsync_RotateV2_NoSignatureKeyPair_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
{
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV2ExistingUser(user, signatureRepository);
SetV2ModelUser(model);
model.AccountKeys.SignatureKeyPairData = null;
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
Assert.Equal("Signature key pair data is required for V2 encryption.", ex.Message);
}
[Theory, BitAutoData]
public async Task UpdateAccountKeysAsync_GetEncryptionType_EmptyString_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
{
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV1ExistingUser(user, signatureRepository);
SetV1ModelUser(model);
model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey = "";
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
var ex = await Assert.ThrowsAsync<ArgumentException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
Assert.Equal("Invalid encryption type string.", ex.Message);
}
[Theory, BitAutoData]
public async Task UpdateAccountKeysAsync_GetEncryptionType_InvalidString_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
{
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV1ExistingUser(user, signatureRepository);
SetV1ModelUser(model);
model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey = "9.xxx";
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
var ex = await Assert.ThrowsAsync<ArgumentException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
Assert.Equal("Invalid encryption type string.", ex.Message);
}
[Theory, BitAutoData]
public async Task UpdateUserData_RevisionDateChanged_Success(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
{
var oldDate = new DateTime(2017, 1, 1);
var cipher = Substitute.For<Cipher>();
cipher.RevisionDate = oldDate;
model.Ciphers = [cipher];
var folder = Substitute.For<Folder>();
folder.RevisionDate = oldDate;
model.Folders = [folder];
var send = Substitute.For<Send>();
send.RevisionDate = oldDate;
model.Sends = [send];
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
sutProvider.Sut.UpdateUserData(model, user, saveEncryptedDataActions);
foreach (var dataAction in saveEncryptedDataActions)
{
await dataAction.Invoke();
}
var updatedCiphers = sutProvider.GetDependency<ICipherRepository>()
.ReceivedCalls()
.FirstOrDefault(call => call.GetMethodInfo().Name == "UpdateForKeyRotation")?
.GetArguments()[1] as IEnumerable<Cipher>;
foreach (var updatedCipher in updatedCiphers!)
{
var oldCipher = model.Ciphers.FirstOrDefault(c => c.Id == updatedCipher.Id);
Assert.NotEqual(oldDate, updatedCipher.RevisionDate);
}
var updatedFolders = sutProvider.GetDependency<IFolderRepository>()
.ReceivedCalls()
.FirstOrDefault(call => call.GetMethodInfo().Name == "UpdateForKeyRotation")?
.GetArguments()[1] as IEnumerable<Folder>;
foreach (var updatedFolder in updatedFolders!)
{
var oldFolder = model.Folders.FirstOrDefault(f => f.Id == updatedFolder.Id);
Assert.NotEqual(oldDate, updatedFolder.RevisionDate);
}
var updatedSends = sutProvider.GetDependency<ISendRepository>()
.ReceivedCalls()
.FirstOrDefault(call => call.GetMethodInfo().Name == "UpdateForKeyRotation")?
.GetArguments()[1] as IEnumerable<Send>;
foreach (var updatedSend in updatedSends!)
{
var oldSend = model.Sends.FirstOrDefault(s => s.Id == updatedSend.Id);
Assert.NotEqual(oldDate, updatedSend.RevisionDate);
}
}
// Helper functions to set valid test parameters that match each other to the model and user.
private static void SetTestKdfAndSaltForUserAndModel(User user, RotateUserAccountKeysData model)
{
user.Kdf = Enums.KdfType.Argon2id;
user.KdfIterations = 3;
user.KdfMemory = 64;
user.KdfParallelism = 4;
model.MasterPasswordUnlockData.KdfType = Enums.KdfType.Argon2id;
model.MasterPasswordUnlockData.KdfIterations = 3;
model.MasterPasswordUnlockData.KdfMemory = 64;
model.MasterPasswordUnlockData.KdfParallelism = 4;
// The email is the salt for the KDF and is validated currently.
user.Email = model.MasterPasswordUnlockData.Email;
}
private static void SetV1ExistingUser(User user, IUserSignatureKeyPairRepository userSignatureKeyPairRepository)
{
user.PrivateKey = "2.abc";
user.PublicKey = "public";
user.SignedPublicKey = null;
userSignatureKeyPairRepository.GetByUserIdAsync(user.Id).ReturnsNull();
}
private static void SetV2ExistingUser(User user, IUserSignatureKeyPairRepository userSignatureKeyPairRepository)
{
user.PrivateKey = "7.abc";
user.PublicKey = "public";
user.SignedPublicKey = "signed-public";
userSignatureKeyPairRepository.GetByUserIdAsync(user.Id).Returns(new SignatureKeyPairData(SignatureAlgorithm.Ed25519, "7.abc", "verifying-key"));
}
private static void SetV1ModelUser(RotateUserAccountKeysData model)
{
model.AccountKeys.PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData("2.abc", "public", null);
model.AccountKeys.SignatureKeyPairData = null;
model.AccountKeys.SecurityStateData = null;
}
private static void SetV2ModelUser(RotateUserAccountKeysData model)
{
model.AccountKeys.PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData("7.abc", "public", "signed-public");
model.AccountKeys.SignatureKeyPairData = new SignatureKeyPairData(SignatureAlgorithm.Ed25519, "7.abc", "verifying-key");
model.AccountKeys.SecurityStateData = new SecurityStateData
{
SecurityState = "abc",
SecurityVersion = 2,
};
}
}

View File

@@ -278,21 +278,27 @@ public class UpdateSecretsManagerSubscriptionCommandTests
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
// Arrange
const int seatCount = 10;
var existingSeatCount = 9;
// Make sure Password Manager seats is greater or equal to Secrets Manager seats
organization.Seats = seatCount;
const int initialSeatCount = 9;
const int maxSeatCount = 20;
// This represents the total number of users allowed in the organization.
organization.Seats = maxSeatCount;
// This represents the number of Secrets Manager users allowed in the organization.
organization.SmSeats = initialSeatCount;
// This represents the upper limit of Secrets Manager seats that can be automatically scaled.
organization.MaxAutoscaleSmSeats = maxSeatCount;
organization.PlanType = PlanType.EnterpriseAnnually;
var plan = StaticStore.GetPlan(organization.PlanType);
var update = new SecretsManagerSubscriptionUpdate(organization, plan, false)
{
SmSeats = seatCount,
MaxAutoscaleSmSeats = seatCount
SmSeats = 8,
MaxAutoscaleSmSeats = maxSeatCount
};
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
.Returns(existingSeatCount);
.Returns(5);
// Act
await sutProvider.Sut.UpdateSubscriptionAsync(update);
@@ -316,21 +322,29 @@ public class UpdateSecretsManagerSubscriptionCommandTests
SutProvider<UpdateSecretsManagerSubscriptionCommand> sutProvider)
{
// Arrange
const int seatCount = 10;
const int existingSeatCount = 10;
var ownerDetailsList = new List<OrganizationUserUserDetails> { new() { Email = "owner@example.com" } };
const int initialSeatCount = 5;
const int maxSeatCount = 10;
// The amount of seats for users in an organization
// This represents the total number of users allowed in the organization.
organization.Seats = maxSeatCount;
// This represents the number of Secrets Manager users allowed in the organization.
organization.SmSeats = initialSeatCount;
// This represents the upper limit of Secrets Manager seats that can be automatically scaled.
organization.MaxAutoscaleSmSeats = maxSeatCount;
var ownerDetailsList = new List<OrganizationUserUserDetails> { new() { Email = "owner@example.com" } };
organization.PlanType = PlanType.EnterpriseAnnually;
var plan = StaticStore.GetPlan(organization.PlanType);
var update = new SecretsManagerSubscriptionUpdate(organization, plan, false)
{
SmSeats = seatCount,
MaxAutoscaleSmSeats = seatCount
SmSeats = maxSeatCount,
MaxAutoscaleSmSeats = maxSeatCount
};
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
.Returns(existingSeatCount);
.Returns(maxSeatCount);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByMinimumRoleAsync(organization.Id, OrganizationUserType.Owner)
.Returns(ownerDetailsList);
@@ -340,15 +354,14 @@ public class UpdateSecretsManagerSubscriptionCommandTests
// Assert
// Currently being called once each for different validation methods
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(2)
.Received(1)
.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id);
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendSecretsManagerMaxSeatLimitReachedEmailAsync(Arg.Is(organization),
Arg.Is(seatCount),
Arg.Is(maxSeatCount),
Arg.Is<IEnumerable<string>>(emails => emails.Contains(ownerDetailsList[0].Email)));
}

View File

@@ -358,20 +358,28 @@ public class AzureQueuePushEngineTests
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task PushLogOutAsync_SendsExpectedResponse(bool excludeCurrentContext)
[InlineData(true, null)]
[InlineData(true, PushNotificationLogOutReason.KdfChange)]
[InlineData(false, null)]
[InlineData(false, PushNotificationLogOutReason.KdfChange)]
public async Task PushLogOutAsync_SendsExpectedResponse(bool excludeCurrentContext,
PushNotificationLogOutReason? reason)
{
var userId = Guid.NewGuid();
var payload = new JsonObject
{
["UserId"] = userId
};
if (reason != null)
{
payload["Reason"] = (int)reason;
}
var expectedPayload = new JsonObject
{
["Type"] = 11,
["Payload"] = new JsonObject
{
["UserId"] = userId,
["Date"] = _fakeTimeProvider.GetUtcNow().UtcDateTime,
},
["Payload"] = payload,
};
if (excludeCurrentContext)
@@ -380,7 +388,7 @@ public class AzureQueuePushEngineTests
}
await VerifyNotificationAsync(
async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext),
async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext, reason),
expectedPayload
);
}

View File

@@ -1,6 +1,7 @@
using System.Text.Json.Nodes;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Enums;
using Bit.Core.NotificationCenter.Entities;
using Bit.Core.Platform.Push.Internal;
using Bit.Core.Tools.Entities;
@@ -193,7 +194,8 @@ public class NotificationsApiPushEngineTests : PushTestBase
};
}
protected override JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext)
protected override JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext,
PushNotificationLogOutReason? reason)
{
JsonNode? contextId = excludeCurrentContext ? DeviceIdentifier : null;
@@ -203,7 +205,7 @@ public class NotificationsApiPushEngineTests : PushTestBase
["Payload"] = new JsonObject
{
["UserId"] = userId,
["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime,
["Reason"] = reason != null ? (int)reason : null
},
["ContextId"] = contextId,
};

View File

@@ -86,7 +86,8 @@ public abstract class PushTestBase
protected abstract JsonNode GetPushSyncOrganizationsPayload(Guid userId);
protected abstract JsonNode GetPushSyncOrgKeysPayload(Guid userId);
protected abstract JsonNode GetPushSyncSettingsPayload(Guid userId);
protected abstract JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext);
protected abstract JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext,
PushNotificationLogOutReason? reason);
protected abstract JsonNode GetPushSendCreatePayload(Send send);
protected abstract JsonNode GetPushSendUpdatePayload(Send send);
protected abstract JsonNode GetPushSendDeletePayload(Send send);
@@ -263,15 +264,18 @@ public abstract class PushTestBase
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task PushLogOutAsync_SendsExpectedResponse(bool excludeCurrentContext)
[InlineData(true, null)]
[InlineData(true, PushNotificationLogOutReason.KdfChange)]
[InlineData(false, null)]
[InlineData(false, PushNotificationLogOutReason.KdfChange)]
public async Task PushLogOutAsync_SendsExpectedResponse(bool excludeCurrentContext,
PushNotificationLogOutReason? reason)
{
var userId = Guid.NewGuid();
await VerifyNotificationAsync(
async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext),
GetPushLogOutPayload(userId, excludeCurrentContext)
async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext, reason),
GetPushLogOutPayload(userId, excludeCurrentContext, reason)
);
}

View File

@@ -4,6 +4,7 @@ using System.Text.Json.Nodes;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.NotificationCenter.Entities;
using Bit.Core.Platform.Push.Internal;
using Bit.Core.Repositories;
@@ -64,7 +65,7 @@ public class RelayPushNotificationServiceTests : PushTestBase
["UserId"] = cipher.UserId,
["OrganizationId"] = null,
// Currently CollectionIds are not passed along from the method signature
// to the request body.
// to the request body.
["CollectionIds"] = null,
["RevisionDate"] = cipher.RevisionDate,
},
@@ -88,7 +89,7 @@ public class RelayPushNotificationServiceTests : PushTestBase
["UserId"] = cipher.UserId,
["OrganizationId"] = null,
// Currently CollectionIds are not passed along from the method signature
// to the request body.
// to the request body.
["CollectionIds"] = null,
["RevisionDate"] = cipher.RevisionDate,
},
@@ -274,7 +275,8 @@ public class RelayPushNotificationServiceTests : PushTestBase
};
}
protected override JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext)
protected override JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext,
PushNotificationLogOutReason? reason)
{
JsonNode? identifier = excludeCurrentContext ? DeviceIdentifier : null;
@@ -288,7 +290,7 @@ public class RelayPushNotificationServiceTests : PushTestBase
["Payload"] = new JsonObject
{
["UserId"] = userId,
["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime,
["Reason"] = reason != null ? (int)reason : null
},
["ClientType"] = null,
["InstallationId"] = null,

View File

@@ -404,16 +404,18 @@ public class NotificationHubPushNotificationServiceTests
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task PushLogOutAsync_SendExpectedData(bool excludeCurrentContext)
[InlineData(true, null)]
[InlineData(true, PushNotificationLogOutReason.KdfChange)]
[InlineData(false, null)]
[InlineData(false, PushNotificationLogOutReason.KdfChange)]
public async Task PushLogOutAsync_SendExpectedData(bool excludeCurrentContext, PushNotificationLogOutReason? reason)
{
var userId = Guid.NewGuid();
var expectedPayload = new JsonObject
{
["UserId"] = userId,
["Date"] = _now,
["Reason"] = reason != null ? (int)reason : null,
};
var expectedTag = excludeCurrentContext
@@ -421,7 +423,7 @@ public class NotificationHubPushNotificationServiceTests
: $"(template:payload_userId:{userId})";
await VerifyNotificationAsync(
async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext),
async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext, reason),
PushType.LogOut,
expectedPayload,
expectedTag

View File

@@ -18,8 +18,9 @@ public class StripePaymentServiceTests
{
[Theory]
[BitAutoData]
public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithoutAdditionalStorage(
SutProvider<StripePaymentService> sutProvider)
public async Task
PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithoutAdditionalStorage(
SutProvider<StripePaymentService> sutProvider)
{
var familiesPlan = new FamiliesPlan();
sutProvider.GetDependency<IPricingClient>()
@@ -28,16 +29,13 @@ public class StripePaymentServiceTests
var parameters = new PreviewOrganizationInvoiceRequestBody
{
PasswordManager = new OrganizationPasswordManagerRequestModel
{
Plan = PlanType.FamiliesAnnually,
AdditionalStorage = 0
},
TaxInformation = new TaxInformationRequestModel
{
Country = "FR",
PostalCode = "12345"
}
PasswordManager =
new OrganizationPasswordManagerRequestModel
{
Plan = PlanType.FamiliesAnnually,
AdditionalStorage = 0
},
TaxInformation = new TaxInformationRequestModel { Country = "FR", PostalCode = "12345" }
};
sutProvider.GetDependency<IStripeAdapter>()
@@ -52,7 +50,7 @@ public class StripePaymentServiceTests
.Returns(new Invoice
{
TotalExcludingTax = 4000,
Tax = 800,
TotalTaxes = [new InvoiceTotalTax { Amount = 800 }],
Total = 4800
});
@@ -75,16 +73,13 @@ public class StripePaymentServiceTests
var parameters = new PreviewOrganizationInvoiceRequestBody
{
PasswordManager = new OrganizationPasswordManagerRequestModel
{
Plan = PlanType.FamiliesAnnually,
AdditionalStorage = 1
},
TaxInformation = new TaxInformationRequestModel
{
Country = "FR",
PostalCode = "12345"
}
PasswordManager =
new OrganizationPasswordManagerRequestModel
{
Plan = PlanType.FamiliesAnnually,
AdditionalStorage = 1
},
TaxInformation = new TaxInformationRequestModel { Country = "FR", PostalCode = "12345" }
};
sutProvider.GetDependency<IStripeAdapter>()
@@ -96,12 +91,7 @@ public class StripePaymentServiceTests
p.SubscriptionDetails.Items.Any(x =>
x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId &&
x.Quantity == 1)))
.Returns(new Invoice
{
TotalExcludingTax = 4000,
Tax = 800,
Total = 4800
});
.Returns(new Invoice { TotalExcludingTax = 4000, TotalTaxes = [new InvoiceTotalTax { Amount = 800 }], Total = 4800 });
var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
@@ -112,8 +102,9 @@ public class StripePaymentServiceTests
[Theory]
[BitAutoData]
public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithoutAdditionalStorage(
SutProvider<StripePaymentService> sutProvider)
public async Task
PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithoutAdditionalStorage(
SutProvider<StripePaymentService> sutProvider)
{
var familiesPlan = new FamiliesPlan();
sutProvider.GetDependency<IPricingClient>()
@@ -128,11 +119,7 @@ public class StripePaymentServiceTests
SponsoredPlan = PlanSponsorshipType.FamiliesForEnterprise,
AdditionalStorage = 0
},
TaxInformation = new TaxInformationRequestModel
{
Country = "FR",
PostalCode = "12345"
}
TaxInformation = new TaxInformationRequestModel { Country = "FR", PostalCode = "12345" }
};
sutProvider.GetDependency<IStripeAdapter>()
@@ -144,12 +131,7 @@ public class StripePaymentServiceTests
p.SubscriptionDetails.Items.Any(x =>
x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId &&
x.Quantity == 0)))
.Returns(new Invoice
{
TotalExcludingTax = 0,
Tax = 0,
Total = 0
});
.Returns(new Invoice { TotalExcludingTax = 0, TotalTaxes = [new InvoiceTotalTax { Amount = 0 }], Total = 0 });
var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
@@ -160,8 +142,9 @@ public class StripePaymentServiceTests
[Theory]
[BitAutoData]
public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithAdditionalStorage(
SutProvider<StripePaymentService> sutProvider)
public async Task
PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithAdditionalStorage(
SutProvider<StripePaymentService> sutProvider)
{
var familiesPlan = new FamiliesPlan();
sutProvider.GetDependency<IPricingClient>()
@@ -176,11 +159,7 @@ public class StripePaymentServiceTests
SponsoredPlan = PlanSponsorshipType.FamiliesForEnterprise,
AdditionalStorage = 1
},
TaxInformation = new TaxInformationRequestModel
{
Country = "FR",
PostalCode = "12345"
}
TaxInformation = new TaxInformationRequestModel { Country = "FR", PostalCode = "12345" }
};
sutProvider.GetDependency<IStripeAdapter>()
@@ -192,12 +171,7 @@ public class StripePaymentServiceTests
p.SubscriptionDetails.Items.Any(x =>
x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId &&
x.Quantity == 1)))
.Returns(new Invoice
{
TotalExcludingTax = 400,
Tax = 8,
Total = 408
});
.Returns(new Invoice { TotalExcludingTax = 400, TotalTaxes = [new InvoiceTotalTax { Amount = 8 }], Total = 408 });
var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null);
@@ -235,7 +209,7 @@ public class StripePaymentServiceTests
.Returns(new Invoice
{
TotalExcludingTax = 400,
Tax = 8,
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
Total = 408
});
@@ -277,7 +251,7 @@ public class StripePaymentServiceTests
.Returns(new Invoice
{
TotalExcludingTax = 400,
Tax = 8,
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
Total = 408
});
@@ -319,7 +293,7 @@ public class StripePaymentServiceTests
.Returns(new Invoice
{
TotalExcludingTax = 400,
Tax = 8,
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
Total = 408
});
@@ -361,7 +335,7 @@ public class StripePaymentServiceTests
.Returns(new Invoice
{
TotalExcludingTax = 400,
Tax = 8,
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
Total = 408
});
@@ -403,7 +377,7 @@ public class StripePaymentServiceTests
.Returns(new Invoice
{
TotalExcludingTax = 400,
Tax = 8,
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
Total = 408
});
@@ -445,7 +419,7 @@ public class StripePaymentServiceTests
.Returns(new Invoice
{
TotalExcludingTax = 400,
Tax = 8,
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
Total = 408
});
@@ -487,7 +461,7 @@ public class StripePaymentServiceTests
.Returns(new Invoice
{
TotalExcludingTax = 400,
Tax = 8,
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
Total = 408
});
@@ -529,7 +503,7 @@ public class StripePaymentServiceTests
.Returns(new Invoice
{
TotalExcludingTax = 400,
Tax = 8,
TotalTaxes = [new InvoiceTotalTax { Amount = 8 }],
Total = 408
});

View File

@@ -53,38 +53,6 @@ public class ImportCiphersAsyncCommandTests
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
}
[Theory, BitAutoData]
public async Task ImportIntoIndividualVaultAsync_WithBulkResourceCreationServiceEnabled_Success(
Guid importingUserId,
List<CipherDetails> ciphers,
SutProvider<ImportCiphersCommand> sutProvider)
{
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation)
.Returns(true);
sutProvider.GetDependency<IPolicyService>()
.AnyPoliciesApplicableToUserAsync(importingUserId, PolicyType.OrganizationDataOwnership)
.Returns(false);
sutProvider.GetDependency<IFolderRepository>()
.GetManyByUserIdAsync(importingUserId)
.Returns(new List<Folder>());
var folders = new List<Folder> { new Folder { UserId = importingUserId } };
var folderRelationships = new List<KeyValuePair<int, int>>();
// Act
await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId);
// Assert
await sutProvider.GetDependency<ICipherRepository>()
.Received(1)
.CreateAsync_vNext(importingUserId, ciphers, Arg.Any<List<Folder>>());
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
}
[Theory, BitAutoData]
public async Task ImportIntoIndividualVaultAsync_WithPolicyRequirementsEnabled_WithOrganizationDataOwnershipPolicyDisabled_Success(
Guid importingUserId,
@@ -117,42 +85,6 @@ public class ImportCiphersAsyncCommandTests
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
}
[Theory, BitAutoData]
public async Task ImportIntoIndividualVaultAsync_WithBulkResourceCreationServiceEnabled_WithPolicyRequirementsEnabled_WithOrganizationDataOwnershipPolicyDisabled_Success(
Guid importingUserId,
List<CipherDetails> ciphers,
SutProvider<ImportCiphersCommand> sutProvider)
{
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation)
.Returns(true);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PolicyRequirements)
.Returns(true);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<OrganizationDataOwnershipPolicyRequirement>(importingUserId)
.Returns(new OrganizationDataOwnershipPolicyRequirement(
OrganizationDataOwnershipState.Disabled,
[]));
sutProvider.GetDependency<IFolderRepository>()
.GetManyByUserIdAsync(importingUserId)
.Returns(new List<Folder>());
var folders = new List<Folder> { new Folder { UserId = importingUserId } };
var folderRelationships = new List<KeyValuePair<int, int>>();
await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId);
await sutProvider.GetDependency<ICipherRepository>()
.Received(1)
.CreateAsync_vNext(importingUserId, ciphers, Arg.Any<List<Folder>>());
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
}
[Theory, BitAutoData]
public async Task ImportIntoIndividualVaultAsync_ThrowsBadRequestException(
List<Folder> folders,
@@ -259,66 +191,6 @@ public class ImportCiphersAsyncCommandTests
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
}
[Theory, BitAutoData]
public async Task ImportIntoOrganizationalVaultAsync_WithBulkResourceCreationServiceEnabled_Success(
Organization organization,
Guid importingUserId,
OrganizationUser importingOrganizationUser,
List<Collection> collections,
List<CipherDetails> ciphers,
SutProvider<ImportCiphersCommand> sutProvider)
{
organization.MaxCollections = null;
importingOrganizationUser.OrganizationId = organization.Id;
foreach (var collection in collections)
{
collection.OrganizationId = organization.Id;
}
foreach (var cipher in ciphers)
{
cipher.OrganizationId = organization.Id;
}
KeyValuePair<int, int>[] collectionRelationships = {
new(0, 0),
new(1, 1),
new(2, 2)
};
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation)
.Returns(true);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByOrganizationAsync(organization.Id, importingUserId)
.Returns(importingOrganizationUser);
// Set up a collection that already exists in the organization
sutProvider.GetDependency<ICollectionRepository>()
.GetManyByOrganizationIdAsync(organization.Id)
.Returns(new List<Collection> { collections[0] });
await sutProvider.Sut.ImportIntoOrganizationalVaultAsync(collections, ciphers, collectionRelationships, importingUserId);
await sutProvider.GetDependency<ICipherRepository>().Received(1).CreateAsync_vNext(
ciphers,
Arg.Is<IEnumerable<Collection>>(cols => cols.Count() == collections.Count - 1 &&
!cols.Any(c => c.Id == collections[0].Id) && // Check that the collection that already existed in the organization was not added
cols.All(c => collections.Any(x => c.Name == x.Name))),
Arg.Is<IEnumerable<CollectionCipher>>(c => c.Count() == ciphers.Count),
Arg.Is<IEnumerable<CollectionUser>>(cus =>
cus.Count() == collections.Count - 1 &&
!cus.Any(cu => cu.CollectionId == collections[0].Id) && // Check that access was not added for the collection that already existed in the organization
cus.All(cu => cu.OrganizationUserId == importingOrganizationUser.Id && cu.Manage == true)));
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
}
[Theory, BitAutoData]
public async Task ImportIntoOrganizationalVaultAsync_ThrowsBadRequestException(
Organization organization,

View File

@@ -0,0 +1,18 @@
using Bit.Core.Utilities;
using Xunit;
namespace Bit.Core.Test.Utilities;
public class AssemblyHelpersTests
{
[Fact]
public void ReturnsValidVersionAndGitHash()
{
var version = AssemblyHelpers.GetVersion();
_ = Version.Parse(version);
var gitHash = AssemblyHelpers.GetGitHash();
Assert.NotNull(gitHash);
Assert.Equal(8, gitHash.Length);
}
}

View File

@@ -113,6 +113,242 @@ public class CipherServiceTests
await sutProvider.GetDependency<ICipherRepository>().Received(1).ReplaceAsync(cipherDetails);
}
[Theory, BitAutoData]
public async Task CreateAttachmentAsync_WrongRevisionDate_Throws(SutProvider<CipherService> sutProvider, Cipher cipher, Guid savingUserId)
{
var lastKnownRevisionDate = cipher.RevisionDate.AddDays(-1);
var stream = new MemoryStream();
var fileName = "test.txt";
var key = "test-key";
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CreateAttachmentAsync(cipher, stream, fileName, key, 100, savingUserId, false, lastKnownRevisionDate));
Assert.Contains("out of date", exception.Message);
}
[Theory]
[BitAutoData("")]
[BitAutoData("Correct Time")]
public async Task CreateAttachmentAsync_CorrectRevisionDate_DoesNotThrow(string revisionDateString,
SutProvider<CipherService> sutProvider, CipherDetails cipher, Guid savingUserId)
{
var lastKnownRevisionDate = string.IsNullOrEmpty(revisionDateString) ? (DateTime?)null : cipher.RevisionDate;
var stream = new MemoryStream(new byte[100]);
var fileName = "test.txt";
var key = "test-key";
// Setup cipher with user ownership
cipher.UserId = savingUserId;
cipher.OrganizationId = null;
// Mock user storage and premium access
var user = new User { Id = savingUserId, MaxStorageGb = 1 };
sutProvider.GetDependency<IUserRepository>()
.GetByIdAsync(savingUserId)
.Returns(user);
sutProvider.GetDependency<IUserService>()
.CanAccessPremium(user)
.Returns(true);
sutProvider.GetDependency<IAttachmentStorageService>()
.UploadNewAttachmentAsync(Arg.Any<Stream>(), cipher, Arg.Any<CipherAttachment.MetaData>())
.Returns(Task.CompletedTask);
sutProvider.GetDependency<IAttachmentStorageService>()
.ValidateFileAsync(cipher, Arg.Any<CipherAttachment.MetaData>(), Arg.Any<long>())
.Returns((true, 100L));
sutProvider.GetDependency<ICipherRepository>()
.UpdateAttachmentAsync(Arg.Any<CipherAttachment>())
.Returns(Task.CompletedTask);
sutProvider.GetDependency<ICipherRepository>()
.ReplaceAsync(Arg.Any<CipherDetails>())
.Returns(Task.CompletedTask);
await sutProvider.Sut.CreateAttachmentAsync(cipher, stream, fileName, key, 100, savingUserId, false, lastKnownRevisionDate);
await sutProvider.GetDependency<IAttachmentStorageService>().Received(1)
.UploadNewAttachmentAsync(Arg.Any<Stream>(), cipher, Arg.Any<CipherAttachment.MetaData>());
}
[Theory, BitAutoData]
public async Task CreateAttachmentForDelayedUploadAsync_WrongRevisionDate_Throws(SutProvider<CipherService> sutProvider, Cipher cipher, Guid savingUserId)
{
var lastKnownRevisionDate = cipher.RevisionDate.AddDays(-1);
var key = "test-key";
var fileName = "test.txt";
var fileSize = 100L;
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CreateAttachmentForDelayedUploadAsync(cipher, key, fileName, fileSize, false, savingUserId, lastKnownRevisionDate));
Assert.Contains("out of date", exception.Message);
}
[Theory]
[BitAutoData("")]
[BitAutoData("Correct Time")]
public async Task CreateAttachmentForDelayedUploadAsync_CorrectRevisionDate_DoesNotThrow(string revisionDateString,
SutProvider<CipherService> sutProvider, CipherDetails cipher, Guid savingUserId)
{
var lastKnownRevisionDate = string.IsNullOrEmpty(revisionDateString) ? (DateTime?)null : cipher.RevisionDate;
var key = "test-key";
var fileName = "test.txt";
var fileSize = 100L;
// Setup cipher with user ownership
cipher.UserId = savingUserId;
cipher.OrganizationId = null;
// Mock user storage and premium access
var user = new User { Id = savingUserId, MaxStorageGb = 1 };
sutProvider.GetDependency<IUserRepository>()
.GetByIdAsync(savingUserId)
.Returns(user);
sutProvider.GetDependency<IUserService>()
.CanAccessPremium(user)
.Returns(true);
sutProvider.GetDependency<IAttachmentStorageService>()
.GetAttachmentUploadUrlAsync(cipher, Arg.Any<CipherAttachment.MetaData>())
.Returns("https://example.com/upload");
sutProvider.GetDependency<ICipherRepository>()
.UpdateAttachmentAsync(Arg.Any<CipherAttachment>())
.Returns(Task.CompletedTask);
var result = await sutProvider.Sut.CreateAttachmentForDelayedUploadAsync(cipher, key, fileName, fileSize, false, savingUserId, lastKnownRevisionDate);
Assert.NotNull(result.attachmentId);
Assert.NotNull(result.uploadUrl);
}
[Theory, BitAutoData]
public async Task UploadFileForExistingAttachmentAsync_WrongRevisionDate_Throws(SutProvider<CipherService> sutProvider,
Cipher cipher)
{
var lastKnownRevisionDate = cipher.RevisionDate.AddDays(-1);
var stream = new MemoryStream();
var attachment = new CipherAttachment.MetaData
{
AttachmentId = "test-attachment-id",
Size = 100,
FileName = "test.txt",
Key = "test-key"
};
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.UploadFileForExistingAttachmentAsync(stream, cipher, attachment, lastKnownRevisionDate));
Assert.Contains("out of date", exception.Message);
}
[Theory]
[BitAutoData("")]
[BitAutoData("Correct Time")]
public async Task UploadFileForExistingAttachmentAsync_CorrectRevisionDate_DoesNotThrow(string revisionDateString,
SutProvider<CipherService> sutProvider, CipherDetails cipher)
{
var lastKnownRevisionDate = string.IsNullOrEmpty(revisionDateString) ? (DateTime?)null : cipher.RevisionDate;
var stream = new MemoryStream(new byte[100]);
var attachmentId = "test-attachment-id";
var attachment = new CipherAttachment.MetaData
{
AttachmentId = attachmentId,
Size = 100,
FileName = "test.txt",
Key = "test-key"
};
// Set the attachment on the cipher so ValidateCipherAttachmentFile can find it
cipher.SetAttachments(new Dictionary<string, CipherAttachment.MetaData>
{
[attachmentId] = attachment
});
sutProvider.GetDependency<IAttachmentStorageService>()
.UploadNewAttachmentAsync(stream, cipher, attachment)
.Returns(Task.CompletedTask);
sutProvider.GetDependency<IAttachmentStorageService>()
.ValidateFileAsync(cipher, attachment, Arg.Any<long>())
.Returns((true, 100L));
sutProvider.GetDependency<ICipherRepository>()
.UpdateAttachmentAsync(Arg.Any<CipherAttachment>())
.Returns(Task.CompletedTask);
await sutProvider.Sut.UploadFileForExistingAttachmentAsync(stream, cipher, attachment, lastKnownRevisionDate);
await sutProvider.GetDependency<IAttachmentStorageService>().Received(1)
.UploadNewAttachmentAsync(stream, cipher, attachment);
}
[Theory, BitAutoData]
public async Task CreateAttachmentShareAsync_WrongRevisionDate_Throws(SutProvider<CipherService> sutProvider,
Cipher cipher, Guid organizationId)
{
var lastKnownRevisionDate = cipher.RevisionDate.AddDays(-1);
var stream = new MemoryStream();
var fileName = "test.txt";
var key = "test-key";
var attachmentId = "attachment-id";
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CreateAttachmentShareAsync(cipher, stream, fileName, key, 100, attachmentId, organizationId, lastKnownRevisionDate));
Assert.Contains("out of date", exception.Message);
}
[Theory]
[BitAutoData("")]
[BitAutoData("Correct Time")]
public async Task CreateAttachmentShareAsync_CorrectRevisionDate_DoesNotThrow(string revisionDateString,
SutProvider<CipherService> sutProvider, CipherDetails cipher, Guid organizationId)
{
var lastKnownRevisionDate = string.IsNullOrEmpty(revisionDateString) ? (DateTime?)null : cipher.RevisionDate;
var stream = new MemoryStream(new byte[100]);
var fileName = "test.txt";
var key = "test-key";
var attachmentId = "attachment-id";
// Setup cipher with existing attachment (no TempMetadata)
cipher.OrganizationId = null;
cipher.SetAttachments(new Dictionary<string, CipherAttachment.MetaData>
{
[attachmentId] = new CipherAttachment.MetaData
{
AttachmentId = attachmentId,
Size = 100,
FileName = "existing.txt",
Key = "existing-key"
}
});
// Mock organization
var organization = new Organization
{
Id = organizationId,
MaxStorageGb = 1
};
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organizationId)
.Returns(organization);
sutProvider.GetDependency<IAttachmentStorageService>()
.UploadShareAttachmentAsync(stream, cipher.Id, organizationId, Arg.Any<CipherAttachment.MetaData>())
.Returns(Task.CompletedTask);
sutProvider.GetDependency<ICipherRepository>()
.UpdateAttachmentAsync(Arg.Any<CipherAttachment>())
.Returns(Task.CompletedTask);
await sutProvider.Sut.CreateAttachmentShareAsync(cipher, stream, fileName, key, 100, attachmentId, organizationId, lastKnownRevisionDate);
await sutProvider.GetDependency<IAttachmentStorageService>().Received(1)
.UploadShareAttachmentAsync(stream, cipher.Id, organizationId, Arg.Any<CipherAttachment.MetaData>());
}
[Theory]
[BitAutoData]
public async Task SaveDetailsAsync_PersonalVault_WithOrganizationDataOwnershipPolicyEnabled_Throws(
@@ -674,32 +910,6 @@ public class CipherServiceTests
Arg.Is<IEnumerable<Cipher>>(arg => !arg.Except(ciphers).Any()));
}
[Theory]
[BitAutoData("")]
[BitAutoData("Correct Time")]
public async Task ShareManyAsync_CorrectRevisionDate_WithBulkResourceCreationServiceEnabled_Passes(string revisionDateString,
SutProvider<CipherService> sutProvider, IEnumerable<CipherDetails> ciphers, Organization organization, List<Guid> collectionIds)
{
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation)
.Returns(true);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id)
.Returns(new Organization
{
PlanType = PlanType.EnterpriseAnnually,
MaxStorageGb = 100
});
var cipherInfos = ciphers.Select(c => (c,
string.IsNullOrEmpty(revisionDateString) ? null : (DateTime?)c.RevisionDate));
var sharingUserId = ciphers.First().UserId.Value;
await sutProvider.Sut.ShareManyAsync(cipherInfos, organization.Id, collectionIds, sharingUserId);
await sutProvider.GetDependency<ICipherRepository>().Received(1).UpdateCiphersAsync_vNext(sharingUserId,
Arg.Is<IEnumerable<Cipher>>(arg => !arg.Except(ciphers).Any()));
}
[Theory]
[BitAutoData]
public async Task RestoreAsync_UpdatesUserCipher(Guid restoringUserId, CipherDetails cipher, SutProvider<CipherService> sutProvider)
@@ -1120,33 +1330,6 @@ public class CipherServiceTests
Arg.Is<IEnumerable<Cipher>>(arg => !arg.Except(ciphers).Any()));
}
[Theory, BitAutoData]
public async Task ShareManyAsync_PaidOrgWithAttachment_WithBulkResourceCreationServiceEnabled_Passes(SutProvider<CipherService> sutProvider,
IEnumerable<CipherDetails> ciphers, Guid organizationId, List<Guid> collectionIds)
{
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation)
.Returns(true);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
.Returns(new Organization
{
PlanType = PlanType.EnterpriseAnnually,
MaxStorageGb = 100
});
ciphers.FirstOrDefault().Attachments =
"{\"attachment1\":{\"Size\":\"250\",\"FileName\":\"superCoolFile\","
+ "\"Key\":\"superCoolFile\",\"ContainerName\":\"testContainer\",\"Validated\":false}}";
var cipherInfos = ciphers.Select(c => (c,
(DateTime?)c.RevisionDate));
var sharingUserId = ciphers.First().UserId.Value;
await sutProvider.Sut.ShareManyAsync(cipherInfos, organizationId, collectionIds, sharingUserId);
await sutProvider.GetDependency<ICipherRepository>().Received(1).UpdateCiphersAsync_vNext(sharingUserId,
Arg.Is<IEnumerable<Cipher>>(arg => !arg.Except(ciphers).Any()));
}
private class SaveDetailsAsyncDependencies
{
public CipherDetails CipherDetails { get; set; }

View File

@@ -0,0 +1,58 @@
using System.Reflection;
using System.Security.Claims;
using AutoFixture;
using AutoFixture.Xunit2;
using Bit.Core.Auth.Identity;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Validation;
namespace Bit.Identity.Test.AutoFixture;
internal class ProfileDataRequestContextCustomization : ICustomization
{
public void Customize(IFixture fixture)
{
fixture.Customize<ProfileDataRequestContext>(composer => composer
.With(o => o.Subject, new ClaimsPrincipal(new ClaimsIdentity([
new Claim("sub", Guid.NewGuid().ToString()),
new Claim("name", "Test User"),
new Claim("email", "test@example.com")
])))
.With(o => o.Client, new Client { ClientId = "web" })
.With(o => o.ValidatedRequest, () => null)
.With(o => o.RequestedResources, new ResourceValidationResult())
.With(o => o.IssuedClaims, [])
.Without(o => o.Caller));
}
}
public class ProfileDataRequestContextAttribute : CustomizeAttribute
{
public override ICustomization GetCustomization(ParameterInfo parameter)
{
return new ProfileDataRequestContextCustomization();
}
}
internal class IsActiveContextCustomization : ICustomization
{
public void Customize(IFixture fixture)
{
fixture.Customize<IsActiveContext>(composer => composer
.With(o => o.Subject, new ClaimsPrincipal(new ClaimsIdentity([
new Claim("sub", Guid.NewGuid().ToString()),
new Claim(Claims.SecurityStamp, "test-security-stamp")
])))
.With(o => o.Client, new Client { ClientId = "web" })
.With(o => o.IsActive, false)
.Without(o => o.Caller));
}
}
public class IsActiveContextAttribute : CustomizeAttribute
{
public override ICustomization GetCustomization(ParameterInfo parameter)
{
return new IsActiveContextCustomization();
}
}

View File

@@ -5,6 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Diagnostics.Testing" Version="9.3.0" />
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>

View File

@@ -10,7 +10,9 @@ using Bit.Core.Auth.Repositories;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Models.Response;
using Bit.Core.KeyManagement.Models.Api.Response;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.Models.Api;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -22,6 +24,7 @@ using Bit.Test.Common.AutoFixture.Attributes;
using Duende.IdentityServer.Validation;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;
using Microsoft.Extensions.Options;
using NSubstitute;
using Xunit;
@@ -40,7 +43,7 @@ public class BaseRequestValidatorTests
private readonly IDeviceValidator _deviceValidator;
private readonly ITwoFactorAuthenticationValidator _twoFactorAuthenticationValidator;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly ILogger<BaseRequestValidatorTests> _logger;
private readonly FakeLogger<BaseRequestValidatorTests> _logger;
private readonly ICurrentContext _currentContext;
private readonly GlobalSettings _globalSettings;
private readonly IUserRepository _userRepository;
@@ -51,6 +54,7 @@ public class BaseRequestValidatorTests
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IAuthRequestRepository _authRequestRepository;
private readonly IMailService _mailService;
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
private readonly BaseRequestValidatorTestWrapper _sut;
@@ -62,7 +66,7 @@ public class BaseRequestValidatorTests
_deviceValidator = Substitute.For<IDeviceValidator>();
_twoFactorAuthenticationValidator = Substitute.For<ITwoFactorAuthenticationValidator>();
_organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
_logger = Substitute.For<ILogger<BaseRequestValidatorTests>>();
_logger = new FakeLogger<BaseRequestValidatorTests>();
_currentContext = Substitute.For<ICurrentContext>();
_globalSettings = Substitute.For<GlobalSettings>();
_userRepository = Substitute.For<IUserRepository>();
@@ -73,6 +77,7 @@ public class BaseRequestValidatorTests
_policyRequirementQuery = Substitute.For<IPolicyRequirementQuery>();
_authRequestRepository = Substitute.For<IAuthRequestRepository>();
_mailService = Substitute.For<IMailService>();
_userAccountKeysQuery = Substitute.For<IUserAccountKeysQuery>();
_sut = new BaseRequestValidatorTestWrapper(
_userManager,
@@ -91,7 +96,8 @@ public class BaseRequestValidatorTests
_userDecryptionOptionsBuilder,
_policyRequirementQuery,
_authRequestRepository,
_mailService);
_mailService,
_userAccountKeysQuery);
}
/* Logic path
@@ -115,7 +121,8 @@ public class BaseRequestValidatorTests
await _sut.ValidateAsync(context);
// Assert
_logger.Received(1).LogWarning(Constants.BypassFiltersEventId, "Failed login attempt. ");
var logs = _logger.Collector.GetSnapshot(true);
Assert.Contains(logs, l => l.Level == LogLevel.Warning && l.Message == "Failed login attempt. Is2FARequest: False IpAddress: ");
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
Assert.Equal("Username or password is incorrect. Try again.", errorResponse.Message);
}
@@ -180,6 +187,13 @@ public class BaseRequestValidatorTests
// 5 -> not legacy user
_userService.IsLegacyUser(Arg.Any<string>())
.Returns(false);
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
"test-private-key",
"test-public-key"
)
});
// Act
await _sut.ValidateAsync(context);
@@ -227,6 +241,13 @@ public class BaseRequestValidatorTests
// 5 -> not legacy user
_userService.IsLegacyUser(Arg.Any<string>())
.Returns(false);
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
"test-private-key",
"test-public-key"
)
});
// Act
await _sut.ValidateAsync(context);
@@ -337,7 +358,7 @@ public class BaseRequestValidatorTests
// 1 -> initial validation passes
_sut.isValid = true;
// 2 -> enable the FailedTwoFactorEmail feature flag
// 2 -> enable the FailedTwoFactorEmail feature flag
_featureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail).Returns(true);
// 3 -> set up 2FA as required
@@ -460,6 +481,13 @@ public class BaseRequestValidatorTests
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
.Returns(Task.FromResult(true));
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
"test-private-key",
"test-public-key"
)
});
await _sut.ValidateAsync(context);
@@ -495,6 +523,13 @@ public class BaseRequestValidatorTests
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
.Returns(Task.FromResult(true));
context.ValidatedTokenRequest.ClientId = "web";
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
"test-private-key",
"test-public-key"
)
});
// Act
await _sut.ValidateAsync(context);
@@ -529,6 +564,13 @@ public class BaseRequestValidatorTests
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
.Returns(Task.FromResult(true));
context.ValidatedTokenRequest.ClientId = "web";
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
"test-private-key",
"test-public-key"
)
});
// Act
await _sut.ValidateAsync(context);
@@ -591,6 +633,13 @@ public class BaseRequestValidatorTests
HasMasterPassword = false,
MasterPasswordUnlock = null
}));
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
"test-private-key",
"test-public-key"
)
});
var context = CreateContext(tokenRequest, requestContext, grantResult);
_sut.isValid = true;
@@ -644,6 +693,14 @@ public class BaseRequestValidatorTests
}
}));
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
"test-private-key",
"test-public-key"
)
});
var context = CreateContext(tokenRequest, requestContext, grantResult);
_sut.isValid = true;
@@ -671,6 +728,152 @@ public class BaseRequestValidatorTests
Assert.Equal("test@example.com", userDecryptionOptions.MasterPasswordUnlock.Salt);
}
[Theory, BitAutoData]
public async Task ValidateAsync_CustomResponse_ShouldIncludeAccountKeys(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
var mockAccountKeys = new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
"test-private-key",
"test-public-key",
"test-signed-public-key"
),
SignatureKeyPairData = new SignatureKeyPairData(
Core.KeyManagement.Enums.SignatureAlgorithm.Ed25519,
"test-wrapped-signing-key",
"test-verifying-key"
),
SecurityStateData = new SecurityStateData
{
SecurityState = "test-security-state",
SecurityVersion = 2
}
};
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(mockAccountKeys);
_userDecryptionOptionsBuilder.ForUser(Arg.Any<User>()).Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.WithDevice(Arg.Any<Device>()).Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.WithSso(Arg.Any<SsoConfig>()).Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any<WebAuthnCredential>()).Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions
{
HasMasterPassword = true,
MasterPasswordUnlock = new MasterPasswordUnlockResponseModel
{
Kdf = new MasterPasswordUnlockKdfResponseModel
{
KdfType = KdfType.PBKDF2_SHA256,
Iterations = 100000
},
MasterKeyEncryptedUserKey = _mockEncryptedString,
Salt = "test@example.com"
}
}));
var context = CreateContext(tokenRequest, requestContext, grantResult);
_sut.isValid = true;
_twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
.Returns(Task.FromResult(true));
// Act
await _sut.ValidateAsync(context);
// Assert
Assert.False(context.GrantResult.IsError);
var customResponse = context.GrantResult.CustomResponse;
// Verify AccountKeys are included in response
Assert.Contains("AccountKeys", customResponse);
Assert.IsType<PrivateKeysResponseModel>(customResponse["AccountKeys"]);
var accountKeysResponse = (PrivateKeysResponseModel)customResponse["AccountKeys"];
Assert.NotNull(accountKeysResponse.PublicKeyEncryptionKeyPair);
Assert.Equal("test-public-key", accountKeysResponse.PublicKeyEncryptionKeyPair.PublicKey);
Assert.Equal("test-private-key", accountKeysResponse.PublicKeyEncryptionKeyPair.WrappedPrivateKey);
Assert.Equal("test-signed-public-key", accountKeysResponse.PublicKeyEncryptionKeyPair.SignedPublicKey);
Assert.NotNull(accountKeysResponse.SignatureKeyPair);
Assert.Equal("test-wrapped-signing-key", accountKeysResponse.SignatureKeyPair.WrappedSigningKey);
Assert.Equal("test-verifying-key", accountKeysResponse.SignatureKeyPair.VerifyingKey);
Assert.NotNull(accountKeysResponse.SecurityState);
Assert.Equal("test-security-state", accountKeysResponse.SecurityState.SecurityState);
Assert.Equal(2, accountKeysResponse.SecurityState.SecurityVersion);
}
[Theory, BitAutoData]
public async Task ValidateAsync_CustomResponse_AccountKeysQuery_SkippedWhenPrivateKeyIsNull(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
requestContext.User.PrivateKey = null;
var context = CreateContext(tokenRequest, requestContext, grantResult);
_sut.isValid = true;
_twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
.Returns(Task.FromResult(true));
// Act
await _sut.ValidateAsync(context);
// Assert
Assert.False(context.GrantResult.IsError);
// Verify that the account keys query wasn't called.
await _userAccountKeysQuery.Received(0).Run(Arg.Any<User>());
}
[Theory, BitAutoData]
public async Task ValidateAsync_CustomResponse_AccountKeysQuery_CalledWithCorrectUser(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
var expectedUser = requestContext.User;
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
"test-private-key",
"test-public-key"
)
});
_userDecryptionOptionsBuilder.ForUser(Arg.Any<User>()).Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.WithDevice(Arg.Any<Device>()).Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.WithSso(Arg.Any<SsoConfig>()).Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any<WebAuthnCredential>()).Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions()));
var context = CreateContext(tokenRequest, requestContext, grantResult);
_sut.isValid = true;
_twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
.Returns(Task.FromResult(true));
// Act
await _sut.ValidateAsync(context);
// Assert
Assert.False(context.GrantResult.IsError);
// Verify that the account keys query was called with the correct user
await _userAccountKeysQuery.Received(1).Run(Arg.Is<User>(u => u.Id == expectedUser.Id));
}
private BaseRequestValidationContextFake CreateContext(
ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,

View File

@@ -0,0 +1,562 @@
using System.Security.Claims;
using Bit.Core.AdminConsole.Context;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Identity;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Identity.IdentityServer;
using Bit.Test.Common.AutoFixture.Attributes;
using Duende.IdentityServer.Models;
using NSubstitute;
using Xunit;
using AuthFixtures = Bit.Identity.Test.AutoFixture;
namespace Bit.Identity.Test.IdentityServer;
public class ProfileServiceTests
{
private readonly IUserService _userService;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IProviderUserRepository _providerUserRepository;
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly ILicensingService _licensingService;
private readonly ICurrentContext _currentContext;
private readonly ProfileService _sut;
public ProfileServiceTests()
{
_userService = Substitute.For<IUserService>();
_organizationUserRepository = Substitute.For<IOrganizationUserRepository>();
_providerUserRepository = Substitute.For<IProviderUserRepository>();
_providerOrganizationRepository = Substitute.For<IProviderOrganizationRepository>();
_licensingService = Substitute.For<ILicensingService>();
_currentContext = Substitute.For<ICurrentContext>();
_sut = new ProfileService(
_userService,
_organizationUserRepository,
_providerUserRepository,
_providerOrganizationRepository,
_licensingService,
_currentContext);
}
/// <summary>
/// For Bitwarden Sends, the zero-knowledge feature architecture is enforced by preserving claims as issued,
/// without attempting user lookup or claims mutation.
/// When acting on behalf of a Send client, the service preserves existing claims, including those issued
/// by the SendAccessGrantValidator, and returns without further claims lookup.
/// </summary>
/// <seealso cref="Bit.Identity.IdentityServer.RequestValidators.SendAccess.SendAccessGrantValidator"/>
[Theory, BitAutoData]
public async Task GetProfileDataAsync_SendClient_PreservesExistingClaims(
[AuthFixtures.ProfileDataRequestContext]
ProfileDataRequestContext context)
{
context.Client.ClientId = BitwardenClient.Send;
var existingClaims = new[]
{
new Claim(Claims.SendAccessClaims.SendId, Guid.NewGuid().ToString()), new Claim("send_access", "test")
};
context.Subject = new ClaimsPrincipal(new ClaimsIdentity(existingClaims));
await _sut.GetProfileDataAsync(context);
Assert.Equal(existingClaims.Length, context.IssuedClaims.Count);
Assert.All(existingClaims, existingClaim =>
Assert.Contains(context.IssuedClaims, issuedClaim => issuedClaim.Type == existingClaim.Type
&& issuedClaim.Value == existingClaim.Value));
}
/// <summary>
/// For Bitwarden Sends, Send access tokens neither represent a user state nor require user profile data.
/// The SendAccessGrantValidator handles validity of requests, including resource passwords and 2FA.
/// Separation of concerns dictates that actions on behalf of Send clients should complete without
/// further lookup of user data.
/// </summary>
/// <seealso cref="Bit.Identity.IdentityServer.RequestValidators.SendAccess.SendAccessGrantValidator"/>
[Theory, BitAutoData]
public async Task GetProfileDataAsync_SendClient_DoesNotCallUserService(
[AuthFixtures.ProfileDataRequestContext]
ProfileDataRequestContext context)
{
context.Client.ClientId = BitwardenClient.Send;
await _sut.GetProfileDataAsync(context);
await _userService.DidNotReceive().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>());
}
/// <summary>
/// For Bitwarden Sends, the client is treated as having always-active behavior, and is neither representative of
/// a user state nor requires user profile data.
/// </summary>
/// <seealso cref="Bit.Identity.IdentityServer.RequestValidators.SendAccess.SendAccessGrantValidator"/>
[Theory, BitAutoData]
public async Task IsActiveAsync_SendClient_ReturnsTrue(
[AuthFixtures.IsActiveContext] IsActiveContext context)
{
context.Client.ClientId = BitwardenClient.Send;
context.IsActive = false;
await _sut.IsActiveAsync(context);
Assert.True(context.IsActive);
}
/// <summary>
/// For Bitwarden Sends, the client should not interrogate the user principal as part of evaluating
/// whether it is active.
/// </summary>
[Theory, BitAutoData]
public async Task IsActiveAsync_SendClient_DoesNotCallUserService(
[AuthFixtures.IsActiveContext] IsActiveContext context)
{
context.Client.ClientId = BitwardenClient.Send;
await _sut.IsActiveAsync(context);
await _userService.DidNotReceive().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>());
}
/// <summary>
/// When IdentityServer issues a new access token or services a UserInfo request for a given user,
/// re-evaluate the claims for that user to ensure freshness.
/// Organization-specific claims should be filtered out if the user is null for any reason.
/// This allows users to continue acting on their own behalf from a valid authenticated state, but enforces
/// a security boundary which prevents leaking of organization data and ensures organization claims,
/// which are more likely to change than user claims, are accurate and not present if the user cannot be
/// verified.
/// </summary>
[Theory]
[BitAutoData(BitwardenClient.Web)]
[BitAutoData(BitwardenClient.Browser)]
[BitAutoData(BitwardenClient.Cli)]
[BitAutoData(BitwardenClient.Desktop)]
[BitAutoData(BitwardenClient.Mobile)]
[BitAutoData(BitwardenClient.DirectoryConnector)]
public async Task GetProfileDataAsync_UserNull_PreservesExistingNonOrgClaims(
string client,
[AuthFixtures.ProfileDataRequestContext]
ProfileDataRequestContext context)
{
context.Client.ClientId = client;
var existingClaims = new[]
{
new Claim("sub", Guid.NewGuid().ToString()), new Claim("email", "test@example.com"),
new Claim(Claims.OrganizationOwner, Guid.NewGuid().ToString()) // This should be filtered out
};
context.Subject = new ClaimsPrincipal(new ClaimsIdentity(existingClaims));
_userService.GetUserByPrincipalAsync(context.Subject).Returns((User)null);
await _sut.GetProfileDataAsync(context);
// Should preserve user claims
Assert.Contains(context.IssuedClaims, issuedClaim => issuedClaim.Type == "sub");
Assert.Contains(context.IssuedClaims, issuedClaim => issuedClaim.Type == "email");
// Should filter out organization-related claims
Assert.DoesNotContain(context.IssuedClaims, issuedClaim => issuedClaim.Type.StartsWith("org"));
}
/// <summary>
/// When IdentityServer issues a new access token or services a UserInfo request for a given user,
/// re-evaluate the claims for that user to ensure freshness.
/// New or updated claims, including premium access and organization or provider membership,
/// should be served with the response.
/// </summary>
[Theory]
[BitAutoData(BitwardenClient.Web)]
[BitAutoData(BitwardenClient.Browser)]
[BitAutoData(BitwardenClient.Cli)]
[BitAutoData(BitwardenClient.Desktop)]
[BitAutoData(BitwardenClient.Mobile)]
[BitAutoData(BitwardenClient.DirectoryConnector)]
public async Task GetProfileDataAsync_UserExists_BuildsIdentityClaims(
string client,
[AuthFixtures.ProfileDataRequestContext]
ProfileDataRequestContext context,
User user)
{
context.Client.ClientId = client;
user.Id = Guid.Parse(context.Subject.FindFirst("sub")!.Value);
var orgMemberships = new List<CurrentContextOrganization>
{
new() { Id = Guid.NewGuid(), Type = OrganizationUserType.User }
};
var providerMemberships = new List<CurrentContextProvider>();
_userService.GetUserByPrincipalAsync(context.Subject).Returns(user);
_licensingService.ValidateUserPremiumAsync(user).Returns(true);
_currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id)
.Returns(orgMemberships);
_currentContext.ProviderMembershipAsync(_providerUserRepository, user.Id)
.Returns(providerMemberships);
await _sut.GetProfileDataAsync(context);
Assert.NotEmpty(context.IssuedClaims);
Assert.Contains(context.IssuedClaims,
issuedClaim => issuedClaim.Type == Claims.Premium &&
issuedClaim.Value.Equals("true", StringComparison.CurrentCultureIgnoreCase));
await _licensingService.Received(1).ValidateUserPremiumAsync(user);
await _currentContext.Received(1).OrganizationMembershipAsync(_organizationUserRepository, user.Id);
await _currentContext.Received(1).ProviderMembershipAsync(_providerUserRepository, user.Id);
}
/// <summary>
/// OpenID Connect Core and JWT distinguish between string and boolean types. For spec compliance,
/// boolean types should be served as booleans, not as strings (e.g., true, not "true"). See
/// https://datatracker.ietf.org/doc/html/rfc7159#section-3, and
/// https://datatracker.ietf.org/doc/html/rfc7519#section-2.
/// For proper claims deserialization and type safety, ensure boolean values are treated as
/// ClaimType.Boolean.
/// </summary>
[Theory]
[BitAutoData(BitwardenClient.Web)]
[BitAutoData(BitwardenClient.Browser)]
[BitAutoData(BitwardenClient.Cli)]
[BitAutoData(BitwardenClient.Desktop)]
[BitAutoData(BitwardenClient.Mobile)]
[BitAutoData(BitwardenClient.DirectoryConnector)]
public async Task GetProfileDataAsync_UserExists_BooleanClaimsHaveBooleanType(
string client,
[AuthFixtures.ProfileDataRequestContext]
ProfileDataRequestContext context,
User user)
{
context.Client.ClientId = client;
user.Id = Guid.Parse(context.Subject.FindFirst("sub").Value);
_userService.GetUserByPrincipalAsync(context.Subject).Returns(user);
_licensingService.ValidateUserPremiumAsync(user).Returns(true);
_currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id)
.Returns(new List<CurrentContextOrganization>());
_currentContext.ProviderMembershipAsync(_providerUserRepository, user.Id)
.Returns(new List<CurrentContextProvider>());
await _sut.GetProfileDataAsync(context);
var booleanClaims = context.IssuedClaims.Where(claim =>
claim.Value.Equals("true", StringComparison.OrdinalIgnoreCase) ||
claim.Value.Equals("false", StringComparison.OrdinalIgnoreCase));
Assert.All(booleanClaims, claim =>
Assert.Equal(ClaimValueTypes.Boolean, claim.ValueType));
}
/// <summary>
/// When IdentityServer issues a new access token or services a UserInfo request for a given user,
/// re-evaluate the claims for that user to ensure freshness.
/// Organization-specific claims should never be allowed to persist, and should always be fetched fresh.
/// </summary>
/// <seealso cref="Bit.Core.Context.ICurrentContext.OrganizationMembershipAsync" />
[Theory]
[BitAutoData(BitwardenClient.Web)]
[BitAutoData(BitwardenClient.Browser)]
[BitAutoData(BitwardenClient.Cli)]
[BitAutoData(BitwardenClient.Desktop)]
[BitAutoData(BitwardenClient.Mobile)]
[BitAutoData(BitwardenClient.DirectoryConnector)]
public async Task GetProfileDataAsync_FiltersOutOrgClaimsFromExisting(
string client,
[AuthFixtures.ProfileDataRequestContext]
ProfileDataRequestContext context,
User user)
{
context.Client.ClientId = client;
user.Id = Guid.Parse(context.Subject.FindFirst("sub").Value);
var existingClaims = new[]
{
new Claim(Claims.OrganizationOwner, Guid.NewGuid().ToString()),
new Claim(Claims.OrganizationAdmin, Guid.NewGuid().ToString()), new Claim("email", "test@example.com"),
new Claim("name", "Test User")
};
context.Subject = new ClaimsPrincipal(new ClaimsIdentity(existingClaims));
_userService.GetUserByPrincipalAsync(context.Subject).Returns(user);
_licensingService.ValidateUserPremiumAsync(user).Returns(false);
_currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id)
.Returns(new List<CurrentContextOrganization>());
_currentContext.ProviderMembershipAsync(_providerUserRepository, user.Id)
.Returns(new List<CurrentContextProvider>());
await _sut.GetProfileDataAsync(context);
Assert.DoesNotContain(context.IssuedClaims, issuedClaim => issuedClaim.Type.StartsWith("org"));
Assert.Contains(context.IssuedClaims, issuedClaim => issuedClaim.Type == "email");
Assert.Contains(context.IssuedClaims, issuedClaim => issuedClaim.Type == "name");
}
/// <summary>
/// When IdentityServer issues a new access token or services a UserInfo request for a given user,
/// re-evaluate the claims for that user to ensure freshness.
/// Existing claims should always be updated, even if their type exists in the incoming collection.
/// </summary>
[Theory]
[BitAutoData(BitwardenClient.Web)]
[BitAutoData(BitwardenClient.Browser)]
[BitAutoData(BitwardenClient.Cli)]
[BitAutoData(BitwardenClient.Desktop)]
[BitAutoData(BitwardenClient.Mobile)]
[BitAutoData(BitwardenClient.DirectoryConnector)]
public async Task GetProfileDataAsync_NewClaimsOverrideExistingNonOrgClaims(
string client,
[AuthFixtures.ProfileDataRequestContext]
ProfileDataRequestContext context,
User user)
{
context.Client.ClientId = client;
user.Id = Guid.Parse(context.Subject.FindFirst("sub").Value);
user.Email = "new@example.com";
var existingClaims = new[]
{
new Claim("sub", user.Id.ToString()), new Claim("email", "old@example.com"),
new Claim(Claims.Premium, "false")
};
context.Subject = new ClaimsPrincipal(new ClaimsIdentity(existingClaims));
_userService.GetUserByPrincipalAsync(context.Subject).Returns(user);
_licensingService.ValidateUserPremiumAsync(user).Returns(true);
_currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id)
.Returns(new List<CurrentContextOrganization>());
_currentContext.ProviderMembershipAsync(_providerUserRepository, user.Id)
.Returns(new List<CurrentContextProvider>());
await _sut.GetProfileDataAsync(context);
// Should have new premium claim, not old one
Assert.Contains(context.IssuedClaims,
issuedClaim => issuedClaim.Type == Claims.Premium &&
issuedClaim.Value.Equals("true", StringComparison.CurrentCultureIgnoreCase));
Assert.DoesNotContain(context.IssuedClaims,
issuedClaim => issuedClaim.Type == Claims.Premium &&
issuedClaim.Value.Equals("false", StringComparison.CurrentCultureIgnoreCase));
// Should have new email
Assert.Contains(context.IssuedClaims,
issuedClaim => issuedClaim.Type == "email" && issuedClaim.Value == "new@example.com");
Assert.DoesNotContain(context.IssuedClaims,
issuedClaim => issuedClaim.Type == "email" && issuedClaim.Value == "old@example.com");
}
/// <summary>
/// Users may belong to multiple organizations. Claims should be properly scoped to each relevant organization
/// and not cross-pollinate claims across organizations, and should be fetched fresh on each request.
/// </summary>
/// <seealso cref="Bit.Core.Context.ICurrentContext.OrganizationMembershipAsync" />
[Theory]
[BitAutoData(BitwardenClient.Web)]
[BitAutoData(BitwardenClient.Browser)]
[BitAutoData(BitwardenClient.Cli)]
[BitAutoData(BitwardenClient.Desktop)]
[BitAutoData(BitwardenClient.Mobile)]
[BitAutoData(BitwardenClient.DirectoryConnector)]
public async Task GetProfileDataAsync_WithMultipleOrganizations_IncludesOrgClaims(
string client,
[AuthFixtures.ProfileDataRequestContext]
ProfileDataRequestContext context,
User user)
{
context.Client.ClientId = client;
user.Id = Guid.Parse(context.Subject.FindFirst("sub").Value);
var orgId1 = Guid.NewGuid();
var orgId2 = Guid.NewGuid();
var orgMemberships = new List<CurrentContextOrganization>
{
new() { Id = orgId1, Type = OrganizationUserType.Owner },
new() { Id = orgId2, Type = OrganizationUserType.Admin }
};
_userService.GetUserByPrincipalAsync(context.Subject).Returns(user);
_licensingService.ValidateUserPremiumAsync(user).Returns(false);
_currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id)
.Returns(orgMemberships);
_currentContext.ProviderMembershipAsync(_providerUserRepository, user.Id)
.Returns(new List<CurrentContextProvider>());
await _sut.GetProfileDataAsync(context);
Assert.Contains(context.IssuedClaims,
issuedClaim => issuedClaim.Type == Claims.OrganizationOwner && issuedClaim.Value == orgId1.ToString());
Assert.Contains(context.IssuedClaims,
issuedClaim => issuedClaim.Type == Claims.OrganizationAdmin && issuedClaim.Value == orgId2.ToString());
}
/// <summary>
/// Users may belong to providers. Claims should be properly scoped to each relevant provider
/// and not cross-pollinate claims across providers, and should be fetched fresh on each request.
/// </summary>
/// <seealso cref="Bit.Core.Context.ICurrentContext.ProviderMembershipAsync" />
[Theory]
[BitAutoData(BitwardenClient.Web)]
[BitAutoData(BitwardenClient.Browser)]
[BitAutoData(BitwardenClient.Cli)]
[BitAutoData(BitwardenClient.Desktop)]
[BitAutoData(BitwardenClient.Mobile)]
[BitAutoData(BitwardenClient.DirectoryConnector)]
public async Task GetProfileDataAsync_WithProviders_IncludesProviderClaims(
string client,
[AuthFixtures.ProfileDataRequestContext]
ProfileDataRequestContext context,
User user)
{
context.Client.ClientId = client;
user.Id = Guid.Parse(context.Subject.FindFirst("sub").Value);
var providerId = Guid.NewGuid();
var providerMemberships = new List<CurrentContextProvider>
{
new() { Id = providerId, Type = ProviderUserType.ProviderAdmin }
};
_userService.GetUserByPrincipalAsync(context.Subject).Returns(user);
_licensingService.ValidateUserPremiumAsync(user).Returns(false);
_currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id)
.Returns(new List<CurrentContextOrganization>());
_currentContext.ProviderMembershipAsync(_providerUserRepository, user.Id)
.Returns(providerMemberships);
await _sut.GetProfileDataAsync(context);
Assert.Contains(context.IssuedClaims, issuedClaim => issuedClaim.Type.StartsWith("provider"));
}
/// <summary>
/// Evaluates the happy path for the core session invalidation mechanism.
/// Critical events (e.g., password change) update the security stamp, and any subsequent request through
/// this service should expose the stamp as invalid. A found user and matching security stamp
/// prove out an active session.
/// </summary>
[Theory]
[BitAutoData(BitwardenClient.Web)]
[BitAutoData(BitwardenClient.Browser)]
[BitAutoData(BitwardenClient.Cli)]
[BitAutoData(BitwardenClient.Desktop)]
[BitAutoData(BitwardenClient.Mobile)]
[BitAutoData(BitwardenClient.DirectoryConnector)]
public async Task IsActiveAsync_SecurityStampMatches_ReturnsTrue(
string client,
[AuthFixtures.IsActiveContext] IsActiveContext context,
User user)
{
context.Client.ClientId = client;
var securityStamp = "matching-security-stamp";
user.SecurityStamp = securityStamp;
context.Subject = new ClaimsPrincipal(new ClaimsIdentity([
new Claim("sub", user.Id.ToString()),
new Claim(Claims.SecurityStamp, securityStamp)
]));
_userService.GetUserByPrincipalAsync(context.Subject).Returns(user);
await _sut.IsActiveAsync(context);
Assert.True(context.IsActive);
}
/// <summary>
/// Critical events (e.g., password change) update the security stamp, and any subsequent request through
/// this service should expose the stamp as invalid.
/// See also examples for stamp invalidation (non-exhaustive):
/// </summary>
/// <seealso cref="Bit.Core.KeyManagement.UserKey.Implementations.RotateUserAccountKeysCommand.RotateUserAccountKeysAsync"/>
/// <seealso cref="Bit.Core.Services.UserService.ChangePasswordAsync"/>
/// <seealso cref="Bit.Core.Services.UserService.UpdatePasswordHash"/>
[Theory]
[BitAutoData(BitwardenClient.Web)]
[BitAutoData(BitwardenClient.Browser)]
[BitAutoData(BitwardenClient.Cli)]
[BitAutoData(BitwardenClient.Desktop)]
[BitAutoData(BitwardenClient.Mobile)]
[BitAutoData(BitwardenClient.DirectoryConnector)]
public async Task IsActiveAsync_SecurityStampDoesNotMatch_ReturnsFalse(
string client,
[AuthFixtures.IsActiveContext] IsActiveContext context,
User user)
{
context.Client.ClientId = client;
user.SecurityStamp = "current-security-stamp";
context.Subject = new ClaimsPrincipal(new ClaimsIdentity([
new Claim("sub", user.Id.ToString()),
new Claim(Claims.SecurityStamp, "old-security-stamp")
]));
_userService.GetUserByPrincipalAsync(context.Subject).Returns(user);
await _sut.IsActiveAsync(context);
Assert.False(context.IsActive);
}
/// <summary>
/// Because security stamps are GUIDs, and database collations, etc., might treat case differently,
/// a case-insensitive comparison is sufficient for proving the match of a security stamp.
/// </summary>
[Theory]
[BitAutoData(BitwardenClient.Web, "CuRrEnT-StAmP")]
[BitAutoData(BitwardenClient.Browser, "CuRrEnT-StAmP")]
[BitAutoData(BitwardenClient.Cli, "CuRrEnT-StAmP")]
[BitAutoData(BitwardenClient.Desktop, "CuRrEnT-StAmP")]
[BitAutoData(BitwardenClient.Mobile, "CuRrEnT-StAmP")]
[BitAutoData(BitwardenClient.DirectoryConnector, "CuRrEnT-StAmP")]
public async Task IsActiveAsync_SecurityStampComparison_IsCaseInsensitive(
string client,
string claimStamp,
[AuthFixtures.IsActiveContext] IsActiveContext context,
User user)
{
context.Client.ClientId = client;
user.SecurityStamp = "current-stamp";
context.Subject = new ClaimsPrincipal(new ClaimsIdentity([
new Claim("sub", user.Id.ToString()),
new Claim(Claims.SecurityStamp, claimStamp)
]));
_userService.GetUserByPrincipalAsync(context.Subject).Returns(user);
await _sut.IsActiveAsync(context);
Assert.True(context.IsActive);
}
/// <summary>
/// Security stamps should be evaluated when present, but should not always be expected to be present.
/// Given a successful user lookup, absent a security stamp, the session is treated as active.
/// Only if the stamp is presented on context claims should it be validated.
/// </summary>
[Theory]
[BitAutoData(BitwardenClient.Web)]
[BitAutoData(BitwardenClient.Browser)]
[BitAutoData(BitwardenClient.Cli)]
[BitAutoData(BitwardenClient.Desktop)]
[BitAutoData(BitwardenClient.Mobile)]
[BitAutoData(BitwardenClient.DirectoryConnector)]
public async Task IsActiveAsync_UserExistsButNoSecurityStampClaim_ReturnsTrue(
string client,
[AuthFixtures.IsActiveContext] IsActiveContext context,
User user)
{
context.Client.ClientId = client;
context.Subject = new ClaimsPrincipal(new ClaimsIdentity([
new Claim("sub", user.Id.ToString()),
new Claim("email", user.Email)
]));
_userService.GetUserByPrincipalAsync(context.Subject).Returns(user);
await _sut.IsActiveAsync(context);
Assert.True(context.IsActive);
}
}

View File

@@ -265,9 +265,10 @@ public class SendEmailOtpRequestValidatorTests
// Arrange
var otpTokenProvider = Substitute.For<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>();
var mailService = Substitute.For<IMailService>();
var featureService = Substitute.For<IFeatureService>();
// Act
var validator = new SendEmailOtpRequestValidator(otpTokenProvider, mailService);
var validator = new SendEmailOtpRequestValidator(featureService, otpTokenProvider, mailService);
// Assert
Assert.NotNull(validator);

View File

@@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Repositories;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
@@ -64,7 +65,8 @@ IBaseRequestValidatorTestWrapper
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IPolicyRequirementQuery policyRequirementQuery,
IAuthRequestRepository authRequestRepository,
IMailService mailService) :
IMailService mailService,
IUserAccountKeysQuery userAccountKeysQuery) :
base(
userManager,
userService,
@@ -82,7 +84,8 @@ IBaseRequestValidatorTestWrapper
userDecryptionOptionsBuilder,
policyRequirementQuery,
authRequestRepository,
mailService)
mailService,
userAccountKeysQuery)
{
}

View File

@@ -92,6 +92,7 @@ public class EfRepositoryListBuilder<T> : ISpecimenBuilder where T : BaseEntityF
cfg.AddProfile<TransactionMapperProfile>();
cfg.AddProfile<UserMapperProfile>();
cfg.AddProfile<PasswordHealthReportApplicationProfile>();
cfg.AddProfile<UserSignatureKeyPairMapperProfile>();
cfg.AddProfile<OrganizationReportProfile>();
})
.CreateMapper()));

View File

@@ -29,7 +29,8 @@ public class UserCompare : IEqualityComparer<User>
x.LicenseKey == y.LicenseKey &&
x.ApiKey == y.ApiKey &&
x.Kdf == y.Kdf &&
x.KdfIterations == y.KdfIterations;
x.KdfIterations == y.KdfIterations &&
x.SignedPublicKey == y.SignedPublicKey;
}
public int GetHashCode([DisallowNull] User obj)

View File

@@ -69,6 +69,42 @@ public static class OrganizationTestHelpers
Type = OrganizationUserType.Owner
});
public static Task<OrganizationUser> CreateAcceptedTestOrganizationUserAsync(
this IOrganizationUserRepository organizationUserRepository,
Organization organization,
User user)
=> organizationUserRepository.CreateAsync(new OrganizationUser
{
OrganizationId = organization.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Accepted,
Type = OrganizationUserType.Owner
});
public static Task<OrganizationUser> CreateRevokedTestOrganizationUserAsync(
this IOrganizationUserRepository organizationUserRepository,
Organization organization,
User user)
=> organizationUserRepository.CreateAsync(new OrganizationUser
{
OrganizationId = organization.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Revoked,
Type = OrganizationUserType.Owner
});
public static Task<OrganizationUser> CreateConfirmedTestOrganizationUserAsync(
this IOrganizationUserRepository organizationUserRepository,
Organization organization,
User user)
=> organizationUserRepository.CreateAsync(new OrganizationUser
{
OrganizationId = organization.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.Owner
});
public static Task<Group> CreateTestGroupAsync(
this IGroupRepository groupRepository,
Organization organization,
@@ -81,9 +117,9 @@ public static class OrganizationTestHelpers
this ICollectionRepository collectionRepository,
Organization organization,
string identifier = "test")
=> collectionRepository.CreateAsync(new Collection
{
OrganizationId = organization.Id,
Name = $"{identifier} {Guid.NewGuid()}"
});
=> collectionRepository.CreateAsync(new Collection
{
OrganizationId = organization.Id,
Name = $"{identifier} {Guid.NewGuid()}"
});
}

View File

@@ -0,0 +1,447 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Xunit;
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.OrganizationUserRepository;
public class GetByUserIdWithPolicyDetailsTests
{
[Theory, DatabaseData]
public async Task GetByUserIdWithPolicyDetailsAsync_WithConfirmedUser_ReturnsPolicy(
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository)
{
// Arrange
var user = await userRepository.CreateTestUserAsync();
var org = await organizationRepository.CreateAsync(new Organization
{
Name = "Test Org",
BillingEmail = "billing@example.com",
Plan = "Test",
});
var orgUser = new OrganizationUser
{
OrganizationId = org.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.User,
Email = null
};
await organizationUserRepository.CreateAsync(orgUser);
await policyRepository.CreateAsync(new Policy
{
OrganizationId = org.Id,
Enabled = true,
Type = PolicyType.SingleOrg,
Data = CoreHelpers.ClassToJsonData(new { Setting = "value" })
});
// Act
var result = await organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(user.Id, PolicyType.SingleOrg);
// Assert
var policyDetails = result.Single();
Assert.Equal(orgUser.Id, policyDetails.OrganizationUserId);
Assert.Equal(org.Id, policyDetails.OrganizationId);
Assert.Equal(PolicyType.SingleOrg, policyDetails.PolicyType);
Assert.True(policyDetails.PolicyEnabled);
Assert.Equal(OrganizationUserType.User, policyDetails.OrganizationUserType);
Assert.Equal(OrganizationUserStatusType.Confirmed, policyDetails.OrganizationUserStatus);
Assert.False(policyDetails.IsProvider);
}
[Theory, DatabaseData]
public async Task GetByUserIdWithPolicyDetailsAsync_WithAcceptedUser_ReturnsPolicy(
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository)
{
// Arrange
var user = await userRepository.CreateTestUserAsync();
var org = await organizationRepository.CreateAsync(new Organization
{
Name = "Test Org",
BillingEmail = "billing@example.com",
Plan = "Test",
});
var orgUser = new OrganizationUser
{
OrganizationId = org.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Accepted,
Type = OrganizationUserType.Admin,
Email = null
};
await organizationUserRepository.CreateAsync(orgUser);
await policyRepository.CreateAsync(new Policy
{
OrganizationId = org.Id,
Enabled = false, // Note: disabled policy
Type = PolicyType.RequireSso,
});
// Act
var result = await organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(user.Id, PolicyType.RequireSso);
// Assert
var policyDetails = result.Single();
Assert.Equal(orgUser.Id, policyDetails.OrganizationUserId);
Assert.False(policyDetails.PolicyEnabled); // Should return even if disabled
}
[Theory, DatabaseData]
public async Task GetByUserIdWithPolicyDetailsAsync_WithInvitedUser_ReturnsPolicy(
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository)
{
// Arrange
var user = await userRepository.CreateTestUserAsync();
var org = await organizationRepository.CreateAsync(new Organization
{
Name = "Test Org",
BillingEmail = "billing@example.com",
Plan = "Test",
});
var orgUser = new OrganizationUser
{
OrganizationId = org.Id,
UserId = null, // invited users have null userId
Status = OrganizationUserStatusType.Invited,
Type = OrganizationUserType.User,
Email = user.Email // invited users have matching Email
};
await organizationUserRepository.CreateAsync(orgUser);
await policyRepository.CreateAsync(new Policy
{
OrganizationId = org.Id,
Enabled = true,
Type = PolicyType.TwoFactorAuthentication,
});
// Act
var result = await organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(user.Id, PolicyType.TwoFactorAuthentication);
// Assert
var policyDetails = result.Single();
Assert.Equal(orgUser.Id, policyDetails.OrganizationUserId);
Assert.Equal(OrganizationUserStatusType.Invited, policyDetails.OrganizationUserStatus);
}
[Theory, DatabaseData]
public async Task GetByUserIdWithPolicyDetailsAsync_WithRevokedUser_ReturnsPolicy(
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository)
{
// Arrange
var user = await userRepository.CreateTestUserAsync();
var org = await organizationRepository.CreateAsync(new Organization
{
Name = "Test Org",
BillingEmail = "billing@example.com",
Plan = "Test",
});
var orgUser = new OrganizationUser
{
OrganizationId = org.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Revoked,
Type = OrganizationUserType.Owner,
Email = null
};
await organizationUserRepository.CreateAsync(orgUser);
await policyRepository.CreateAsync(new Policy
{
OrganizationId = org.Id,
Enabled = true,
Type = PolicyType.SingleOrg,
});
// Act
var result = await organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(user.Id, PolicyType.SingleOrg);
// Assert
var policyDetails = result.Single();
Assert.Equal(OrganizationUserStatusType.Revoked, policyDetails.OrganizationUserStatus);
}
[Theory, DatabaseData]
public async Task GetByUserIdWithPolicyDetailsAsync_WithMultipleOrganizations_ReturnsAllMatchingPolicies(
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository)
{
// Arrange
var user = await userRepository.CreateTestUserAsync();
// Org1 with SingleOrg policy
var org1 = await organizationRepository.CreateAsync(new Organization
{
Name = "Org 1",
BillingEmail = "billing@example.com",
Plan = "Test",
});
var orgUser1 = new OrganizationUser
{
OrganizationId = org1.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.User,
};
await organizationUserRepository.CreateAsync(orgUser1);
await policyRepository.CreateAsync(new Policy
{
OrganizationId = org1.Id,
Enabled = true,
Type = PolicyType.SingleOrg,
});
// Org2 with SingleOrg policy
var org2 = await organizationRepository.CreateAsync(new Organization
{
Name = "Org 2",
BillingEmail = "billing2@example.com",
Plan = "Test",
});
var orgUser2 = new OrganizationUser
{
OrganizationId = org2.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.Admin,
};
await organizationUserRepository.CreateAsync(orgUser2);
await policyRepository.CreateAsync(new Policy
{
OrganizationId = org2.Id,
Enabled = true,
Type = PolicyType.SingleOrg,
});
// Org3 with RequireSso policy (different type - should not be returned)
var org3 = await organizationRepository.CreateAsync(new Organization
{
Name = "Org 3",
BillingEmail = "billing3@example.com",
Plan = "Test",
});
var orgUser3 = new OrganizationUser
{
OrganizationId = org3.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.Owner,
};
await organizationUserRepository.CreateAsync(orgUser3);
await policyRepository.CreateAsync(new Policy
{
OrganizationId = org3.Id,
Enabled = true,
Type = PolicyType.RequireSso,
});
// Act
var result = (await organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(user.Id, PolicyType.SingleOrg)).ToList();
// Assert - should only get 2 policies (org1 and org2)
Assert.Equal(2, result.Count);
Assert.Contains(result, p => p.OrganizationId == org1.Id && p.OrganizationUserType == OrganizationUserType.User);
Assert.Contains(result, p => p.OrganizationId == org2.Id && p.OrganizationUserType == OrganizationUserType.Admin);
Assert.DoesNotContain(result, p => p.OrganizationId == org3.Id);
}
[Theory, DatabaseData]
public async Task GetByUserIdWithPolicyDetailsAsync_WithNonExistingPolicyType_ReturnsEmpty(
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository)
{
// Arrange
var user = await userRepository.CreateTestUserAsync();
var org = await organizationRepository.CreateAsync(new Organization
{
Name = "Test Org",
BillingEmail = "billing@example.com",
Plan = "Test",
});
await organizationUserRepository.CreateTestOrganizationUserAsync(org, user);
await policyRepository.CreateAsync(new Policy
{
OrganizationId = org.Id,
Enabled = true,
Type = PolicyType.SingleOrg,
});
// Act
var result = await organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(user.Id, PolicyType.RequireSso);
// Assert
Assert.Empty(result);
}
[Theory, DatabaseData]
public async Task GetByUserIdWithPolicyDetailsAsync_WithProviderUser_ReturnsIsProviderTrue(
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository,
IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository,
IProviderOrganizationRepository providerOrganizationRepository)
{
// Arrange
var user = await userRepository.CreateTestUserAsync();
var org = await organizationRepository.CreateAsync(new Organization
{
Name = "Test Org",
BillingEmail = "billing@example.com",
Plan = "Test",
});
var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(org, user);
await policyRepository.CreateAsync(new Policy
{
OrganizationId = org.Id,
Enabled = true,
Type = PolicyType.SingleOrg,
});
var provider = await providerRepository.CreateAsync(new Provider
{
Name = Guid.NewGuid().ToString(),
Enabled = true
});
await providerUserRepository.CreateAsync(new ProviderUser
{
ProviderId = provider.Id,
UserId = user.Id,
Status = ProviderUserStatusType.Confirmed
});
await providerOrganizationRepository.CreateAsync(new ProviderOrganization
{
OrganizationId = org.Id,
ProviderId = provider.Id
});
// Act
var result = await organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(user.Id, PolicyType.SingleOrg);
// Assert
var policyDetails = result.Single();
Assert.True(policyDetails.IsProvider);
}
[Theory, DatabaseData]
public async Task GetByUserIdWithPolicyDetailsAsync_WithCustomUserWithPermissions_ReturnsPermissions(
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository)
{
// Arrange
var user = await userRepository.CreateTestUserAsync();
var org = await organizationRepository.CreateAsync(new Organization
{
Name = "Test Org",
BillingEmail = "billing@example.com",
Plan = "Test",
});
var orgUser = new OrganizationUser
{
OrganizationId = org.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.Custom,
Email = null
};
orgUser.SetPermissions(new Permissions
{
ManagePolicies = true,
EditAnyCollection = true
});
await organizationUserRepository.CreateAsync(orgUser);
await policyRepository.CreateAsync(new Policy
{
OrganizationId = org.Id,
Enabled = true,
Type = PolicyType.SingleOrg,
});
// Act
var result = await organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(user.Id, PolicyType.SingleOrg);
// Assert
var policyDetails = result.Single();
Assert.NotNull(policyDetails.OrganizationUserPermissionsData);
var permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(policyDetails.OrganizationUserPermissionsData);
Assert.True(permissions.ManagePolicies);
Assert.True(permissions.EditAnyCollection);
}
[Theory, DatabaseData]
public async Task GetByUserIdWithPolicyDetailsAsync_WhenNoPolicyExists_ReturnsEmpty(
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository)
{
// Arrange
var user = await userRepository.CreateTestUserAsync();
var org = await organizationRepository.CreateAsync(new Organization
{
Name = "Test Org",
BillingEmail = "billing@example.com",
Plan = "Test",
});
await organizationUserRepository.CreateTestOrganizationUserAsync(org, user);
// Act
var result = await organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(user.Id, PolicyType.SingleOrg);
// Assert
Assert.Empty(result);
}
[Theory, DatabaseData]
public async Task GetByUserIdWithPolicyDetailsAsync_WhenUserNotInOrg_ReturnsEmpty(
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository)
{
// Arrange
var user = await userRepository.CreateTestUserAsync();
var org = await organizationRepository.CreateAsync(new Organization
{
Name = "Test Org",
BillingEmail = "billing@example.com",
Plan = "Test",
});
await policyRepository.CreateAsync(new Policy
{
OrganizationId = org.Id,
Enabled = true,
Type = PolicyType.SingleOrg,
});
// Act
var result = await organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(user.Id, PolicyType.SingleOrg);
// Assert
Assert.Empty(result);
}
}

View File

@@ -1417,4 +1417,146 @@ public class OrganizationUserRepositoryTests
// Regular collection should be removed
Assert.DoesNotContain(actualCollections, c => c.Id == regularCollection.Id);
}
[Theory, DatabaseData]
public async Task ConfirmOrganizationUserAsync_WhenUserIsAccepted_ReturnsTrue(IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IUserRepository userRepository)
{
// Arrange
var organization = await organizationRepository.CreateTestOrganizationAsync();
var user = await userRepository.CreateTestUserAsync();
var orgUser = await organizationUserRepository.CreateAcceptedTestOrganizationUserAsync(organization, user);
// Act
var result = await organizationUserRepository.ConfirmOrganizationUserAsync(orgUser);
// Assert
Assert.True(result);
var updatedUser = await organizationUserRepository.GetByIdAsync(orgUser.Id);
Assert.NotNull(updatedUser);
Assert.Equal(OrganizationUserStatusType.Confirmed, updatedUser.Status);
// Annul
await organizationRepository.DeleteAsync(organization);
await userRepository.DeleteAsync(user);
}
[Theory, DatabaseData]
public async Task ConfirmOrganizationUserAsync_WhenUserIsInvited_ReturnsFalse(IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository)
{
// Arrange
var organization = await organizationRepository.CreateTestOrganizationAsync();
var orgUser = await organizationUserRepository.CreateTestOrganizationUserInviteAsync(organization);
// Act
var result = await organizationUserRepository.ConfirmOrganizationUserAsync(orgUser);
// Assert
Assert.False(result);
var unchangedUser = await organizationUserRepository.GetByIdAsync(orgUser.Id);
Assert.NotNull(unchangedUser);
Assert.Equal(OrganizationUserStatusType.Invited, unchangedUser.Status);
// Annul
await organizationRepository.DeleteAsync(organization);
}
[Theory, DatabaseData]
public async Task ConfirmOrganizationUserAsync_WhenUserIsAlreadyConfirmed_ReturnsFalse(IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IUserRepository userRepository)
{
// Arrange
var organization = await organizationRepository.CreateTestOrganizationAsync();
var user = await userRepository.CreateTestUserAsync();
var orgUser = await organizationUserRepository.CreateConfirmedTestOrganizationUserAsync(organization, user);
// Act
var result = await organizationUserRepository.ConfirmOrganizationUserAsync(orgUser);
// Assert
Assert.False(result);
var unchangedUser = await organizationUserRepository.GetByIdAsync(orgUser.Id);
Assert.NotNull(unchangedUser);
Assert.Equal(OrganizationUserStatusType.Confirmed, unchangedUser.Status);
// Annul
await organizationRepository.DeleteAsync(organization);
await userRepository.DeleteAsync(user);
}
[Theory, DatabaseData]
public async Task ConfirmOrganizationUserAsync_WhenUserIsRevoked_ReturnsFalse(IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IUserRepository userRepository)
{
// Arrange
var organization = await organizationRepository.CreateTestOrganizationAsync();
var user = await userRepository.CreateTestUserAsync();
var orgUser = await organizationUserRepository.CreateRevokedTestOrganizationUserAsync(organization, user);
// Act
var result = await organizationUserRepository.ConfirmOrganizationUserAsync(orgUser);
// Assert
Assert.False(result);
var unchangedUser = await organizationUserRepository.GetByIdAsync(orgUser.Id);
Assert.NotNull(unchangedUser);
Assert.Equal(OrganizationUserStatusType.Revoked, unchangedUser.Status);
// Annul
await organizationRepository.DeleteAsync(organization);
await userRepository.DeleteAsync(user);
}
[Theory, DatabaseData]
public async Task ConfirmOrganizationUserAsync_IsIdempotent_WhenCalledMultipleTimes(
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IUserRepository userRepository)
{
// Arrange
var organization = await organizationRepository.CreateTestOrganizationAsync();
var user = await userRepository.CreateTestUserAsync();
var orgUser = await organizationUserRepository.CreateAcceptedTestOrganizationUserAsync(organization, user);
// Act - First call should confirm
var firstResult = await organizationUserRepository.ConfirmOrganizationUserAsync(orgUser);
var secondResult = await organizationUserRepository.ConfirmOrganizationUserAsync(orgUser);
// Assert
Assert.True(firstResult);
Assert.False(secondResult);
var finalUser = await organizationUserRepository.GetByIdAsync(orgUser.Id);
Assert.NotNull(finalUser);
Assert.Equal(OrganizationUserStatusType.Confirmed, finalUser.Status);
// Annul
await organizationRepository.DeleteAsync(organization);
await userRepository.DeleteAsync(user);
}
[Theory, DatabaseData]
public async Task ConfirmOrganizationUserAsync_WhenUserDoesNotExist_ReturnsFalse(
IOrganizationUserRepository organizationUserRepository)
{
// Arrange
var nonExistentUser = new OrganizationUser
{
Id = Guid.NewGuid(),
OrganizationId = Guid.NewGuid(),
UserId = Guid.NewGuid(),
Email = "nonexistent@bitwarden.com",
Status = OrganizationUserStatusType.Accepted,
Type = OrganizationUserType.Owner
};
// Act
var result = await organizationUserRepository.ConfirmOrganizationUserAsync(nonExistentUser);
// Assert
Assert.False(result);
}
}

View File

@@ -1,385 +0,0 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Xunit;
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.PolicyRepository;
public class GetPolicyDetailsByUserIdTests
{
[Theory, DatabaseData]
public async Task GetPolicyDetailsByUserId_NonInvitedUsers_Works(
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository)
{
// Arrange
// OrgUser1 - owner of org1 - confirmed
var user = await userRepository.CreateTestUserAsync();
var org1 = await CreateEnterpriseOrg(organizationRepository);
var orgUser1 = new OrganizationUser
{
OrganizationId = org1.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.Owner,
Email = null // confirmed OrgUsers use the email on the User table
};
await organizationUserRepository.CreateAsync(orgUser1);
await policyRepository.CreateAsync(new Policy
{
OrganizationId = org1.Id,
Enabled = true,
Type = PolicyType.SingleOrg,
Data = CoreHelpers.ClassToJsonData(new TestPolicyData { BoolSetting = true, IntSetting = 5 })
});
// OrgUser2 - custom user of org2 - accepted
var org2 = await CreateEnterpriseOrg(organizationRepository);
var orgUser2 = new OrganizationUser
{
OrganizationId = org2.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Accepted,
Type = OrganizationUserType.Custom,
Email = null // accepted OrgUsers use the email on the User table
};
orgUser2.SetPermissions(new Permissions
{
ManagePolicies = true
});
await organizationUserRepository.CreateAsync(orgUser2);
await policyRepository.CreateAsync(new Policy
{
OrganizationId = org2.Id,
Enabled = true,
Type = PolicyType.SingleOrg,
Data = CoreHelpers.ClassToJsonData(new TestPolicyData { BoolSetting = false, IntSetting = 15 })
});
// Act
var policyDetails = (await policyRepository.GetPolicyDetailsByUserId(user.Id)).ToList();
// Assert
Assert.Equal(2, policyDetails.Count);
var actualPolicyDetails1 = policyDetails.Find(p => p.OrganizationUserId == orgUser1.Id);
var expectedPolicyDetails1 = new PolicyDetails
{
OrganizationUserId = orgUser1.Id,
OrganizationId = org1.Id,
PolicyType = PolicyType.SingleOrg,
PolicyData = CoreHelpers.ClassToJsonData(new TestPolicyData { BoolSetting = true, IntSetting = 5 }),
OrganizationUserType = OrganizationUserType.Owner,
OrganizationUserStatus = OrganizationUserStatusType.Confirmed,
OrganizationUserPermissionsData = null,
IsProvider = false
};
Assert.Equivalent(expectedPolicyDetails1, actualPolicyDetails1);
Assert.Equivalent(expectedPolicyDetails1.GetDataModel<TestPolicyData>(), new TestPolicyData { BoolSetting = true, IntSetting = 5 });
var actualPolicyDetails2 = policyDetails.Find(p => p.OrganizationUserId == orgUser2.Id);
var expectedPolicyDetails2 = new PolicyDetails
{
OrganizationUserId = orgUser2.Id,
OrganizationId = org2.Id,
PolicyType = PolicyType.SingleOrg,
PolicyData = CoreHelpers.ClassToJsonData(new TestPolicyData { BoolSetting = false, IntSetting = 15 }),
OrganizationUserType = OrganizationUserType.Custom,
OrganizationUserStatus = OrganizationUserStatusType.Accepted,
OrganizationUserPermissionsData = CoreHelpers.ClassToJsonData(new Permissions { ManagePolicies = true }),
IsProvider = false
};
Assert.Equivalent(expectedPolicyDetails2, actualPolicyDetails2);
Assert.Equivalent(expectedPolicyDetails2.GetDataModel<TestPolicyData>(), new TestPolicyData { BoolSetting = false, IntSetting = 15 });
Assert.Equivalent(new Permissions { ManagePolicies = true }, actualPolicyDetails2.GetOrganizationUserCustomPermissions(), strict: true);
}
[Theory, DatabaseData]
public async Task GetPolicyDetailsByUserId_InvitedUser_Works(
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository)
{
// Arrange
var user = await userRepository.CreateTestUserAsync();
var org = await CreateEnterpriseOrg(organizationRepository);
var orgUser = new OrganizationUser
{
OrganizationId = org.Id,
UserId = null, // invited users have null userId
Status = OrganizationUserStatusType.Invited,
Type = OrganizationUserType.Custom,
Email = user.Email // invited users have matching Email
};
await organizationUserRepository.CreateAsync(orgUser);
await policyRepository.CreateAsync(new Policy
{
OrganizationId = org.Id,
Enabled = true,
Type = PolicyType.SingleOrg,
});
// Act
var actualPolicyDetails = await policyRepository.GetPolicyDetailsByUserId(user.Id);
// Assert
var expectedPolicyDetails = new PolicyDetails
{
OrganizationUserId = orgUser.Id,
OrganizationId = org.Id,
PolicyType = PolicyType.SingleOrg,
OrganizationUserType = OrganizationUserType.Custom,
OrganizationUserStatus = OrganizationUserStatusType.Invited,
IsProvider = false
};
Assert.Equivalent(expectedPolicyDetails, actualPolicyDetails.Single());
}
[Theory, DatabaseData]
public async Task GetPolicyDetailsByUserId_RevokedConfirmedUser_Works(
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository)
{
// Arrange
var user = await userRepository.CreateTestUserAsync();
var org = await CreateEnterpriseOrg(organizationRepository);
// User has been confirmed to the org but then revoked
var orgUser = new OrganizationUser
{
OrganizationId = org.Id,
UserId = user.Id,
Status = OrganizationUserStatusType.Revoked,
Type = OrganizationUserType.Owner,
Email = null
};
await organizationUserRepository.CreateAsync(orgUser);
await policyRepository.CreateAsync(new Policy
{
OrganizationId = org.Id,
Enabled = true,
Type = PolicyType.SingleOrg,
});
// Act
var actualPolicyDetails = await policyRepository.GetPolicyDetailsByUserId(user.Id);
// Assert
var expectedPolicyDetails = new PolicyDetails
{
OrganizationUserId = orgUser.Id,
OrganizationId = org.Id,
PolicyType = PolicyType.SingleOrg,
OrganizationUserType = OrganizationUserType.Owner,
OrganizationUserStatus = OrganizationUserStatusType.Revoked,
IsProvider = false
};
Assert.Equivalent(expectedPolicyDetails, actualPolicyDetails.Single());
}
[Theory, DatabaseData]
public async Task GetPolicyDetailsByUserId_RevokedInvitedUser_DoesntReturnPolicies(
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository)
{
// Arrange
var user = await userRepository.CreateTestUserAsync();
var org = await CreateEnterpriseOrg(organizationRepository);
// User has been invited to the org but then revoked - without ever being confirmed and linked to a user.
// This is an unhandled edge case because those users will go through policy enforcement later,
// as part of accepting their invite after being restored. For now this is just documented as expected behavior.
var orgUser = new OrganizationUser
{
OrganizationId = org.Id,
UserId = null,
Status = OrganizationUserStatusType.Revoked,
Type = OrganizationUserType.Owner,
Email = user.Email
};
await organizationUserRepository.CreateAsync(orgUser);
await policyRepository.CreateAsync(new Policy
{
OrganizationId = org.Id,
Enabled = true,
Type = PolicyType.SingleOrg,
});
// Act
var actualPolicyDetails = await policyRepository.GetPolicyDetailsByUserId(user.Id);
Assert.Empty(actualPolicyDetails);
}
[Theory, DatabaseData]
public async Task GetPolicyDetailsByUserId_SetsIsProvider(
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository,
IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository,
IProviderOrganizationRepository providerOrganizationRepository)
{
// Arrange
var user = await userRepository.CreateTestUserAsync();
var org = await CreateEnterpriseOrg(organizationRepository);
var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(org, user);
await policyRepository.CreateAsync(new Policy
{
OrganizationId = org.Id,
Enabled = true,
Type = PolicyType.SingleOrg,
});
// Arrange provider
var provider = await providerRepository.CreateAsync(new Provider
{
Name = Guid.NewGuid().ToString(),
Enabled = true
});
await providerUserRepository.CreateAsync(new ProviderUser
{
ProviderId = provider.Id,
UserId = user.Id,
Status = ProviderUserStatusType.Confirmed
});
await providerOrganizationRepository.CreateAsync(new ProviderOrganization
{
OrganizationId = org.Id,
ProviderId = provider.Id
});
// Act
var actualPolicyDetails = await policyRepository.GetPolicyDetailsByUserId(user.Id);
// Assert
var expectedPolicyDetails = new PolicyDetails
{
OrganizationUserId = orgUser.Id,
OrganizationId = org.Id,
PolicyType = PolicyType.SingleOrg,
OrganizationUserType = OrganizationUserType.Owner,
OrganizationUserStatus = OrganizationUserStatusType.Confirmed,
IsProvider = true
};
Assert.Equivalent(expectedPolicyDetails, actualPolicyDetails.Single());
}
[Theory, DatabaseData]
public async Task GetPolicyDetailsByUserId_IgnoresDisabledOrganizations(
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository)
{
// Arrange
var user = await userRepository.CreateTestUserAsync();
var org = await CreateEnterpriseOrg(organizationRepository);
await organizationUserRepository.CreateTestOrganizationUserAsync(org, user);
await policyRepository.CreateAsync(new Policy
{
OrganizationId = org.Id,
Enabled = true,
Type = PolicyType.SingleOrg,
});
// Org is disabled; its policies remain, but it is now inactive
org.Enabled = false;
await organizationRepository.ReplaceAsync(org);
// Act
var actualPolicyDetails = await policyRepository.GetPolicyDetailsByUserId(user.Id);
// Assert
Assert.Empty(actualPolicyDetails);
}
[Theory, DatabaseData]
public async Task GetPolicyDetailsByUserId_IgnoresDowngradedOrganizations(
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository)
{
// Arrange
var user = await userRepository.CreateTestUserAsync();
var org = await CreateEnterpriseOrg(organizationRepository);
await organizationUserRepository.CreateTestOrganizationUserAsync(org, user);
await policyRepository.CreateAsync(new Policy
{
OrganizationId = org.Id,
Enabled = true,
Type = PolicyType.SingleOrg,
});
// Org is downgraded; its policies remain but its plan no longer supports them
org.UsePolicies = false;
org.PlanType = PlanType.TeamsAnnually;
await organizationRepository.ReplaceAsync(org);
// Act
var actualPolicyDetails = await policyRepository.GetPolicyDetailsByUserId(user.Id);
// Assert
Assert.Empty(actualPolicyDetails);
}
[Theory, DatabaseData]
public async Task GetPolicyDetailsByUserId_IgnoresDisabledPolicies(
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository)
{
// Arrange
var user = await userRepository.CreateTestUserAsync();
var org = await CreateEnterpriseOrg(organizationRepository);
await organizationUserRepository.CreateTestOrganizationUserAsync(org, user);
await policyRepository.CreateAsync(new Policy
{
OrganizationId = org.Id,
Enabled = false,
Type = PolicyType.SingleOrg,
});
// Act
var actualPolicyDetails = await policyRepository.GetPolicyDetailsByUserId(user.Id);
// Assert
Assert.Empty(actualPolicyDetails);
}
private class TestPolicyData : IPolicyDataModel
{
public bool BoolSetting { get; set; }
public int IntSetting { get; set; }
}
private Task<Organization> CreateEnterpriseOrg(IOrganizationRepository organizationRepository)
=> organizationRepository.CreateAsync(new Organization
{
Name = Guid.NewGuid().ToString(),
BillingEmail = "billing@example.com", // TODO: EF does not enforce this being NOT NULL
Plan = "Test", // TODO: EF does not enforce this being NOT NULl
PlanType = PlanType.EnterpriseAnnually,
UsePolicies = true
});
}

View File

@@ -949,36 +949,7 @@ public class CipherRepositoryTests
}
[DatabaseTheory, DatabaseData]
public async Task UpdateCiphersAsync_Works(ICipherRepository cipherRepository, IUserRepository userRepository)
{
var user = await userRepository.CreateAsync(new User
{
Name = "Test User",
Email = $"test+{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
var cipher1 = await CreatePersonalCipher(user, cipherRepository);
var cipher2 = await CreatePersonalCipher(user, cipherRepository);
cipher1.Type = CipherType.SecureNote;
cipher2.Attachments = "new_attachments";
await cipherRepository.UpdateCiphersAsync(user.Id, [cipher1, cipher2]);
var updatedCipher1 = await cipherRepository.GetByIdAsync(cipher1.Id);
var updatedCipher2 = await cipherRepository.GetByIdAsync(cipher2.Id);
Assert.NotNull(updatedCipher1);
Assert.NotNull(updatedCipher2);
Assert.Equal(CipherType.SecureNote, updatedCipher1.Type);
Assert.Equal("new_attachments", updatedCipher2.Attachments);
}
[DatabaseTheory, DatabaseData]
public async Task CreateAsync_vNext_WithFolders_Works(
public async Task CreateAsync_WithFolders_Works(
IUserRepository userRepository, ICipherRepository cipherRepository, IFolderRepository folderRepository)
{
// Arrange
@@ -996,7 +967,7 @@ public class CipherRepositoryTests
var cipher2 = new Cipher { Id = CoreHelpers.GenerateComb(), Type = CipherType.SecureNote, UserId = user.Id, Data = "" };
// Act
await cipherRepository.CreateAsync_vNext(
await cipherRepository.CreateAsync(
userId: user.Id,
ciphers: [cipher1, cipher2],
folders: [folder1, folder2]);
@@ -1014,7 +985,7 @@ public class CipherRepositoryTests
}
[DatabaseTheory, DatabaseData]
public async Task CreateAsync_vNext_WithCollectionsAndUsers_Works(
public async Task CreateAsync_WithCollectionsAndUsers_Works(
IOrganizationRepository orgRepository,
IOrganizationUserRepository orgUserRepository,
ICollectionRepository collectionRepository,
@@ -1059,7 +1030,7 @@ public class CipherRepositoryTests
};
// Act
await cipherRepository.CreateAsync_vNext(
await cipherRepository.CreateAsync(
ciphers: [cipher],
collections: [collection],
collectionCiphers: [collectionCipher],
@@ -1084,7 +1055,7 @@ public class CipherRepositoryTests
}
[DatabaseTheory, DatabaseData]
public async Task UpdateCiphersAsync_vNext_Works(
public async Task UpdateCiphersAsync_Works(
IUserRepository userRepository, ICipherRepository cipherRepository)
{
// Arrange
@@ -1110,7 +1081,7 @@ public class CipherRepositoryTests
c2.Attachments = expectedNewAttachments;
// Act
await cipherRepository.UpdateCiphersAsync_vNext(user.Id, [c1, c2]);
await cipherRepository.UpdateCiphersAsync(user.Id, [c1, c2]);
// Assert
var updated1 = await cipherRepository.GetByIdAsync(c1.Id);

View File

@@ -0,0 +1,250 @@
#nullable enable
using System.Text.Json;
using Bit.Core.Enums;
using Bit.Core.Models;
using Bit.Core.Test.NotificationCenter.AutoFixture;
using Bit.Core.Utilities;
using Bit.Notifications;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.SignalR;
using NSubstitute;
namespace Notifications.Test;
[SutProviderCustomize]
[NotificationCustomize(false)]
public class HubHelpersTest
{
[Theory]
[BitAutoData]
public async Task SendNotificationToHubAsync_NotificationPushNotificationGlobal_NothingSent(
SutProvider<HubHelpers> sutProvider,
NotificationPushNotification notification,
string contextId, CancellationToken cancellationToke)
{
notification.Global = true;
notification.InstallationId = null;
notification.UserId = null;
notification.OrganizationId = null;
var json = ToNotificationJson(notification, PushType.Notification, contextId);
await sutProvider.Sut.SendNotificationToHubAsync(json, cancellationToke);
sutProvider.GetDependency<IHubContext<NotificationsHub>>().Clients.Received(0).User(Arg.Any<string>());
sutProvider.GetDependency<IHubContext<NotificationsHub>>().Clients.Received(0).Group(Arg.Any<string>());
sutProvider.GetDependency<IHubContext<AnonymousNotificationsHub>>().Clients.Received(0).User(Arg.Any<string>());
sutProvider.GetDependency<IHubContext<AnonymousNotificationsHub>>().Clients.Received(0)
.Group(Arg.Any<string>());
}
[Theory]
[BitAutoData]
public async Task
SendNotificationToHubAsync_NotificationPushNotificationInstallationIdProvidedClientTypeAll_SentToGroupInstallation(
SutProvider<HubHelpers> sutProvider,
NotificationPushNotification notification,
string contextId, CancellationToken cancellationToken)
{
notification.UserId = null;
notification.OrganizationId = null;
notification.ClientType = ClientType.All;
var json = ToNotificationJson(notification, PushType.Notification, contextId);
await sutProvider.Sut.SendNotificationToHubAsync(json, cancellationToken);
sutProvider.GetDependency<IHubContext<NotificationsHub>>().Clients.Received(0).User(Arg.Any<string>());
await sutProvider.GetDependency<IHubContext<NotificationsHub>>().Clients.Received(1)
.Group($"Installation_{notification.InstallationId!.Value.ToString()}")
.Received(1)
.SendCoreAsync("ReceiveMessage", Arg.Is<object?[]>(objects =>
objects.Length == 1 && IsNotificationPushNotificationEqual(notification, objects[0],
PushType.Notification, contextId)),
cancellationToken);
sutProvider.GetDependency<IHubContext<AnonymousNotificationsHub>>().Clients.Received(0).User(Arg.Any<string>());
sutProvider.GetDependency<IHubContext<AnonymousNotificationsHub>>().Clients.Received(0)
.Group(Arg.Any<string>());
}
[Theory]
[BitAutoData(ClientType.Browser)]
[BitAutoData(ClientType.Desktop)]
[BitAutoData(ClientType.Mobile)]
[BitAutoData(ClientType.Web)]
public async Task
SendNotificationToHubAsync_NotificationPushNotificationInstallationIdProvidedClientTypeNotAll_SentToGroupInstallationClientType(
ClientType clientType, SutProvider<HubHelpers> sutProvider,
NotificationPushNotification notification,
string contextId, CancellationToken cancellationToken)
{
notification.UserId = null;
notification.OrganizationId = null;
notification.ClientType = clientType;
var json = ToNotificationJson(notification, PushType.Notification, contextId);
await sutProvider.Sut.SendNotificationToHubAsync(json, cancellationToken);
sutProvider.GetDependency<IHubContext<NotificationsHub>>().Clients.Received(0).User(Arg.Any<string>());
await sutProvider.GetDependency<IHubContext<NotificationsHub>>().Clients.Received(1)
.Group($"Installation_ClientType_{notification.InstallationId!.Value}_{clientType}")
.Received(1)
.SendCoreAsync("ReceiveMessage", Arg.Is<object?[]>(objects =>
objects.Length == 1 && IsNotificationPushNotificationEqual(notification, objects[0],
PushType.Notification, contextId)),
cancellationToken);
sutProvider.GetDependency<IHubContext<AnonymousNotificationsHub>>().Clients.Received(0).User(Arg.Any<string>());
sutProvider.GetDependency<IHubContext<AnonymousNotificationsHub>>().Clients.Received(0)
.Group(Arg.Any<string>());
}
[Theory]
[BitAutoData(false)]
[BitAutoData(true)]
public async Task SendNotificationToHubAsync_NotificationPushNotificationUserIdProvidedClientTypeAll_SentToUser(
bool organizationIdProvided, SutProvider<HubHelpers> sutProvider,
NotificationPushNotification notification,
string contextId, CancellationToken cancellationToken)
{
notification.InstallationId = null;
notification.ClientType = ClientType.All;
if (!organizationIdProvided)
{
notification.OrganizationId = null;
}
var json = ToNotificationJson(notification, PushType.Notification, contextId);
await sutProvider.Sut.SendNotificationToHubAsync(json, cancellationToken);
await sutProvider.GetDependency<IHubContext<NotificationsHub>>().Clients.Received(1)
.User(notification.UserId!.Value.ToString())
.Received(1)
.SendCoreAsync("ReceiveMessage", Arg.Is<object?[]>(objects =>
objects.Length == 1 && IsNotificationPushNotificationEqual(notification, objects[0],
PushType.Notification, contextId)),
cancellationToken);
sutProvider.GetDependency<IHubContext<NotificationsHub>>().Clients.Received(0).Group(Arg.Any<string>());
sutProvider.GetDependency<IHubContext<AnonymousNotificationsHub>>().Clients.Received(0).User(Arg.Any<string>());
sutProvider.GetDependency<IHubContext<AnonymousNotificationsHub>>().Clients.Received(0)
.Group(Arg.Any<string>());
}
[Theory]
[BitAutoData(false, ClientType.Browser)]
[BitAutoData(false, ClientType.Desktop)]
[BitAutoData(false, ClientType.Mobile)]
[BitAutoData(false, ClientType.Web)]
[BitAutoData(true, ClientType.Browser)]
[BitAutoData(true, ClientType.Desktop)]
[BitAutoData(true, ClientType.Mobile)]
[BitAutoData(true, ClientType.Web)]
public async Task
SendNotificationToHubAsync_NotificationPushNotificationUserIdProvidedClientTypeNotAll_SentToGroupUserClientType(
bool organizationIdProvided, ClientType clientType, SutProvider<HubHelpers> sutProvider,
NotificationPushNotification notification,
string contextId, CancellationToken cancellationToken)
{
notification.InstallationId = null;
notification.ClientType = clientType;
if (!organizationIdProvided)
{
notification.OrganizationId = null;
}
var json = ToNotificationJson(notification, PushType.Notification, contextId);
await sutProvider.Sut.SendNotificationToHubAsync(json, cancellationToken);
sutProvider.GetDependency<IHubContext<NotificationsHub>>().Clients.Received(0).User(Arg.Any<string>());
await sutProvider.GetDependency<IHubContext<NotificationsHub>>().Clients.Received(1)
.Group($"UserClientType_{notification.UserId!.Value}_{clientType}")
.Received(1)
.SendCoreAsync("ReceiveMessage", Arg.Is<object?[]>(objects =>
objects.Length == 1 && IsNotificationPushNotificationEqual(notification, objects[0],
PushType.Notification, contextId)),
cancellationToken);
sutProvider.GetDependency<IHubContext<AnonymousNotificationsHub>>().Clients.Received(0).User(Arg.Any<string>());
sutProvider.GetDependency<IHubContext<AnonymousNotificationsHub>>().Clients.Received(0)
.Group(Arg.Any<string>());
}
[Theory]
[BitAutoData]
public async Task
SendNotificationToHubAsync_NotificationPushNotificationOrganizationIdProvidedClientTypeAll_SentToGroupOrganization(
SutProvider<HubHelpers> sutProvider, string contextId,
NotificationPushNotification notification,
CancellationToken cancellationToken)
{
notification.UserId = null;
notification.InstallationId = null;
notification.ClientType = ClientType.All;
var json = ToNotificationJson(notification, PushType.Notification, contextId);
await sutProvider.Sut.SendNotificationToHubAsync(json, cancellationToken);
sutProvider.GetDependency<IHubContext<NotificationsHub>>().Clients.Received(0).User(Arg.Any<string>());
await sutProvider.GetDependency<IHubContext<NotificationsHub>>().Clients.Received(1)
.Group($"Organization_{notification.OrganizationId!.Value}")
.Received(1)
.SendCoreAsync("ReceiveMessage", Arg.Is<object?[]>(objects =>
objects.Length == 1 && IsNotificationPushNotificationEqual(notification, objects[0],
PushType.Notification, contextId)),
cancellationToken);
sutProvider.GetDependency<IHubContext<AnonymousNotificationsHub>>().Clients.Received(0).User(Arg.Any<string>());
sutProvider.GetDependency<IHubContext<AnonymousNotificationsHub>>().Clients.Received(0)
.Group(Arg.Any<string>());
}
[Theory]
[BitAutoData(ClientType.Browser)]
[BitAutoData(ClientType.Desktop)]
[BitAutoData(ClientType.Mobile)]
[BitAutoData(ClientType.Web)]
public async Task
SendNotificationToHubAsync_NotificationPushNotificationOrganizationIdProvidedClientTypeNotAll_SentToGroupOrganizationClientType(
ClientType clientType, SutProvider<HubHelpers> sutProvider, string contextId,
NotificationPushNotification notification,
CancellationToken cancellationToken)
{
notification.UserId = null;
notification.InstallationId = null;
notification.ClientType = clientType;
var json = ToNotificationJson(notification, PushType.Notification, contextId);
await sutProvider.Sut.SendNotificationToHubAsync(json, cancellationToken);
sutProvider.GetDependency<IHubContext<NotificationsHub>>().Clients.Received(0).User(Arg.Any<string>());
await sutProvider.GetDependency<IHubContext<NotificationsHub>>().Clients.Received(1)
.Group($"OrganizationClientType_{notification.OrganizationId!.Value}_{clientType}")
.Received(1)
.SendCoreAsync("ReceiveMessage", Arg.Is<object?[]>(objects =>
objects.Length == 1 && IsNotificationPushNotificationEqual(notification, objects[0],
PushType.Notification, contextId)),
cancellationToken);
sutProvider.GetDependency<IHubContext<AnonymousNotificationsHub>>().Clients.Received(0).User(Arg.Any<string>());
sutProvider.GetDependency<IHubContext<AnonymousNotificationsHub>>().Clients.Received(0)
.Group(Arg.Any<string>());
}
private static string ToNotificationJson(object payload, PushType type, string contextId)
{
var notification = new PushNotificationData<object>(type, payload, contextId);
return JsonSerializer.Serialize(notification, JsonHelpers.IgnoreWritingNull);
}
private static bool IsNotificationPushNotificationEqual(NotificationPushNotification expected, object? actual,
PushType type, string contextId)
{
if (actual is not PushNotificationData<NotificationPushNotification> pushNotificationData)
{
return false;
}
return pushNotificationData.Type == type &&
pushNotificationData.ContextId == contextId &&
expected.Id == pushNotificationData.Payload.Id &&
expected.UserId == pushNotificationData.Payload.UserId &&
expected.OrganizationId == pushNotificationData.Payload.OrganizationId &&
expected.ClientType == pushNotificationData.Payload.ClientType &&
expected.RevisionDate == pushNotificationData.Payload.RevisionDate;
}
}

View File

@@ -18,5 +18,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Notifications\Notifications.csproj" />
<ProjectReference Include="..\Common\Common.csproj" />
<ProjectReference Include="..\Core.Test\Core.Test.csproj" />
</ItemGroup>
</Project>