mirror of
https://github.com/bitwarden/server
synced 2025-12-25 20:53:16 +00:00
Merge branch 'main' of github.com:bitwarden/server into arch/seeder-api
# Conflicts: # bitwarden-server.sln # util/Seeder/Factories/UserSeeder.cs
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()));
|
||||
|
||||
112
test/Api.Test/KeyManagement/Controllers/UsersControllerTests.cs
Normal file
112
test/Api.Test/KeyManagement/Controllers/UsersControllerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 } }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,9 +28,10 @@ public class VNextSavePolicyCommandTests
|
||||
// Arrange
|
||||
var fakePolicyValidationEvent = new FakeSingleOrgValidationEvent();
|
||||
fakePolicyValidationEvent.ValidateAsyncMock(Arg.Any<SavePolicyModel>(), Arg.Any<Policy>()).Returns("");
|
||||
var sutProvider = SutProviderFactory(
|
||||
[new FakeSingleOrgDependencyEvent()],
|
||||
[fakePolicyValidationEvent]);
|
||||
var sutProvider = SutProviderFactory([
|
||||
new FakeSingleOrgDependencyEvent(),
|
||||
fakePolicyValidationEvent
|
||||
]);
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
|
||||
|
||||
@@ -71,9 +72,10 @@ public class VNextSavePolicyCommandTests
|
||||
// Arrange
|
||||
var fakePolicyValidationEvent = new FakeSingleOrgValidationEvent();
|
||||
fakePolicyValidationEvent.ValidateAsyncMock(Arg.Any<SavePolicyModel>(), Arg.Any<Policy>()).Returns("");
|
||||
var sutProvider = SutProviderFactory(
|
||||
[new FakeSingleOrgDependencyEvent()],
|
||||
[fakePolicyValidationEvent]);
|
||||
var sutProvider = SutProviderFactory([
|
||||
new FakeSingleOrgDependencyEvent(),
|
||||
fakePolicyValidationEvent
|
||||
]);
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
|
||||
|
||||
@@ -110,23 +112,6 @@ public class VNextSavePolicyCommandTests
|
||||
p.RevisionDate == revisionDate));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DuplicatePolicyDependencyEvents_Throws()
|
||||
{
|
||||
// Arrange & Act
|
||||
var exception = Assert.Throws<Exception>(() =>
|
||||
new VNextSavePolicyCommand(
|
||||
Substitute.For<IApplicationCacheService>(),
|
||||
Substitute.For<IEventService>(),
|
||||
Substitute.For<IPolicyRepository>(),
|
||||
[new FakeSingleOrgDependencyEvent(), new FakeSingleOrgDependencyEvent()],
|
||||
Substitute.For<TimeProvider>(),
|
||||
Substitute.For<IPolicyEventHandlerFactory>()));
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Duplicate PolicyValidationEvent for SingleOrg policy", exception.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task SaveAsync_OrganizationDoesNotExist_ThrowsBadRequest([PolicyUpdate(PolicyType.ActivateAutofill)] PolicyUpdate policyUpdate)
|
||||
{
|
||||
@@ -366,9 +351,10 @@ public class VNextSavePolicyCommandTests
|
||||
// Arrange
|
||||
var fakePolicyValidationEvent = new FakeSingleOrgValidationEvent();
|
||||
fakePolicyValidationEvent.ValidateAsyncMock(Arg.Any<SavePolicyModel>(), Arg.Any<Policy>()).Returns("Validation error!");
|
||||
var sutProvider = SutProviderFactory(
|
||||
[new FakeSingleOrgDependencyEvent()],
|
||||
[fakePolicyValidationEvent]);
|
||||
var sutProvider = SutProviderFactory([
|
||||
new FakeSingleOrgDependencyEvent(),
|
||||
fakePolicyValidationEvent
|
||||
]);
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel());
|
||||
|
||||
@@ -392,20 +378,20 @@ public class VNextSavePolicyCommandTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a new SutProvider with the PolicyDependencyEvents registered in the Sut.
|
||||
/// Returns a new SutProvider with the PolicyUpdateEvents registered in the Sut.
|
||||
/// </summary>
|
||||
private static SutProvider<VNextSavePolicyCommand> SutProviderFactory(
|
||||
IEnumerable<IEnforceDependentPoliciesEvent>? policyDependencyEvents = null,
|
||||
IEnumerable<IPolicyValidationEvent>? policyValidationEvents = null)
|
||||
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 = policyDependencyEvents?.FirstOrDefault(e => e.Type == 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());
|
||||
});
|
||||
|
||||
@@ -413,7 +399,7 @@ public class VNextSavePolicyCommandTests
|
||||
.Returns(callInfo =>
|
||||
{
|
||||
var policyType = callInfo.Arg<PolicyType>();
|
||||
var handler = policyValidationEvents?.FirstOrDefault(e => e.Type == 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());
|
||||
});
|
||||
|
||||
@@ -425,7 +411,7 @@ public class VNextSavePolicyCommandTests
|
||||
|
||||
return new SutProvider<VNextSavePolicyCommand>()
|
||||
.WithFakeTimeProvider()
|
||||
.SetDependency(policyDependencyEvents ?? [])
|
||||
.SetDependency(handlers)
|
||||
.SetDependency(policyEventHandlerFactory)
|
||||
.Create();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -475,7 +477,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == "2020-families-org-annually" &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 1 &&
|
||||
options.Coupon == null));
|
||||
options.Discounts == null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -524,7 +526,11 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 900,
|
||||
TotalTaxes = [new InvoiceTotalTax
|
||||
{
|
||||
Amount = 900
|
||||
}
|
||||
],
|
||||
Total = 9900
|
||||
};
|
||||
|
||||
@@ -546,7 +552,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-annually" &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 6 &&
|
||||
options.Coupon == null));
|
||||
options.Discounts == null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -595,7 +601,11 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 1200,
|
||||
TotalTaxes = [new InvoiceTotalTax
|
||||
{
|
||||
Amount = 1200
|
||||
}
|
||||
],
|
||||
Total = 13200
|
||||
};
|
||||
|
||||
@@ -617,7 +627,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
options.SubscriptionDetails.Items.Count == 1 &&
|
||||
options.SubscriptionDetails.Items[0].Price == "2023-enterprise-org-seat-annually" &&
|
||||
options.SubscriptionDetails.Items[0].Quantity == 6 &&
|
||||
options.Coupon == null));
|
||||
options.Discounts == null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -647,7 +657,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 800,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 800 }],
|
||||
Total = 8800
|
||||
};
|
||||
|
||||
@@ -672,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]
|
||||
@@ -724,7 +734,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 1500,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 1500 }],
|
||||
Total = 16500
|
||||
};
|
||||
|
||||
@@ -753,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]
|
||||
@@ -808,7 +818,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 600,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 600 }],
|
||||
Total = 6600
|
||||
};
|
||||
|
||||
@@ -831,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]
|
||||
@@ -911,7 +923,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 600,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 600 }],
|
||||
Total = 6600
|
||||
};
|
||||
|
||||
@@ -934,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]
|
||||
@@ -976,7 +988,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 1200,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 1200 }],
|
||||
Total = 13200
|
||||
};
|
||||
|
||||
@@ -1001,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]
|
||||
@@ -1043,7 +1055,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 800,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 800 }],
|
||||
Total = 8800
|
||||
};
|
||||
|
||||
@@ -1066,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]
|
||||
@@ -1111,7 +1123,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 1500,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 1500 }],
|
||||
Total = 16500
|
||||
};
|
||||
|
||||
@@ -1139,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]
|
||||
@@ -1192,7 +1204,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 2500,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 2500 }],
|
||||
Total = 27500
|
||||
};
|
||||
|
||||
@@ -1224,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]
|
||||
@@ -1266,7 +1280,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 500,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 500 }],
|
||||
Total = 5500
|
||||
};
|
||||
|
||||
@@ -1291,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]
|
||||
@@ -1368,7 +1382,7 @@ public class PreviewOrganizationTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 300,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 300 }],
|
||||
Total = 3300
|
||||
};
|
||||
|
||||
@@ -1391,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
|
||||
|
||||
@@ -88,7 +88,7 @@ public class UpdateOrganizationLicenseCommandTests
|
||||
"Hash", "Signature", "SignatureBytes", "InstallationId", "Expires",
|
||||
"ExpirationWithoutGracePeriod", "Token", "LimitCollectionCreationDeletion",
|
||||
"LimitCollectionCreation", "LimitCollectionDeletion", "AllowAdminAccessToAllCollectionItems",
|
||||
"UseOrganizationDomains", "UseAdminSponsoredFamilies") &&
|
||||
"UseOrganizationDomains", "UseAdminSponsoredFamilies", "UseAutomaticUserConfirmation") &&
|
||||
// Same property but different name, use explicit mapping
|
||||
org.ExpirationDate == license.Expires));
|
||||
}
|
||||
|
||||
@@ -27,25 +27,27 @@ public class GetCloudOrganizationLicenseQueryTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetLicenseAsync_InvalidInstallationId_Throws(SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
public async Task GetLicenseAsync_InvalidInstallationId_Throws(
|
||||
SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
Organization organization, Guid installationId, int version)
|
||||
{
|
||||
sutProvider.GetDependency<IInstallationRepository>().GetByIdAsync(installationId).ReturnsNull();
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
async () => await sutProvider.Sut.GetLicenseAsync(organization, installationId, version));
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(async () =>
|
||||
await sutProvider.Sut.GetLicenseAsync(organization, installationId, version));
|
||||
Assert.Contains("Invalid installation id", exception.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetLicenseAsync_DisabledOrganization_Throws(SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
public async Task GetLicenseAsync_DisabledOrganization_Throws(
|
||||
SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
Organization organization, Guid installationId, Installation installation)
|
||||
{
|
||||
installation.Enabled = false;
|
||||
sutProvider.GetDependency<IInstallationRepository>().GetByIdAsync(installationId).Returns(installation);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(
|
||||
async () => await sutProvider.Sut.GetLicenseAsync(organization, installationId));
|
||||
var exception = await Assert.ThrowsAsync<BadRequestException>(async () =>
|
||||
await sutProvider.Sut.GetLicenseAsync(organization, installationId));
|
||||
Assert.Contains("Invalid installation id", exception.Message);
|
||||
}
|
||||
|
||||
@@ -71,7 +73,8 @@ public class GetCloudOrganizationLicenseQueryTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetLicenseAsync_WhenFeatureFlagEnabled_CreatesToken(SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
public async Task GetLicenseAsync_WhenFeatureFlagEnabled_CreatesToken(
|
||||
SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
Organization organization, Guid installationId, Installation installation, SubscriptionInfo subInfo,
|
||||
byte[] licenseSignature, string token)
|
||||
{
|
||||
@@ -90,7 +93,8 @@ public class GetCloudOrganizationLicenseQueryTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetLicenseAsync_MSPManagedOrganization_UsesProviderSubscription(SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
public async Task GetLicenseAsync_MSPManagedOrganization_UsesProviderSubscription(
|
||||
SutProvider<GetCloudOrganizationLicenseQuery> sutProvider,
|
||||
Organization organization, Guid installationId, Installation installation, SubscriptionInfo subInfo,
|
||||
byte[] licenseSignature, Provider provider)
|
||||
{
|
||||
@@ -99,8 +103,17 @@ public class GetCloudOrganizationLicenseQueryTests
|
||||
|
||||
subInfo.Subscription = new SubscriptionInfo.BillingSubscription(new Subscription
|
||||
{
|
||||
CurrentPeriodStart = DateTime.UtcNow,
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1)
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodStart = DateTime.UtcNow,
|
||||
CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1)
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
installation.Enabled = true;
|
||||
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
@@ -272,7 +272,16 @@ public class GetOrganizationWarningsQueryTests
|
||||
CollectionMethod = CollectionMethod.SendInvoice,
|
||||
Customer = new Customer(),
|
||||
Status = SubscriptionStatus.Active,
|
||||
CurrentPeriodEnd = now.AddDays(10),
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
CurrentPeriodEnd = now.AddDays(10)
|
||||
}
|
||||
]
|
||||
},
|
||||
TestClock = new TestClock
|
||||
{
|
||||
FrozenTime = now
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Bit.Core.Billing.Caches;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
using Bit.Core.Billing.Premium.Commands;
|
||||
using Bit.Core.Billing.Services;
|
||||
@@ -105,6 +106,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 +163,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 +262,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 +316,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 +366,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 +391,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 +417,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 +442,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 +469,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 +520,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>();
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ public class PreviewPremiumTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 300,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 300 }],
|
||||
Total = 3300
|
||||
};
|
||||
|
||||
@@ -65,7 +65,7 @@ public class PreviewPremiumTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 500,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 500 }],
|
||||
Total = 5500
|
||||
};
|
||||
|
||||
@@ -101,7 +101,7 @@ public class PreviewPremiumTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 250,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 250 }],
|
||||
Total = 2750
|
||||
};
|
||||
|
||||
@@ -135,7 +135,7 @@ public class PreviewPremiumTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 800,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 800 }],
|
||||
Total = 8800
|
||||
};
|
||||
|
||||
@@ -171,7 +171,7 @@ public class PreviewPremiumTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 450,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 450 }],
|
||||
Total = 4950
|
||||
};
|
||||
|
||||
@@ -207,7 +207,7 @@ public class PreviewPremiumTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 0,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 0 }],
|
||||
Total = 3000
|
||||
};
|
||||
|
||||
@@ -241,7 +241,7 @@ public class PreviewPremiumTaxCommandTests
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
Tax = 600,
|
||||
TotalTaxes = [new InvoiceTotalTax { Amount = 600 }],
|
||||
Total = 6600
|
||||
};
|
||||
|
||||
@@ -276,7 +276,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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.KeyManagement.Kdf.Implementations;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
@@ -21,16 +23,12 @@ public class ChangeKdfCommandTests
|
||||
[BitAutoData]
|
||||
public async Task ChangeKdfAsync_ChangesKdfAsync(SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
|
||||
sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(IdentityResult.Success));
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(true));
|
||||
sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(IdentityResult.Success));
|
||||
|
||||
var kdf = new KdfSettings
|
||||
{
|
||||
KdfType = Enums.KdfType.Argon2id,
|
||||
Iterations = 4,
|
||||
Memory = 512,
|
||||
Parallelism = 4
|
||||
};
|
||||
var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 };
|
||||
var authenticationData = new MasterPasswordAuthenticationData
|
||||
{
|
||||
Kdf = kdf,
|
||||
@@ -59,13 +57,7 @@ public class ChangeKdfCommandTests
|
||||
[BitAutoData]
|
||||
public async Task ChangeKdfAsync_UserIsNull_ThrowsArgumentNullException(SutProvider<ChangeKdfCommand> sutProvider)
|
||||
{
|
||||
var kdf = new KdfSettings
|
||||
{
|
||||
KdfType = Enums.KdfType.Argon2id,
|
||||
Iterations = 4,
|
||||
Memory = 512,
|
||||
Parallelism = 4
|
||||
};
|
||||
var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 };
|
||||
var authenticationData = new MasterPasswordAuthenticationData
|
||||
{
|
||||
Kdf = kdf,
|
||||
@@ -85,17 +77,13 @@ public class ChangeKdfCommandTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ChangeKdfAsync_WrongPassword_ReturnsPasswordMismatch(SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
public async Task ChangeKdfAsync_WrongPassword_ReturnsPasswordMismatch(SutProvider<ChangeKdfCommand> sutProvider,
|
||||
User user)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(false));
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(false));
|
||||
|
||||
var kdf = new KdfSettings
|
||||
{
|
||||
KdfType = Enums.KdfType.Argon2id,
|
||||
Iterations = 4,
|
||||
Memory = 512,
|
||||
Parallelism = 4
|
||||
};
|
||||
var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 };
|
||||
var authenticationData = new MasterPasswordAuthenticationData
|
||||
{
|
||||
Kdf = kdf,
|
||||
@@ -116,7 +104,9 @@ public class ChangeKdfCommandTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ChangeKdfAsync_WithAuthenticationAndUnlockData_UpdatesUserCorrectly(SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
public async Task
|
||||
ChangeKdfAsync_WithAuthenticationAndUnlockDataAndNoLogoutOnKdfChangeFeatureFlagOff_UpdatesUserCorrectlyAndLogsOut(
|
||||
SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
{
|
||||
var constantKdf = new KdfSettings
|
||||
{
|
||||
@@ -137,8 +127,12 @@ public class ChangeKdfCommandTests
|
||||
MasterKeyWrappedUserKey = "new-wrapped-key",
|
||||
Salt = user.GetMasterPasswordSalt()
|
||||
};
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
|
||||
sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(IdentityResult.Success));
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(true));
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<bool>())
|
||||
.Returns(Task.FromResult(IdentityResult.Success));
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(Arg.Any<string>()).Returns(false);
|
||||
|
||||
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData);
|
||||
|
||||
@@ -150,17 +144,79 @@ public class ChangeKdfCommandTests
|
||||
&& u.KdfParallelism == constantKdf.Parallelism
|
||||
&& u.Key == "new-wrapped-key"
|
||||
));
|
||||
await sutProvider.GetDependency<IUserService>().Received(1).UpdatePasswordHash(user,
|
||||
authenticationData.MasterPasswordAuthenticationHash, validatePassword: true, refreshStamp: true);
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushLogOutAsync(user.Id);
|
||||
sutProvider.GetDependency<IFeatureService>().Received(1).IsEnabled(FeatureFlagKeys.NoLogoutOnKdfChange);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ChangeKdfAsync_KdfNotEqualBetweenAuthAndUnlock_ThrowsBadRequestException(SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
public async Task
|
||||
ChangeKdfAsync_WithAuthenticationAndUnlockDataAndNoLogoutOnKdfChangeFeatureFlagOn_UpdatesUserCorrectlyAndDoesNotLogOut(
|
||||
SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
|
||||
var constantKdf = new KdfSettings
|
||||
{
|
||||
KdfType = Enums.KdfType.Argon2id,
|
||||
Iterations = 5,
|
||||
Memory = 1024,
|
||||
Parallelism = 4
|
||||
};
|
||||
var authenticationData = new MasterPasswordAuthenticationData
|
||||
{
|
||||
Kdf = constantKdf,
|
||||
MasterPasswordAuthenticationHash = "new-auth-hash",
|
||||
Salt = user.GetMasterPasswordSalt()
|
||||
};
|
||||
var unlockData = new MasterPasswordUnlockData
|
||||
{
|
||||
Kdf = constantKdf,
|
||||
MasterKeyWrappedUserKey = "new-wrapped-key",
|
||||
Salt = user.GetMasterPasswordSalt()
|
||||
};
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(true));
|
||||
sutProvider.GetDependency<IUserService>()
|
||||
.UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<bool>())
|
||||
.Returns(Task.FromResult(IdentityResult.Success));
|
||||
sutProvider.GetDependency<IFeatureService>().IsEnabled(Arg.Any<string>()).Returns(true);
|
||||
|
||||
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData);
|
||||
|
||||
await sutProvider.GetDependency<IUserRepository>().Received(1).ReplaceAsync(Arg.Is<User>(u =>
|
||||
u.Id == user.Id
|
||||
&& u.Kdf == constantKdf.KdfType
|
||||
&& u.KdfIterations == constantKdf.Iterations
|
||||
&& u.KdfMemory == constantKdf.Memory
|
||||
&& u.KdfParallelism == constantKdf.Parallelism
|
||||
&& u.Key == "new-wrapped-key"
|
||||
));
|
||||
await sutProvider.GetDependency<IUserService>().Received(1).UpdatePasswordHash(user,
|
||||
authenticationData.MasterPasswordAuthenticationHash, validatePassword: true, refreshStamp: false);
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1)
|
||||
.PushLogOutAsync(user.Id, false, PushNotificationLogOutReason.KdfChange);
|
||||
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncSettingsAsync(user.Id);
|
||||
sutProvider.GetDependency<IFeatureService>().Received(1).IsEnabled(FeatureFlagKeys.NoLogoutOnKdfChange);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ChangeKdfAsync_KdfNotEqualBetweenAuthAndUnlock_ThrowsBadRequestException(
|
||||
SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
var authenticationData = new MasterPasswordAuthenticationData
|
||||
{
|
||||
Kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 },
|
||||
Kdf = new KdfSettings
|
||||
{
|
||||
KdfType = Enums.KdfType.Argon2id,
|
||||
Iterations = 4,
|
||||
Memory = 512,
|
||||
Parallelism = 4
|
||||
},
|
||||
MasterPasswordAuthenticationHash = "new-auth-hash",
|
||||
Salt = user.GetMasterPasswordSalt()
|
||||
};
|
||||
@@ -176,9 +232,11 @@ public class ChangeKdfCommandTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ChangeKdfAsync_AuthDataSaltMismatch_Throws(SutProvider<ChangeKdfCommand> sutProvider, User user, KdfSettings kdf)
|
||||
public async Task ChangeKdfAsync_AuthDataSaltMismatch_Throws(SutProvider<ChangeKdfCommand> sutProvider, User user,
|
||||
KdfSettings kdf)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
var authenticationData = new MasterPasswordAuthenticationData
|
||||
{
|
||||
@@ -192,15 +250,17 @@ public class ChangeKdfCommandTests
|
||||
MasterKeyWrappedUserKey = "new-wrapped-key",
|
||||
Salt = user.GetMasterPasswordSalt()
|
||||
};
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
await Assert.ThrowsAsync<BadRequestException>(async () =>
|
||||
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ChangeKdfAsync_UnlockDataSaltMismatch_Throws(SutProvider<ChangeKdfCommand> sutProvider, User user, KdfSettings kdf)
|
||||
public async Task ChangeKdfAsync_UnlockDataSaltMismatch_Throws(SutProvider<ChangeKdfCommand> sutProvider, User user,
|
||||
KdfSettings kdf)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
var authenticationData = new MasterPasswordAuthenticationData
|
||||
{
|
||||
@@ -214,25 +274,22 @@ public class ChangeKdfCommandTests
|
||||
MasterKeyWrappedUserKey = "new-wrapped-key",
|
||||
Salt = "different-salt"
|
||||
};
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||
await Assert.ThrowsAsync<BadRequestException>(async () =>
|
||||
await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ChangeKdfAsync_UpdatePasswordHashFails_ReturnsFailure(SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
public async Task ChangeKdfAsync_UpdatePasswordHashFails_ReturnsFailure(SutProvider<ChangeKdfCommand> sutProvider,
|
||||
User user)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(true));
|
||||
var failedResult = IdentityResult.Failed(new IdentityError { Code = "TestFail", Description = "Test fail" });
|
||||
sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(failedResult));
|
||||
sutProvider.GetDependency<IUserService>().UpdatePasswordHash(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(failedResult));
|
||||
|
||||
var kdf = new KdfSettings
|
||||
{
|
||||
KdfType = Enums.KdfType.Argon2id,
|
||||
Iterations = 4,
|
||||
Memory = 512,
|
||||
Parallelism = 4
|
||||
};
|
||||
var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 };
|
||||
var authenticationData = new MasterPasswordAuthenticationData
|
||||
{
|
||||
Kdf = kdf,
|
||||
@@ -253,9 +310,11 @@ public class ChangeKdfCommandTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ChangeKdfAsync_InvalidKdfSettings_ThrowsBadRequestException(SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
public async Task ChangeKdfAsync_InvalidKdfSettings_ThrowsBadRequestException(
|
||||
SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// Create invalid KDF settings (iterations too low for PBKDF2)
|
||||
var invalidKdf = new KdfSettings
|
||||
@@ -287,9 +346,11 @@ public class ChangeKdfCommandTests
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ChangeKdfAsync_InvalidArgon2Settings_ThrowsBadRequestException(SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
public async Task ChangeKdfAsync_InvalidArgon2Settings_ThrowsBadRequestException(
|
||||
SutProvider<ChangeKdfCommand> sutProvider, User user)
|
||||
{
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>()).Returns(Task.FromResult(true));
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(Arg.Any<User>(), Arg.Any<string>())
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// Create invalid Argon2 KDF settings (memory too high)
|
||||
var invalidKdf = new KdfSettings
|
||||
@@ -318,5 +379,4 @@ public class ChangeKdfCommandTests
|
||||
|
||||
Assert.Equal("KDF settings are invalid.", exception.Message);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
43
test/Core.Test/KeyManagement/Queries/UserAccountKeysQuery.cs
Normal file
43
test/Core.Test/KeyManagement/Queries/UserAccountKeysQuery.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.KeyManagement.Queries;
|
||||
using Bit.Core.KeyManagement.Repositories;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.KeyManagement.Queries;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class UserAccountKeysQueryTests
|
||||
{
|
||||
[Theory, BitAutoData]
|
||||
public async Task V1User_Success(SutProvider<UserAccountKeysQuery> sutProvider, User user)
|
||||
{
|
||||
var result = await sutProvider.Sut.Run(user);
|
||||
Assert.Equal(user.GetPublicKeyEncryptionKeyPair().PublicKey, result.PublicKeyEncryptionKeyPairData.PublicKey);
|
||||
Assert.Equal(user.GetPublicKeyEncryptionKeyPair().WrappedPrivateKey, result.PublicKeyEncryptionKeyPairData.WrappedPrivateKey);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task V2User_Success(SutProvider<UserAccountKeysQuery> sutProvider, User user)
|
||||
{
|
||||
user.SecurityState = "v2";
|
||||
user.SecurityVersion = 2;
|
||||
var signatureKeyPairRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
signatureKeyPairRepository.GetByUserIdAsync(user.Id).Returns(new SignatureKeyPairData(Core.KeyManagement.Enums.SignatureAlgorithm.Ed25519, "wrappedSigningKey", "verifyingKey"));
|
||||
var result = await sutProvider.Sut.Run(user);
|
||||
Assert.Equal(user.GetPublicKeyEncryptionKeyPair().PublicKey, result.PublicKeyEncryptionKeyPairData.PublicKey);
|
||||
Assert.Equal(user.GetPublicKeyEncryptionKeyPair().WrappedPrivateKey, result.PublicKeyEncryptionKeyPairData.WrappedPrivateKey);
|
||||
Assert.Equal(user.GetPublicKeyEncryptionKeyPair().SignedPublicKey, result.PublicKeyEncryptionKeyPairData.SignedPublicKey);
|
||||
|
||||
Assert.NotNull(result.SignatureKeyPairData);
|
||||
Assert.Equal("wrappedSigningKey", result.SignatureKeyPairData.WrappedSigningKey);
|
||||
Assert.Equal("verifyingKey", result.SignatureKeyPairData.VerifyingKey);
|
||||
|
||||
Assert.Equal(user.SecurityState, result.SecurityStateData.SecurityState);
|
||||
Assert.Equal(user.GetSecurityVersion(), result.SecurityStateData.SecurityVersion);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.KeyManagement.Enums;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.KeyManagement.Repositories;
|
||||
using Bit.Core.KeyManagement.UserKey.Implementations;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Tools.Entities;
|
||||
using Bit.Core.Tools.Repositories;
|
||||
using Bit.Core.Vault.Entities;
|
||||
using Bit.Core.Vault.Repositories;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.KeyManagement.UserKey;
|
||||
@@ -14,7 +21,7 @@ namespace Bit.Core.Test.KeyManagement.UserKey;
|
||||
public class RotateUserAccountKeysCommandTests
|
||||
{
|
||||
[Theory, BitAutoData]
|
||||
public async Task RejectsWrongOldMasterPassword(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
|
||||
public async Task RotateUserAccountKeysAsync_WrongOldMasterPassword_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
|
||||
RotateUserAccountKeysData model)
|
||||
{
|
||||
user.Email = model.MasterPasswordUnlockData.Email;
|
||||
@@ -25,41 +32,38 @@ public class RotateUserAccountKeysCommandTests
|
||||
|
||||
Assert.NotEqual(IdentityResult.Success, result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ThrowsWhenUserIsNull(SutProvider<RotateUserAccountKeysCommand> sutProvider,
|
||||
public async Task RotateUserAccountKeysAsync_UserIsNull_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider,
|
||||
RotateUserAccountKeysData model)
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(async () => await sutProvider.Sut.RotateUserAccountKeysAsync(null, model));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RejectsEmailChange(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
|
||||
public async Task RotateUserAccountKeysAsync_EmailChange_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
|
||||
RotateUserAccountKeysData model)
|
||||
{
|
||||
user.Kdf = Enums.KdfType.Argon2id;
|
||||
user.KdfIterations = 3;
|
||||
user.KdfMemory = 64;
|
||||
user.KdfParallelism = 4;
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV1ModelUser(model);
|
||||
|
||||
model.MasterPasswordUnlockData.Email = user.Email + ".different-domain";
|
||||
model.MasterPasswordUnlockData.KdfType = Enums.KdfType.Argon2id;
|
||||
model.MasterPasswordUnlockData.KdfIterations = 3;
|
||||
model.MasterPasswordUnlockData.KdfMemory = 64;
|
||||
model.MasterPasswordUnlockData.KdfParallelism = 4;
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)
|
||||
.Returns(true);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.RotateUserAccountKeysAsync(user, model));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RejectsKdfChange(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
|
||||
public async Task RotateUserAccountKeysAsync_KdfChange_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
|
||||
RotateUserAccountKeysData model)
|
||||
{
|
||||
user.Kdf = Enums.KdfType.Argon2id;
|
||||
user.KdfIterations = 3;
|
||||
user.KdfMemory = 64;
|
||||
user.KdfParallelism = 4;
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV1ModelUser(model);
|
||||
|
||||
model.MasterPasswordUnlockData.Email = user.Email;
|
||||
model.MasterPasswordUnlockData.KdfType = Enums.KdfType.PBKDF2_SHA256;
|
||||
model.MasterPasswordUnlockData.KdfIterations = 600000;
|
||||
model.MasterPasswordUnlockData.KdfMemory = null;
|
||||
@@ -71,22 +75,15 @@ public class RotateUserAccountKeysCommandTests
|
||||
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RejectsPublicKeyChange(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
|
||||
public async Task RotateUserAccountKeysAsync_PublicKeyChange_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
|
||||
RotateUserAccountKeysData model)
|
||||
{
|
||||
user.PublicKey = "old-public";
|
||||
user.Kdf = Enums.KdfType.Argon2id;
|
||||
user.KdfIterations = 3;
|
||||
user.KdfMemory = 64;
|
||||
user.KdfParallelism = 4;
|
||||
|
||||
model.AccountPublicKey = "new-public";
|
||||
model.MasterPasswordUnlockData.Email = user.Email;
|
||||
model.MasterPasswordUnlockData.KdfType = Enums.KdfType.Argon2id;
|
||||
model.MasterPasswordUnlockData.KdfIterations = 3;
|
||||
model.MasterPasswordUnlockData.KdfMemory = 64;
|
||||
model.MasterPasswordUnlockData.KdfParallelism = 4;
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV1ModelUser(model);
|
||||
|
||||
model.AccountKeys.PublicKeyEncryptionKeyPairData.PublicKey = "new-public";
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)
|
||||
.Returns(true);
|
||||
|
||||
@@ -94,27 +91,350 @@ public class RotateUserAccountKeysCommandTests
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RotatesCorrectly(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
|
||||
public async Task RotateUserAccountKeysAsync_V1_Success(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
|
||||
RotateUserAccountKeysData model)
|
||||
{
|
||||
user.Kdf = Enums.KdfType.Argon2id;
|
||||
user.KdfIterations = 3;
|
||||
user.KdfMemory = 64;
|
||||
user.KdfParallelism = 4;
|
||||
|
||||
model.MasterPasswordUnlockData.Email = user.Email;
|
||||
model.MasterPasswordUnlockData.KdfType = Enums.KdfType.Argon2id;
|
||||
model.MasterPasswordUnlockData.KdfIterations = 3;
|
||||
model.MasterPasswordUnlockData.KdfMemory = 64;
|
||||
model.MasterPasswordUnlockData.KdfParallelism = 4;
|
||||
|
||||
model.AccountPublicKey = user.PublicKey;
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV1ModelUser(model);
|
||||
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)
|
||||
.Returns(true);
|
||||
|
||||
var result = await sutProvider.Sut.RotateUserAccountKeysAsync(user, model);
|
||||
|
||||
Assert.Equal(IdentityResult.Success, result);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task RotateUserAccountKeysAsync_UpgradeV1ToV2_Success(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
|
||||
RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV2ModelUser(model);
|
||||
|
||||
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)
|
||||
.Returns(true);
|
||||
|
||||
var result = await sutProvider.Sut.RotateUserAccountKeysAsync(user, model);
|
||||
Assert.Equal(IdentityResult.Success, result);
|
||||
Assert.Equal(user.SecurityState, model.AccountKeys.SecurityStateData!.SecurityState);
|
||||
}
|
||||
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_PublicKeyChange_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV1ModelUser(model);
|
||||
|
||||
model.AccountKeys.PublicKeyEncryptionKeyPairData.PublicKey = "new-public";
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_V2User_PrivateKeyNotXChaCha20_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV2ExistingUser(user, signatureRepository);
|
||||
SetV2ModelUser(model);
|
||||
model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey = "2.xxx";
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_V1User_PrivateKeyNotAesCbcHmac_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV1ModelUser(model);
|
||||
model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey = "7.xxx";
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
Assert.Equal("The provided account private key was not wrapped with AES-256-CBC-HMAC", ex.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_V1_Success(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV1ModelUser(model);
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions);
|
||||
Assert.Empty(saveEncryptedDataActions);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_V2_Success(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV2ExistingUser(user, signatureRepository);
|
||||
SetV2ModelUser(model);
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions);
|
||||
Assert.NotEmpty(saveEncryptedDataActions);
|
||||
Assert.Equal(user.SecurityState, model.AccountKeys.SecurityStateData!.SecurityState);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_V2User_VerifyingKeyMismatch_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV2ExistingUser(user, signatureRepository);
|
||||
SetV2ModelUser(model);
|
||||
model.AccountKeys.SignatureKeyPairData.VerifyingKey = "different-verifying-key";
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
Assert.Equal("The provided verifying key does not match the user's current verifying key.", ex.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_V2User_SignedPublicKeyNullOrEmpty_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV2ExistingUser(user, signatureRepository);
|
||||
SetV2ModelUser(model);
|
||||
model.AccountKeys.PublicKeyEncryptionKeyPairData.SignedPublicKey = null;
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
Assert.Equal("No signed public key provided, but the user already has a signature key pair.", ex.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_V2User_WrappedSigningKeyNotXChaCha20_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV2ExistingUser(user, signatureRepository);
|
||||
SetV2ModelUser(model);
|
||||
model.AccountKeys.SignatureKeyPairData.WrappedSigningKey = "2.xxx";
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
Assert.Equal("The provided signing key data is not wrapped with XChaCha20-Poly1305.", ex.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeys_UpgradeToV2_InvalidVerifyingKey_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV2ModelUser(model);
|
||||
model.AccountKeys.SignatureKeyPairData.VerifyingKey = "";
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
Assert.Equal("The provided signature key pair data does not contain a valid verifying key.", ex.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_UpgradeToV2_IncorrectlyWrappedPrivateKey_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV2ModelUser(model);
|
||||
model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey = "2.abc";
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
Assert.Equal("The provided private key encryption key is not wrapped with XChaCha20-Poly1305.", ex.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_UpgradeToV2_NoSignedPublicKey_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV2ModelUser(model);
|
||||
model.AccountKeys.PublicKeyEncryptionKeyPairData.SignedPublicKey = null;
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
Assert.Equal("No signed public key provided, but the user already has a signature key pair.", ex.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_UpgradeToV2_NoSecurityState_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV2ModelUser(model);
|
||||
model.AccountKeys.SecurityStateData = null;
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
Assert.Equal("No signed security state provider for V2 user", ex.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_RotateV2_NoSignatureKeyPair_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV2ExistingUser(user, signatureRepository);
|
||||
SetV2ModelUser(model);
|
||||
model.AccountKeys.SignatureKeyPairData = null;
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
Assert.Equal("Signature key pair data is required for V2 encryption.", ex.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_GetEncryptionType_EmptyString_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV1ModelUser(model);
|
||||
model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey = "";
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
var ex = await Assert.ThrowsAsync<ArgumentException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
Assert.Equal("Invalid encryption type string.", ex.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateAccountKeysAsync_GetEncryptionType_InvalidString_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
SetTestKdfAndSaltForUserAndModel(user, model);
|
||||
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
|
||||
SetV1ExistingUser(user, signatureRepository);
|
||||
SetV1ModelUser(model);
|
||||
model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey = "9.xxx";
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
var ex = await Assert.ThrowsAsync<ArgumentException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
|
||||
Assert.Equal("Invalid encryption type string.", ex.Message);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task UpdateUserData_RevisionDateChanged_Success(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
var oldDate = new DateTime(2017, 1, 1);
|
||||
|
||||
var cipher = Substitute.For<Cipher>();
|
||||
cipher.RevisionDate = oldDate;
|
||||
model.Ciphers = [cipher];
|
||||
|
||||
var folder = Substitute.For<Folder>();
|
||||
folder.RevisionDate = oldDate;
|
||||
model.Folders = [folder];
|
||||
|
||||
var send = Substitute.For<Send>();
|
||||
send.RevisionDate = oldDate;
|
||||
model.Sends = [send];
|
||||
|
||||
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
|
||||
|
||||
sutProvider.Sut.UpdateUserData(model, user, saveEncryptedDataActions);
|
||||
foreach (var dataAction in saveEncryptedDataActions)
|
||||
{
|
||||
await dataAction.Invoke();
|
||||
}
|
||||
|
||||
var updatedCiphers = sutProvider.GetDependency<ICipherRepository>()
|
||||
.ReceivedCalls()
|
||||
.FirstOrDefault(call => call.GetMethodInfo().Name == "UpdateForKeyRotation")?
|
||||
.GetArguments()[1] as IEnumerable<Cipher>;
|
||||
foreach (var updatedCipher in updatedCiphers!)
|
||||
{
|
||||
var oldCipher = model.Ciphers.FirstOrDefault(c => c.Id == updatedCipher.Id);
|
||||
Assert.NotEqual(oldDate, updatedCipher.RevisionDate);
|
||||
}
|
||||
|
||||
var updatedFolders = sutProvider.GetDependency<IFolderRepository>()
|
||||
.ReceivedCalls()
|
||||
.FirstOrDefault(call => call.GetMethodInfo().Name == "UpdateForKeyRotation")?
|
||||
.GetArguments()[1] as IEnumerable<Folder>;
|
||||
foreach (var updatedFolder in updatedFolders!)
|
||||
{
|
||||
var oldFolder = model.Folders.FirstOrDefault(f => f.Id == updatedFolder.Id);
|
||||
Assert.NotEqual(oldDate, updatedFolder.RevisionDate);
|
||||
}
|
||||
|
||||
var updatedSends = sutProvider.GetDependency<ISendRepository>()
|
||||
.ReceivedCalls()
|
||||
.FirstOrDefault(call => call.GetMethodInfo().Name == "UpdateForKeyRotation")?
|
||||
.GetArguments()[1] as IEnumerable<Send>;
|
||||
foreach (var updatedSend in updatedSends!)
|
||||
{
|
||||
var oldSend = model.Sends.FirstOrDefault(s => s.Id == updatedSend.Id);
|
||||
Assert.NotEqual(oldDate, updatedSend.RevisionDate);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions to set valid test parameters that match each other to the model and user.
|
||||
private static void SetTestKdfAndSaltForUserAndModel(User user, RotateUserAccountKeysData model)
|
||||
{
|
||||
user.Kdf = Enums.KdfType.Argon2id;
|
||||
user.KdfIterations = 3;
|
||||
user.KdfMemory = 64;
|
||||
user.KdfParallelism = 4;
|
||||
model.MasterPasswordUnlockData.KdfType = Enums.KdfType.Argon2id;
|
||||
model.MasterPasswordUnlockData.KdfIterations = 3;
|
||||
model.MasterPasswordUnlockData.KdfMemory = 64;
|
||||
model.MasterPasswordUnlockData.KdfParallelism = 4;
|
||||
// The email is the salt for the KDF and is validated currently.
|
||||
user.Email = model.MasterPasswordUnlockData.Email;
|
||||
}
|
||||
|
||||
private static void SetV1ExistingUser(User user, IUserSignatureKeyPairRepository userSignatureKeyPairRepository)
|
||||
{
|
||||
user.PrivateKey = "2.abc";
|
||||
user.PublicKey = "public";
|
||||
user.SignedPublicKey = null;
|
||||
userSignatureKeyPairRepository.GetByUserIdAsync(user.Id).ReturnsNull();
|
||||
}
|
||||
|
||||
private static void SetV2ExistingUser(User user, IUserSignatureKeyPairRepository userSignatureKeyPairRepository)
|
||||
{
|
||||
user.PrivateKey = "7.abc";
|
||||
user.PublicKey = "public";
|
||||
user.SignedPublicKey = "signed-public";
|
||||
userSignatureKeyPairRepository.GetByUserIdAsync(user.Id).Returns(new SignatureKeyPairData(SignatureAlgorithm.Ed25519, "7.abc", "verifying-key"));
|
||||
}
|
||||
|
||||
private static void SetV1ModelUser(RotateUserAccountKeysData model)
|
||||
{
|
||||
model.AccountKeys.PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData("2.abc", "public", null);
|
||||
model.AccountKeys.SignatureKeyPairData = null;
|
||||
model.AccountKeys.SecurityStateData = null;
|
||||
}
|
||||
|
||||
private static void SetV2ModelUser(RotateUserAccountKeysData model)
|
||||
{
|
||||
model.AccountKeys.PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData("7.abc", "public", "signed-public");
|
||||
model.AccountKeys.SignatureKeyPairData = new SignatureKeyPairData(SignatureAlgorithm.Ed25519, "7.abc", "verifying-key");
|
||||
model.AccountKeys.SecurityStateData = new SecurityStateData
|
||||
{
|
||||
SecurityState = "abc",
|
||||
SecurityVersion = 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,20 +358,28 @@ public class AzureQueuePushEngineTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task PushLogOutAsync_SendsExpectedResponse(bool excludeCurrentContext)
|
||||
[InlineData(true, null)]
|
||||
[InlineData(true, PushNotificationLogOutReason.KdfChange)]
|
||||
[InlineData(false, null)]
|
||||
[InlineData(false, PushNotificationLogOutReason.KdfChange)]
|
||||
public async Task PushLogOutAsync_SendsExpectedResponse(bool excludeCurrentContext,
|
||||
PushNotificationLogOutReason? reason)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var payload = new JsonObject
|
||||
{
|
||||
["UserId"] = userId
|
||||
};
|
||||
if (reason != null)
|
||||
{
|
||||
payload["Reason"] = (int)reason;
|
||||
}
|
||||
|
||||
var expectedPayload = new JsonObject
|
||||
{
|
||||
["Type"] = 11,
|
||||
["Payload"] = new JsonObject
|
||||
{
|
||||
["UserId"] = userId,
|
||||
["Date"] = _fakeTimeProvider.GetUtcNow().UtcDateTime,
|
||||
},
|
||||
["Payload"] = payload,
|
||||
};
|
||||
|
||||
if (excludeCurrentContext)
|
||||
@@ -380,7 +388,7 @@ public class AzureQueuePushEngineTests
|
||||
}
|
||||
|
||||
await VerifyNotificationAsync(
|
||||
async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext),
|
||||
async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext, reason),
|
||||
expectedPayload
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.NotificationCenter.Entities;
|
||||
using Bit.Core.Platform.Push.Internal;
|
||||
using Bit.Core.Tools.Entities;
|
||||
@@ -193,7 +194,8 @@ public class NotificationsApiPushEngineTests : PushTestBase
|
||||
};
|
||||
}
|
||||
|
||||
protected override JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext)
|
||||
protected override JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext,
|
||||
PushNotificationLogOutReason? reason)
|
||||
{
|
||||
JsonNode? contextId = excludeCurrentContext ? DeviceIdentifier : null;
|
||||
|
||||
@@ -203,7 +205,7 @@ public class NotificationsApiPushEngineTests : PushTestBase
|
||||
["Payload"] = new JsonObject
|
||||
{
|
||||
["UserId"] = userId,
|
||||
["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime,
|
||||
["Reason"] = reason != null ? (int)reason : null
|
||||
},
|
||||
["ContextId"] = contextId,
|
||||
};
|
||||
|
||||
@@ -86,7 +86,8 @@ public abstract class PushTestBase
|
||||
protected abstract JsonNode GetPushSyncOrganizationsPayload(Guid userId);
|
||||
protected abstract JsonNode GetPushSyncOrgKeysPayload(Guid userId);
|
||||
protected abstract JsonNode GetPushSyncSettingsPayload(Guid userId);
|
||||
protected abstract JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext);
|
||||
protected abstract JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext,
|
||||
PushNotificationLogOutReason? reason);
|
||||
protected abstract JsonNode GetPushSendCreatePayload(Send send);
|
||||
protected abstract JsonNode GetPushSendUpdatePayload(Send send);
|
||||
protected abstract JsonNode GetPushSendDeletePayload(Send send);
|
||||
@@ -263,15 +264,18 @@ public abstract class PushTestBase
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task PushLogOutAsync_SendsExpectedResponse(bool excludeCurrentContext)
|
||||
[InlineData(true, null)]
|
||||
[InlineData(true, PushNotificationLogOutReason.KdfChange)]
|
||||
[InlineData(false, null)]
|
||||
[InlineData(false, PushNotificationLogOutReason.KdfChange)]
|
||||
public async Task PushLogOutAsync_SendsExpectedResponse(bool excludeCurrentContext,
|
||||
PushNotificationLogOutReason? reason)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
await VerifyNotificationAsync(
|
||||
async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext),
|
||||
GetPushLogOutPayload(userId, excludeCurrentContext)
|
||||
async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext, reason),
|
||||
GetPushLogOutPayload(userId, excludeCurrentContext, reason)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Text.Json.Nodes;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Auth.Entities;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.NotificationCenter.Entities;
|
||||
using Bit.Core.Platform.Push.Internal;
|
||||
using Bit.Core.Repositories;
|
||||
@@ -64,7 +65,7 @@ public class RelayPushNotificationServiceTests : PushTestBase
|
||||
["UserId"] = cipher.UserId,
|
||||
["OrganizationId"] = null,
|
||||
// Currently CollectionIds are not passed along from the method signature
|
||||
// to the request body.
|
||||
// to the request body.
|
||||
["CollectionIds"] = null,
|
||||
["RevisionDate"] = cipher.RevisionDate,
|
||||
},
|
||||
@@ -88,7 +89,7 @@ public class RelayPushNotificationServiceTests : PushTestBase
|
||||
["UserId"] = cipher.UserId,
|
||||
["OrganizationId"] = null,
|
||||
// Currently CollectionIds are not passed along from the method signature
|
||||
// to the request body.
|
||||
// to the request body.
|
||||
["CollectionIds"] = null,
|
||||
["RevisionDate"] = cipher.RevisionDate,
|
||||
},
|
||||
@@ -274,7 +275,8 @@ public class RelayPushNotificationServiceTests : PushTestBase
|
||||
};
|
||||
}
|
||||
|
||||
protected override JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext)
|
||||
protected override JsonNode GetPushLogOutPayload(Guid userId, bool excludeCurrentContext,
|
||||
PushNotificationLogOutReason? reason)
|
||||
{
|
||||
JsonNode? identifier = excludeCurrentContext ? DeviceIdentifier : null;
|
||||
|
||||
@@ -288,7 +290,7 @@ public class RelayPushNotificationServiceTests : PushTestBase
|
||||
["Payload"] = new JsonObject
|
||||
{
|
||||
["UserId"] = userId,
|
||||
["Date"] = FakeTimeProvider.GetUtcNow().UtcDateTime,
|
||||
["Reason"] = reason != null ? (int)reason : null
|
||||
},
|
||||
["ClientType"] = null,
|
||||
["InstallationId"] = null,
|
||||
|
||||
@@ -404,16 +404,18 @@ public class NotificationHubPushNotificationServiceTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task PushLogOutAsync_SendExpectedData(bool excludeCurrentContext)
|
||||
[InlineData(true, null)]
|
||||
[InlineData(true, PushNotificationLogOutReason.KdfChange)]
|
||||
[InlineData(false, null)]
|
||||
[InlineData(false, PushNotificationLogOutReason.KdfChange)]
|
||||
public async Task PushLogOutAsync_SendExpectedData(bool excludeCurrentContext, PushNotificationLogOutReason? reason)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
|
||||
var expectedPayload = new JsonObject
|
||||
{
|
||||
["UserId"] = userId,
|
||||
["Date"] = _now,
|
||||
["Reason"] = reason != null ? (int)reason : null,
|
||||
};
|
||||
|
||||
var expectedTag = excludeCurrentContext
|
||||
@@ -421,7 +423,7 @@ public class NotificationHubPushNotificationServiceTests
|
||||
: $"(template:payload_userId:{userId})";
|
||||
|
||||
await VerifyNotificationAsync(
|
||||
async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext),
|
||||
async sut => await sut.PushLogOutAsync(userId, excludeCurrentContext, reason),
|
||||
PushType.LogOut,
|
||||
expectedPayload,
|
||||
expectedTag
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
58
test/Identity.Test/AutoFixture/ProfileServiceFixtures.cs
Normal file
58
test/Identity.Test/AutoFixture/ProfileServiceFixtures.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
562
test/Identity.Test/IdentityServer/ProfileServiceTests.cs
Normal file
562
test/Identity.Test/IdentityServer/ProfileServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()}"
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user