1
0
mirror of https://github.com/bitwarden/server synced 2025-12-26 05:03:18 +00:00

Merge branch 'main' into SM-1571-DisableSMAdsForUsers

This commit is contained in:
cd-bitwarden
2025-11-05 21:51:55 -05:00
committed by GitHub
110 changed files with 5202 additions and 2782 deletions

View File

@@ -0,0 +1,197 @@
using System.Net;
using Bit.Api.AdminConsole.Authorization;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Api.Models.Request.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
using Bit.Core.Repositories;
using Bit.Core.Services;
using NSubstitute;
using Xunit;
namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;
public class OrganizationUsersControllerPutResetPasswordTests : IClassFixture<ApiApplicationFactory>, IAsyncLifetime
{
private readonly HttpClient _client;
private readonly ApiApplicationFactory _factory;
private readonly LoginHelper _loginHelper;
private Organization _organization = null!;
private string _ownerEmail = null!;
public OrganizationUsersControllerPutResetPasswordTests(ApiApplicationFactory apiFactory)
{
_factory = apiFactory;
_factory.SubstituteService<IFeatureService>(featureService =>
{
featureService
.IsEnabled(FeatureFlagKeys.AccountRecoveryCommand)
.Returns(true);
});
_client = _factory.CreateClient();
_loginHelper = new LoginHelper(_factory, _client);
}
public async Task InitializeAsync()
{
_ownerEmail = $"reset-password-test-{Guid.NewGuid()}@example.com";
await _factory.LoginWithNewAccount(_ownerEmail);
(_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually2023,
ownerEmail: _ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card);
// Enable reset password and policies for the organization
var organizationRepository = _factory.GetService<IOrganizationRepository>();
_organization.UseResetPassword = true;
_organization.UsePolicies = true;
await organizationRepository.ReplaceAsync(_organization);
// Enable the ResetPassword policy
var policyRepository = _factory.GetService<IPolicyRepository>();
await policyRepository.CreateAsync(new Policy
{
OrganizationId = _organization.Id,
Type = PolicyType.ResetPassword,
Enabled = true,
Data = "{}"
});
}
public Task DisposeAsync()
{
_client.Dispose();
return Task.CompletedTask;
}
/// <summary>
/// Helper method to set the ResetPasswordKey on an organization user, which is required for account recovery
/// </summary>
private async Task SetResetPasswordKeyAsync(OrganizationUser orgUser)
{
var organizationUserRepository = _factory.GetService<IOrganizationUserRepository>();
orgUser.ResetPasswordKey = "encrypted-reset-password-key";
await organizationUserRepository.ReplaceAsync(orgUser);
}
[Fact]
public async Task PutResetPassword_AsHigherRole_CanRecoverLowerRole()
{
// Arrange
var (ownerEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
_organization.Id, OrganizationUserType.Owner);
await _loginHelper.LoginAsync(ownerEmail);
var (_, targetOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
_factory, _organization.Id, OrganizationUserType.User);
await SetResetPasswordKeyAsync(targetOrgUser);
var resetPasswordRequest = new OrganizationUserResetPasswordRequestModel
{
NewMasterPasswordHash = "new-master-password-hash",
Key = "encrypted-recovery-key"
};
// Act
var response = await _client.PutAsJsonAsync(
$"organizations/{_organization.Id}/users/{targetOrgUser.Id}/reset-password",
resetPasswordRequest);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task PutResetPassword_AsLowerRole_CannotRecoverHigherRole()
{
// Arrange
var (adminEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
_organization.Id, OrganizationUserType.Admin);
await _loginHelper.LoginAsync(adminEmail);
var (_, targetOwnerOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
_factory, _organization.Id, OrganizationUserType.Owner);
await SetResetPasswordKeyAsync(targetOwnerOrgUser);
var resetPasswordRequest = new OrganizationUserResetPasswordRequestModel
{
NewMasterPasswordHash = "new-master-password-hash",
Key = "encrypted-recovery-key"
};
// Act
var response = await _client.PutAsJsonAsync(
$"organizations/{_organization.Id}/users/{targetOwnerOrgUser.Id}/reset-password",
resetPasswordRequest);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var model = await response.Content.ReadFromJsonAsync<ErrorResponseModel>();
Assert.Contains(RecoverAccountAuthorizationHandler.FailureReason, model.Message);
}
[Fact]
public async Task PutResetPassword_CannotRecoverProviderAccount()
{
// Arrange - Create owner who will try to recover the provider account
var (ownerEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory,
_organization.Id, OrganizationUserType.Owner);
await _loginHelper.LoginAsync(ownerEmail);
// Create a user who is also a provider user
var (targetUserEmail, targetOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
_factory, _organization.Id, OrganizationUserType.User);
await SetResetPasswordKeyAsync(targetOrgUser);
// Add the target user as a provider user to a different provider
var providerRepository = _factory.GetService<IProviderRepository>();
var providerUserRepository = _factory.GetService<IProviderUserRepository>();
var userRepository = _factory.GetService<IUserRepository>();
var provider = await providerRepository.CreateAsync(new Provider
{
Name = "Test Provider",
BusinessName = "Test Provider Business",
BillingEmail = "provider@example.com",
Type = ProviderType.Msp,
Status = ProviderStatusType.Created,
Enabled = true
});
var targetUser = await userRepository.GetByEmailAsync(targetUserEmail);
Assert.NotNull(targetUser);
await providerUserRepository.CreateAsync(new ProviderUser
{
ProviderId = provider.Id,
UserId = targetUser.Id,
Status = ProviderUserStatusType.Confirmed,
Type = ProviderUserType.ProviderAdmin
});
var resetPasswordRequest = new OrganizationUserResetPasswordRequestModel
{
NewMasterPasswordHash = "new-master-password-hash",
Key = "encrypted-recovery-key"
};
// Act
var response = await _client.PutAsJsonAsync(
$"organizations/{_organization.Id}/users/{targetOrgUser.Id}/reset-password",
resetPasswordRequest);
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var model = await response.Content.ReadFromJsonAsync<ErrorResponseModel>();
Assert.Equal(RecoverAccountAuthorizationHandler.ProviderFailureReason, model.Message);
}
}

View File

@@ -211,4 +211,200 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
}
}
[Fact]
public async Task Put_MasterPasswordPolicy_InvalidDataType_ReturnsBadRequest()
{
// Arrange
var policyType = PolicyType.MasterPassword;
var request = new PolicyRequestModel
{
Type = policyType,
Enabled = true,
Data = new Dictionary<string, object>
{
{ "minLength", "not a number" }, // Wrong type - should be int
{ "requireUpper", true }
}
};
// Act
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}",
JsonContent.Create(request));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("minLength", content); // Verify field name is in error message
}
[Fact]
public async Task Put_SendOptionsPolicy_InvalidDataType_ReturnsBadRequest()
{
// Arrange
var policyType = PolicyType.SendOptions;
var request = new PolicyRequestModel
{
Type = policyType,
Enabled = true,
Data = new Dictionary<string, object>
{
{ "disableHideEmail", "not a boolean" } // Wrong type - should be bool
}
};
// Act
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}",
JsonContent.Create(request));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Put_ResetPasswordPolicy_InvalidDataType_ReturnsBadRequest()
{
// Arrange
var policyType = PolicyType.ResetPassword;
var request = new PolicyRequestModel
{
Type = policyType,
Enabled = true,
Data = new Dictionary<string, object>
{
{ "autoEnrollEnabled", 123 } // Wrong type - should be bool
}
};
// Act
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}",
JsonContent.Create(request));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PutVNext_MasterPasswordPolicy_InvalidDataType_ReturnsBadRequest()
{
// Arrange
var policyType = PolicyType.MasterPassword;
var request = new SavePolicyRequest
{
Policy = new PolicyRequestModel
{
Type = policyType,
Enabled = true,
Data = new Dictionary<string, object>
{
{ "minComplexity", "not a number" }, // Wrong type - should be int
{ "minLength", 12 }
}
}
};
// Act
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext",
JsonContent.Create(request));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("minComplexity", content); // Verify field name is in error message
}
[Fact]
public async Task PutVNext_SendOptionsPolicy_InvalidDataType_ReturnsBadRequest()
{
// Arrange
var policyType = PolicyType.SendOptions;
var request = new SavePolicyRequest
{
Policy = new PolicyRequestModel
{
Type = policyType,
Enabled = true,
Data = new Dictionary<string, object>
{
{ "disableHideEmail", "not a boolean" } // Wrong type - should be bool
}
}
};
// Act
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext",
JsonContent.Create(request));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task PutVNext_ResetPasswordPolicy_InvalidDataType_ReturnsBadRequest()
{
// Arrange
var policyType = PolicyType.ResetPassword;
var request = new SavePolicyRequest
{
Policy = new PolicyRequestModel
{
Type = policyType,
Enabled = true,
Data = new Dictionary<string, object>
{
{ "autoEnrollEnabled", 123 } // Wrong type - should be bool
}
}
};
// Act
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext",
JsonContent.Create(request));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Put_PolicyWithNullData_Success()
{
// Arrange
var policyType = PolicyType.SingleOrg;
var request = new PolicyRequestModel
{
Type = policyType,
Enabled = true,
Data = null
};
// Act
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}",
JsonContent.Create(request));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task PutVNext_PolicyWithNullData_Success()
{
// Arrange
var policyType = PolicyType.TwoFactorAuthentication;
var request = new SavePolicyRequest
{
Policy = new PolicyRequestModel
{
Type = policyType,
Enabled = true,
Data = null
},
Metadata = null
};
// Act
var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext",
JsonContent.Create(request));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}

View File

@@ -64,6 +64,17 @@ public class MembersControllerTests : IClassFixture<ApiApplicationFactory>, IAsy
var (userEmail4, orgUser4) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id,
OrganizationUserType.Admin);
var collection1 = await OrganizationTestHelpers.CreateCollectionAsync(_factory, _organization.Id, "Test Collection 1", users:
[
new CollectionAccessSelection { Id = orgUser1.Id, ReadOnly = false, HidePasswords = false, Manage = true },
new CollectionAccessSelection { Id = orgUser3.Id, ReadOnly = true, HidePasswords = false, Manage = false }
]);
var collection2 = await OrganizationTestHelpers.CreateCollectionAsync(_factory, _organization.Id, "Test Collection 2", users:
[
new CollectionAccessSelection { Id = orgUser1.Id, ReadOnly = false, HidePasswords = true, Manage = false }
]);
var response = await _client.GetAsync($"/public/members");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ListResponseModel<MemberResponseModel>>();
@@ -71,23 +82,47 @@ public class MembersControllerTests : IClassFixture<ApiApplicationFactory>, IAsy
Assert.Equal(5, result.Data.Count());
// The owner
Assert.NotNull(result.Data.SingleOrDefault(m =>
m.Email == _ownerEmail && m.Type == OrganizationUserType.Owner));
var ownerResult = result.Data.SingleOrDefault(m => m.Email == _ownerEmail && m.Type == OrganizationUserType.Owner);
Assert.NotNull(ownerResult);
Assert.Empty(ownerResult.Collections);
// The custom user
// The custom user with collections
var user1Result = result.Data.Single(m => m.Email == userEmail1);
Assert.Equal(OrganizationUserType.Custom, user1Result.Type);
AssertHelper.AssertPropertyEqual(
new PermissionsModel { AccessImportExport = true, ManagePolicies = true, AccessReports = true },
user1Result.Permissions);
// Verify collections
Assert.NotNull(user1Result.Collections);
Assert.Equal(2, user1Result.Collections.Count());
var user1Collection1 = user1Result.Collections.Single(c => c.Id == collection1.Id);
Assert.False(user1Collection1.ReadOnly);
Assert.False(user1Collection1.HidePasswords);
Assert.True(user1Collection1.Manage);
var user1Collection2 = user1Result.Collections.Single(c => c.Id == collection2.Id);
Assert.False(user1Collection2.ReadOnly);
Assert.True(user1Collection2.HidePasswords);
Assert.False(user1Collection2.Manage);
// Everyone else
Assert.NotNull(result.Data.SingleOrDefault(m =>
m.Email == userEmail2 && m.Type == OrganizationUserType.Owner));
Assert.NotNull(result.Data.SingleOrDefault(m =>
m.Email == userEmail3 && m.Type == OrganizationUserType.User));
Assert.NotNull(result.Data.SingleOrDefault(m =>
m.Email == userEmail4 && m.Type == OrganizationUserType.Admin));
// The other owner
var user2Result = result.Data.SingleOrDefault(m => m.Email == userEmail2 && m.Type == OrganizationUserType.Owner);
Assert.NotNull(user2Result);
Assert.Empty(user2Result.Collections);
// The user with one collection
var user3Result = result.Data.SingleOrDefault(m => m.Email == userEmail3 && m.Type == OrganizationUserType.User);
Assert.NotNull(user3Result);
Assert.NotNull(user3Result.Collections);
Assert.Single(user3Result.Collections);
var user3Collection1 = user3Result.Collections.Single(c => c.Id == collection1.Id);
Assert.True(user3Collection1.ReadOnly);
Assert.False(user3Collection1.HidePasswords);
Assert.False(user3Collection1.Manage);
// The admin with no collections
var user4Result = result.Data.SingleOrDefault(m => m.Email == userEmail4 && m.Type == OrganizationUserType.Admin);
Assert.NotNull(user4Result);
Assert.Empty(user4Result.Collections);
}
[Fact]

View File

@@ -160,4 +160,86 @@ public class PoliciesControllerTests : IClassFixture<ApiApplicationFactory>, IAs
Assert.Equal(15, data.MinLength);
Assert.Equal(true, data.RequireUpper);
}
[Fact]
public async Task Put_MasterPasswordPolicy_InvalidDataType_ReturnsBadRequest()
{
// Arrange
var policyType = PolicyType.MasterPassword;
var request = new PolicyUpdateRequestModel
{
Enabled = true,
Data = new Dictionary<string, object>
{
{ "minLength", "not a number" }, // Wrong type - should be int
{ "requireUpper", true }
}
};
// Act
var response = await _client.PutAsync($"/public/policies/{policyType}", JsonContent.Create(request));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Put_SendOptionsPolicy_InvalidDataType_ReturnsBadRequest()
{
// Arrange
var policyType = PolicyType.SendOptions;
var request = new PolicyUpdateRequestModel
{
Enabled = true,
Data = new Dictionary<string, object>
{
{ "disableHideEmail", "not a boolean" } // Wrong type - should be bool
}
};
// Act
var response = await _client.PutAsync($"/public/policies/{policyType}", JsonContent.Create(request));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Put_ResetPasswordPolicy_InvalidDataType_ReturnsBadRequest()
{
// Arrange
var policyType = PolicyType.ResetPassword;
var request = new PolicyUpdateRequestModel
{
Enabled = true,
Data = new Dictionary<string, object>
{
{ "autoEnrollEnabled", 123 } // Wrong type - should be bool
}
};
// Act
var response = await _client.PutAsync($"/public/policies/{policyType}", JsonContent.Create(request));
// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Put_PolicyWithNullData_Success()
{
// Arrange
var policyType = PolicyType.DisableSend;
var request = new PolicyUpdateRequestModel
{
Enabled = true,
Data = null
};
// Act
var response = await _client.PutAsync($"/public/policies/{policyType}", JsonContent.Create(request));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}

View File

@@ -151,6 +151,28 @@ public static class OrganizationTestHelpers
return group;
}
/// <summary>
/// Creates a collection with optional user and group associations.
/// </summary>
public static async Task<Collection> CreateCollectionAsync(
ApiApplicationFactory factory,
Guid organizationId,
string name,
IEnumerable<CollectionAccessSelection>? users = null,
IEnumerable<CollectionAccessSelection>? groups = null)
{
var collectionRepository = factory.GetService<ICollectionRepository>();
var collection = new Collection
{
OrganizationId = organizationId,
Name = name,
Type = CollectionType.SharedCollection
};
await collectionRepository.CreateAsync(collection, groups, users);
return collection;
}
/// <summary>
/// Enables the Organization Data Ownership policy for the specified organization.
/// </summary>

View File

@@ -0,0 +1,296 @@
using System.Security.Claims;
using Bit.Api.AdminConsole.Authorization;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Test.AutoFixture.OrganizationUserFixtures;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Authorization;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Authorization;
[SutProviderCustomize]
public class RecoverAccountAuthorizationHandlerTests
{
[Theory, BitAutoData]
public async Task HandleRequirementAsync_CurrentUserIsProvider_TargetUserNotProvider_Authorized(
SutProvider<RecoverAccountAuthorizationHandler> sutProvider,
[OrganizationUser] OrganizationUser targetOrganizationUser,
ClaimsPrincipal claimsPrincipal)
{
// Arrange
var context = new AuthorizationHandlerContext(
[new RecoverAccountAuthorizationRequirement()],
claimsPrincipal,
targetOrganizationUser);
MockOrganizationClaims(sutProvider, claimsPrincipal, targetOrganizationUser, null);
MockCurrentUserIsProvider(sutProvider, claimsPrincipal, targetOrganizationUser);
// Act
await sutProvider.Sut.HandleAsync(context);
// Assert
Assert.True(context.HasSucceeded);
}
[Theory, BitAutoData]
public async Task HandleRequirementAsync_CurrentUserIsNotMemberOrProvider_NotAuthorized(
SutProvider<RecoverAccountAuthorizationHandler> sutProvider,
[OrganizationUser] OrganizationUser targetOrganizationUser,
ClaimsPrincipal claimsPrincipal)
{
// Arrange
var context = new AuthorizationHandlerContext(
[new RecoverAccountAuthorizationRequirement()],
claimsPrincipal,
targetOrganizationUser);
MockOrganizationClaims(sutProvider, claimsPrincipal, targetOrganizationUser, null);
// Act
await sutProvider.Sut.HandleAsync(context);
// Assert
AssertFailed(context, RecoverAccountAuthorizationHandler.FailureReason);
}
// Pairing of CurrentContextOrganization (current user permissions) and target user role
// Read this as: a ___ can recover the account for a ___
public static IEnumerable<object[]> AuthorizedRoleCombinations => new object[][]
{
[new CurrentContextOrganization { Type = OrganizationUserType.Owner }, OrganizationUserType.Owner],
[new CurrentContextOrganization { Type = OrganizationUserType.Owner }, OrganizationUserType.Admin],
[new CurrentContextOrganization { Type = OrganizationUserType.Owner }, OrganizationUserType.Custom],
[new CurrentContextOrganization { Type = OrganizationUserType.Owner }, OrganizationUserType.User],
[new CurrentContextOrganization { Type = OrganizationUserType.Admin }, OrganizationUserType.Admin],
[new CurrentContextOrganization { Type = OrganizationUserType.Admin }, OrganizationUserType.Custom],
[new CurrentContextOrganization { Type = OrganizationUserType.Admin }, OrganizationUserType.User],
[new CurrentContextOrganization { Type = OrganizationUserType.Custom, Permissions = new Permissions { ManageResetPassword = true}}, OrganizationUserType.Custom],
[new CurrentContextOrganization { Type = OrganizationUserType.Custom, Permissions = new Permissions { ManageResetPassword = true}}, OrganizationUserType.User],
};
[Theory, BitMemberAutoData(nameof(AuthorizedRoleCombinations))]
public async Task AuthorizeMemberAsync_RecoverEqualOrLesserRoles_TargetUserNotProvider_Authorized(
CurrentContextOrganization currentContextOrganization,
OrganizationUserType targetOrganizationUserType,
SutProvider<RecoverAccountAuthorizationHandler> sutProvider,
[OrganizationUser] OrganizationUser targetOrganizationUser,
ClaimsPrincipal claimsPrincipal)
{
// Arrange
targetOrganizationUser.Type = targetOrganizationUserType;
currentContextOrganization.Id = targetOrganizationUser.OrganizationId;
var context = new AuthorizationHandlerContext(
[new RecoverAccountAuthorizationRequirement()],
claimsPrincipal,
targetOrganizationUser);
MockOrganizationClaims(sutProvider, claimsPrincipal, targetOrganizationUser, currentContextOrganization);
// Act
await sutProvider.Sut.HandleAsync(context);
// Assert
Assert.True(context.HasSucceeded);
}
// Pairing of CurrentContextOrganization (current user permissions) and target user role
// Read this as: a ___ cannot recover the account for a ___
public static IEnumerable<object[]> UnauthorizedRoleCombinations => new object[][]
{
// These roles should fail because you cannot recover a greater role
[new CurrentContextOrganization { Type = OrganizationUserType.Admin }, OrganizationUserType.Owner],
[new CurrentContextOrganization { Type = OrganizationUserType.Custom, Permissions = new Permissions { ManageResetPassword = true}}, OrganizationUserType.Owner],
[new CurrentContextOrganization { Type = OrganizationUserType.Custom, Permissions = new Permissions { ManageResetPassword = true} }, OrganizationUserType.Admin],
// These roles are never authorized to recover any account
[new CurrentContextOrganization { Type = OrganizationUserType.User }, OrganizationUserType.Owner],
[new CurrentContextOrganization { Type = OrganizationUserType.User }, OrganizationUserType.Admin],
[new CurrentContextOrganization { Type = OrganizationUserType.User }, OrganizationUserType.Custom],
[new CurrentContextOrganization { Type = OrganizationUserType.User }, OrganizationUserType.User],
[new CurrentContextOrganization { Type = OrganizationUserType.Custom }, OrganizationUserType.Owner],
[new CurrentContextOrganization { Type = OrganizationUserType.Custom }, OrganizationUserType.Admin],
[new CurrentContextOrganization { Type = OrganizationUserType.Custom }, OrganizationUserType.Custom],
[new CurrentContextOrganization { Type = OrganizationUserType.Custom }, OrganizationUserType.User],
};
[Theory, BitMemberAutoData(nameof(UnauthorizedRoleCombinations))]
public async Task AuthorizeMemberAsync_InvalidRoles_TargetUserNotProvider_Unauthorized(
CurrentContextOrganization currentContextOrganization,
OrganizationUserType targetOrganizationUserType,
SutProvider<RecoverAccountAuthorizationHandler> sutProvider,
[OrganizationUser] OrganizationUser targetOrganizationUser,
ClaimsPrincipal claimsPrincipal)
{
// Arrange
targetOrganizationUser.Type = targetOrganizationUserType;
currentContextOrganization.Id = targetOrganizationUser.OrganizationId;
var context = new AuthorizationHandlerContext(
[new RecoverAccountAuthorizationRequirement()],
claimsPrincipal,
targetOrganizationUser);
MockOrganizationClaims(sutProvider, claimsPrincipal, targetOrganizationUser, currentContextOrganization);
// Act
await sutProvider.Sut.HandleAsync(context);
// Assert
AssertFailed(context, RecoverAccountAuthorizationHandler.FailureReason);
}
[Theory, BitAutoData]
public async Task HandleRequirementAsync_TargetUserIdIsNull_DoesNotBlock(
SutProvider<RecoverAccountAuthorizationHandler> sutProvider,
OrganizationUser targetOrganizationUser,
ClaimsPrincipal claimsPrincipal)
{
// Arrange
targetOrganizationUser.UserId = null;
MockCurrentUserIsOwner(sutProvider, claimsPrincipal, targetOrganizationUser);
var context = new AuthorizationHandlerContext(
[new RecoverAccountAuthorizationRequirement()],
claimsPrincipal,
targetOrganizationUser);
// Act
await sutProvider.Sut.HandleAsync(context);
// Assert
Assert.True(context.HasSucceeded);
// This should shortcut the provider escalation check
await sutProvider.GetDependency<IProviderUserRepository>().DidNotReceiveWithAnyArgs()
.GetManyByUserAsync(Arg.Any<Guid>());
}
[Theory, BitAutoData]
public async Task HandleRequirementAsync_CurrentUserIsMemberOfAllTargetUserProviders_DoesNotBlock(
SutProvider<RecoverAccountAuthorizationHandler> sutProvider,
[OrganizationUser] OrganizationUser targetOrganizationUser,
ClaimsPrincipal claimsPrincipal,
Guid providerId1,
Guid providerId2)
{
// Arrange
var targetUserProviders = new List<ProviderUser>
{
new() { ProviderId = providerId1, UserId = targetOrganizationUser.UserId },
new() { ProviderId = providerId2, UserId = targetOrganizationUser.UserId }
};
var context = new AuthorizationHandlerContext(
[new RecoverAccountAuthorizationRequirement()],
claimsPrincipal,
targetOrganizationUser);
MockCurrentUserIsProvider(sutProvider, claimsPrincipal, targetOrganizationUser);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByUserAsync(targetOrganizationUser.UserId!.Value)
.Returns(targetUserProviders);
sutProvider.GetDependency<ICurrentContext>()
.ProviderUser(providerId1)
.Returns(true);
sutProvider.GetDependency<ICurrentContext>()
.ProviderUser(providerId2)
.Returns(true);
// Act
await sutProvider.Sut.HandleAsync(context);
// Assert
Assert.True(context.HasSucceeded);
}
[Theory, BitAutoData]
public async Task HandleRequirementAsync_CurrentUserMissingProviderMembership_Blocks(
SutProvider<RecoverAccountAuthorizationHandler> sutProvider,
[OrganizationUser] OrganizationUser targetOrganizationUser,
ClaimsPrincipal claimsPrincipal,
Guid providerId1,
Guid providerId2)
{
// Arrange
var targetUserProviders = new List<ProviderUser>
{
new() { ProviderId = providerId1, UserId = targetOrganizationUser.UserId },
new() { ProviderId = providerId2, UserId = targetOrganizationUser.UserId }
};
var context = new AuthorizationHandlerContext(
[new RecoverAccountAuthorizationRequirement()],
claimsPrincipal,
targetOrganizationUser);
MockCurrentUserIsOwner(sutProvider, claimsPrincipal, targetOrganizationUser);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByUserAsync(targetOrganizationUser.UserId!.Value)
.Returns(targetUserProviders);
sutProvider.GetDependency<ICurrentContext>()
.ProviderUser(providerId1)
.Returns(true);
// Not a member of this provider
sutProvider.GetDependency<ICurrentContext>()
.ProviderUser(providerId2)
.Returns(false);
// Act
await sutProvider.Sut.HandleAsync(context);
// Assert
AssertFailed(context, RecoverAccountAuthorizationHandler.ProviderFailureReason);
}
private static void MockOrganizationClaims(SutProvider<RecoverAccountAuthorizationHandler> sutProvider,
ClaimsPrincipal currentUser, OrganizationUser targetOrganizationUser,
CurrentContextOrganization? currentContextOrganization)
{
sutProvider.GetDependency<IOrganizationContext>()
.GetOrganizationClaims(currentUser, targetOrganizationUser.OrganizationId)
.Returns(currentContextOrganization);
}
private static void MockCurrentUserIsProvider(SutProvider<RecoverAccountAuthorizationHandler> sutProvider,
ClaimsPrincipal currentUser, OrganizationUser targetOrganizationUser)
{
sutProvider.GetDependency<IOrganizationContext>()
.IsProviderUserForOrganization(currentUser, targetOrganizationUser.OrganizationId)
.Returns(true);
}
private static void MockCurrentUserIsOwner(SutProvider<RecoverAccountAuthorizationHandler> sutProvider,
ClaimsPrincipal currentUser, OrganizationUser targetOrganizationUser)
{
var currentContextOrganization = new CurrentContextOrganization
{
Id = targetOrganizationUser.OrganizationId,
Type = OrganizationUserType.Owner
};
sutProvider.GetDependency<IOrganizationContext>()
.GetOrganizationClaims(currentUser, targetOrganizationUser.OrganizationId)
.Returns(currentContextOrganization);
}
private static void AssertFailed(AuthorizationHandlerContext context, string expectedMessage)
{
Assert.True(context.HasFailed);
var failureReason = Assert.Single(context.FailureReasons);
Assert.Equal(expectedMessage, failureReason.Message);
}
}

View File

@@ -1,11 +1,14 @@
using System.Security.Claims;
using Bit.Api.AdminConsole.Authorization;
using Bit.Api.AdminConsole.Controllers;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Models.Request.Organizations;
using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
@@ -16,6 +19,7 @@ using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Api;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations;
@@ -30,6 +34,7 @@ using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using NSubstitute;
using Xunit;
@@ -440,4 +445,153 @@ public class OrganizationUsersControllerTests
Assert.Equal("Master Password reset is required, but not provided.", exception.Message);
}
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagDisabled_CallsLegacyPath(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model,
SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(false);
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(orgId).Returns(true);
sutProvider.GetDependency<IUserService>().AdminResetPasswordAsync(Arg.Any<OrganizationUserType>(), orgId, orgUserId, model.NewMasterPasswordHash, model.Key)
.Returns(Microsoft.AspNetCore.Identity.IdentityResult.Success);
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
Assert.IsType<Ok>(result);
await sutProvider.GetDependency<IUserService>().Received(1)
.AdminResetPasswordAsync(OrganizationUserType.Owner, orgId, orgUserId, model.NewMasterPasswordHash, model.Key);
}
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagDisabled_WhenOrgUserTypeIsNull_ReturnsNotFound(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model,
SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(false);
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(orgId).Returns(false);
sutProvider.GetDependency<ICurrentContext>().Organizations.Returns(new List<CurrentContextOrganization>());
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
Assert.IsType<NotFound>(result);
}
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagDisabled_WhenAdminResetPasswordFails_ReturnsBadRequest(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model,
SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(false);
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(orgId).Returns(true);
sutProvider.GetDependency<IUserService>().AdminResetPasswordAsync(Arg.Any<OrganizationUserType>(), orgId, orgUserId, model.NewMasterPasswordHash, model.Key)
.Returns(Microsoft.AspNetCore.Identity.IdentityResult.Failed(new Microsoft.AspNetCore.Identity.IdentityError { Description = "Error 1" }));
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
Assert.IsType<BadRequest<ModelStateDictionary>>(result);
}
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagEnabled_WhenOrganizationUserNotFound_ReturnsNotFound(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model,
SutProvider<OrganizationUsersController> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns((OrganizationUser)null);
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
Assert.IsType<NotFound>(result);
}
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagEnabled_WhenOrganizationIdMismatch_ReturnsNotFound(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser,
SutProvider<OrganizationUsersController> sutProvider)
{
organizationUser.OrganizationId = Guid.NewGuid();
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns(organizationUser);
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
Assert.IsType<NotFound>(result);
}
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagEnabled_WhenAuthorizationFails_ReturnsBadRequest(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser,
SutProvider<OrganizationUsersController> sutProvider)
{
organizationUser.OrganizationId = orgId;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns(organizationUser);
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(
Arg.Any<ClaimsPrincipal>(),
organizationUser,
Arg.Is<IEnumerable<IAuthorizationRequirement>>(x => x.SingleOrDefault() is RecoverAccountAuthorizationRequirement))
.Returns(AuthorizationResult.Failed());
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
Assert.IsType<BadRequest<ErrorResponseModel>>(result);
}
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagEnabled_WhenRecoverAccountSucceeds_ReturnsOk(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser,
SutProvider<OrganizationUsersController> sutProvider)
{
organizationUser.OrganizationId = orgId;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns(organizationUser);
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(
Arg.Any<ClaimsPrincipal>(),
organizationUser,
Arg.Is<IEnumerable<IAuthorizationRequirement>>(x => x.SingleOrDefault() is RecoverAccountAuthorizationRequirement))
.Returns(AuthorizationResult.Success());
sutProvider.GetDependency<IAdminRecoverAccountCommand>()
.RecoverAccountAsync(orgId, organizationUser, model.NewMasterPasswordHash, model.Key)
.Returns(Microsoft.AspNetCore.Identity.IdentityResult.Success);
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
Assert.IsType<Ok>(result);
await sutProvider.GetDependency<IAdminRecoverAccountCommand>().Received(1)
.RecoverAccountAsync(orgId, organizationUser, model.NewMasterPasswordHash, model.Key);
}
[Theory]
[BitAutoData]
public async Task PutResetPassword_WithFeatureFlagEnabled_WhenRecoverAccountFails_ReturnsBadRequest(
Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser,
SutProvider<OrganizationUsersController> sutProvider)
{
organizationUser.OrganizationId = orgId;
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByIdAsync(orgUserId).Returns(organizationUser);
sutProvider.GetDependency<IAuthorizationService>()
.AuthorizeAsync(
Arg.Any<ClaimsPrincipal>(),
organizationUser,
Arg.Is<IEnumerable<IAuthorizationRequirement>>(x => x.SingleOrDefault() is RecoverAccountAuthorizationRequirement))
.Returns(AuthorizationResult.Success());
sutProvider.GetDependency<IAdminRecoverAccountCommand>()
.RecoverAccountAsync(orgId, organizationUser, model.NewMasterPasswordHash, model.Key)
.Returns(Microsoft.AspNetCore.Identity.IdentityResult.Failed(new Microsoft.AspNetCore.Identity.IdentityError { Description = "Error message" }));
var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model);
Assert.IsType<BadRequest<ModelStateDictionary>>(result);
}
}

View File

@@ -54,7 +54,7 @@ public class SavePolicyRequestTests
}
[Theory, BitAutoData]
public async Task ToSavePolicyModelAsync_WithNullData_HandlesCorrectly(
public async Task ToSavePolicyModelAsync_WithEmptyData_HandlesCorrectly(
Guid organizationId,
Guid userId)
{
@@ -68,10 +68,8 @@ public class SavePolicyRequestTests
Policy = new PolicyRequestModel
{
Type = PolicyType.SingleOrg,
Enabled = false,
Data = null
},
Metadata = null
Enabled = false
}
};
// Act
@@ -100,10 +98,8 @@ public class SavePolicyRequestTests
Policy = new PolicyRequestModel
{
Type = PolicyType.SingleOrg,
Enabled = false,
Data = null
},
Metadata = null
Enabled = false
}
};
// Act
@@ -133,8 +129,7 @@ public class SavePolicyRequestTests
Policy = new PolicyRequestModel
{
Type = PolicyType.OrganizationDataOwnership,
Enabled = true,
Data = null
Enabled = true
},
Metadata = new Dictionary<string, object>
{
@@ -152,7 +147,7 @@ public class SavePolicyRequestTests
}
[Theory, BitAutoData]
public async Task ToSavePolicyModelAsync_OrganizationDataOwnership_WithNullMetadata_ReturnsEmptyMetadata(
public async Task ToSavePolicyModelAsync_OrganizationDataOwnership_WithEmptyMetadata_ReturnsEmptyMetadata(
Guid organizationId,
Guid userId)
{
@@ -166,10 +161,8 @@ public class SavePolicyRequestTests
Policy = new PolicyRequestModel
{
Type = PolicyType.OrganizationDataOwnership,
Enabled = true,
Data = null
},
Metadata = null
Enabled = true
}
};
// Act
@@ -246,8 +239,7 @@ public class SavePolicyRequestTests
Policy = new PolicyRequestModel
{
Type = PolicyType.MaximumVaultTimeout,
Enabled = true,
Data = null
Enabled = true
},
Metadata = new Dictionary<string, object>
{
@@ -280,8 +272,7 @@ public class SavePolicyRequestTests
Policy = new PolicyRequestModel
{
Type = PolicyType.OrganizationDataOwnership,
Enabled = true,
Data = null
Enabled = true
},
Metadata = errorDictionary
};

View File

@@ -1,4 +1,5 @@
using Bit.Api.Dirt.Controllers;
using Bit.Api.Dirt.Models.Response;
using Bit.Core.Context;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Models.Data;
@@ -39,7 +40,8 @@ public class OrganizationReportControllerTests
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
Assert.Equal(expectedReport, okResult.Value);
var expectedResponse = new OrganizationReportResponseModel(expectedReport);
Assert.Equivalent(expectedResponse, okResult.Value);
}
[Theory, BitAutoData]
@@ -262,7 +264,8 @@ public class OrganizationReportControllerTests
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
Assert.Equal(expectedReport, okResult.Value);
var expectedResponse = new OrganizationReportResponseModel(expectedReport);
Assert.Equivalent(expectedResponse, okResult.Value);
}
[Theory, BitAutoData]
@@ -365,7 +368,8 @@ public class OrganizationReportControllerTests
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
Assert.Equal(expectedReport, okResult.Value);
var expectedResponse = new OrganizationReportResponseModel(expectedReport);
Assert.Equivalent(expectedResponse, okResult.Value);
}
[Theory, BitAutoData]
@@ -597,7 +601,8 @@ public class OrganizationReportControllerTests
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
Assert.Equal(expectedReport, okResult.Value);
var expectedResponse = new OrganizationReportResponseModel(expectedReport);
Assert.Equivalent(expectedResponse, okResult.Value);
}
[Theory, BitAutoData]
@@ -812,7 +817,8 @@ public class OrganizationReportControllerTests
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
Assert.Equal(expectedReport, okResult.Value);
var expectedResponse = new OrganizationReportResponseModel(expectedReport);
Assert.Equivalent(expectedResponse, okResult.Value);
}
[Theory, BitAutoData]
@@ -1050,7 +1056,8 @@ public class OrganizationReportControllerTests
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
Assert.Equal(expectedReport, okResult.Value);
var expectedResponse = new OrganizationReportResponseModel(expectedReport);
Assert.Equivalent(expectedResponse, okResult.Value);
}
[Theory, BitAutoData]

View File

@@ -0,0 +1,221 @@
using Bit.Api.Models.Public.Request;
using Bit.Api.Models.Public.Response;
using Bit.Api.Utilities.DiagnosticTools;
using Bit.Core;
using Bit.Core.Models.Data;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.Utilities.DiagnosticTools;
public class EventDiagnosticLoggerTests
{
[Theory, BitAutoData]
public void LogAggregateData_WithPublicResponse_FeatureFlagEnabled_LogsInformation(
Guid organizationId)
{
// Arrange
var logger = Substitute.For<ILogger>();
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.EventDiagnosticLogging).Returns(true);
var request = new EventFilterRequestModel()
{
Start = DateTime.UtcNow.AddMinutes(-3),
End = DateTime.UtcNow,
ActingUserId = Guid.NewGuid(),
ItemId = Guid.NewGuid(),
};
var newestEvent = Substitute.For<IEvent>();
newestEvent.Date.Returns(DateTime.UtcNow);
var middleEvent = Substitute.For<IEvent>();
middleEvent.Date.Returns(DateTime.UtcNow.AddDays(-1));
var oldestEvent = Substitute.For<IEvent>();
oldestEvent.Date.Returns(DateTime.UtcNow.AddDays(-3));
var eventResponses = new List<EventResponseModel>
{
new (newestEvent),
new (middleEvent),
new (oldestEvent)
};
var response = new PagedListResponseModel<EventResponseModel>(eventResponses, "continuation-token");
// Act
logger.LogAggregateData(featureService, organizationId, response, request);
// Assert
logger.Received(1).Log(
LogLevel.Information,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains(organizationId.ToString()) &&
o.ToString().Contains($"Event count:{eventResponses.Count}") &&
o.ToString().Contains($"newest record:{newestEvent.Date:O}") &&
o.ToString().Contains($"oldest record:{oldestEvent.Date:O}") &&
o.ToString().Contains("HasMore:True") &&
o.ToString().Contains($"Start:{request.Start:o}") &&
o.ToString().Contains($"End:{request.End:o}") &&
o.ToString().Contains($"ActingUserId:{request.ActingUserId}") &&
o.ToString().Contains($"ItemId:{request.ItemId}"))
,
null,
Arg.Any<Func<object, Exception, string>>());
}
[Theory, BitAutoData]
public void LogAggregateData_WithPublicResponse_FeatureFlagDisabled_DoesNotLog(
Guid organizationId,
EventFilterRequestModel request)
{
// Arrange
var logger = Substitute.For<ILogger>();
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.EventDiagnosticLogging).Returns(false);
PagedListResponseModel<EventResponseModel> dummy = null;
// Act
logger.LogAggregateData(featureService, organizationId, dummy, request);
// Assert
logger.DidNotReceive().Log(
LogLevel.Information,
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
}
[Theory, BitAutoData]
public void LogAggregateData_WithPublicResponse_EmptyData_LogsZeroCount(
Guid organizationId)
{
// Arrange
var logger = Substitute.For<ILogger>();
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.EventDiagnosticLogging).Returns(true);
var request = new EventFilterRequestModel()
{
Start = null,
End = null,
ActingUserId = null,
ItemId = null,
ContinuationToken = null,
};
var response = new PagedListResponseModel<EventResponseModel>(new List<EventResponseModel>(), null);
// Act
logger.LogAggregateData(featureService, organizationId, response, request);
// Assert
logger.Received(1).Log(
LogLevel.Information,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains(organizationId.ToString()) &&
o.ToString().Contains("Event count:0") &&
o.ToString().Contains("HasMore:False")),
null,
Arg.Any<Func<object, Exception, string>>());
}
[Theory, BitAutoData]
public void LogAggregateData_WithInternalResponse_FeatureFlagDisabled_DoesNotLog(Guid organizationId)
{
// Arrange
var logger = Substitute.For<ILogger>();
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.EventDiagnosticLogging).Returns(false);
// Act
logger.LogAggregateData(featureService, organizationId, null, null, null, null);
// Assert
logger.DidNotReceive().Log(
LogLevel.Information,
Arg.Any<EventId>(),
Arg.Any<object>(),
Arg.Any<Exception>(),
Arg.Any<Func<object, Exception, string>>());
}
[Theory, BitAutoData]
public void LogAggregateData_WithInternalResponse_EmptyData_LogsZeroCount(
Guid organizationId)
{
// Arrange
var logger = Substitute.For<ILogger>();
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.EventDiagnosticLogging).Returns(true);
Bit.Api.Models.Response.EventResponseModel[] emptyEvents = [];
// Act
logger.LogAggregateData(featureService, organizationId, emptyEvents, null, null, null);
// Assert
logger.Received(1).Log(
LogLevel.Information,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains(organizationId.ToString()) &&
o.ToString().Contains("Event count:0") &&
o.ToString().Contains("HasMore:False")),
null,
Arg.Any<Func<object, Exception, string>>());
}
[Theory, BitAutoData]
public void LogAggregateData_WithInternalResponse_FeatureFlagEnabled_LogsInformation(
Guid organizationId)
{
// Arrange
var logger = Substitute.For<ILogger>();
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.EventDiagnosticLogging).Returns(true);
var newestEvent = Substitute.For<IEvent>();
newestEvent.Date.Returns(DateTime.UtcNow);
var middleEvent = Substitute.For<IEvent>();
middleEvent.Date.Returns(DateTime.UtcNow.AddDays(-1));
var oldestEvent = Substitute.For<IEvent>();
oldestEvent.Date.Returns(DateTime.UtcNow.AddDays(-2));
var events = new List<Bit.Api.Models.Response.EventResponseModel>
{
new (newestEvent),
new (middleEvent),
new (oldestEvent)
};
var queryStart = DateTime.UtcNow.AddMinutes(-3);
var queryEnd = DateTime.UtcNow;
const string continuationToken = "continuation-token";
// Act
logger.LogAggregateData(featureService, organizationId, events, continuationToken, queryStart, queryEnd);
// Assert
logger.Received(1).Log(
LogLevel.Information,
Arg.Any<EventId>(),
Arg.Is<object>(o =>
o.ToString().Contains(organizationId.ToString()) &&
o.ToString().Contains($"Event count:{events.Count}") &&
o.ToString().Contains($"newest record:{newestEvent.Date:O}") &&
o.ToString().Contains($"oldest record:{oldestEvent.Date:O}") &&
o.ToString().Contains("HasMore:True") &&
o.ToString().Contains($"Start:{queryStart:o}") &&
o.ToString().Contains($"End:{queryEnd:o}"))
,
null,
Arg.Any<Func<object, Exception, string>>());
}
}

View File

@@ -1,7 +1,7 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Bit.Core.Models.Mail;
using Bit.Core.Services;
using Bit.Core.Platform.Mail.Delivery;
using Bit.Core.Settings;
using MailKit.Security;
using Microsoft.Extensions.Logging;

View File

@@ -1,4 +1,6 @@
using AutoFixture;
using System.Reflection;
using AutoFixture;
using AutoFixture.Xunit2;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
@@ -23,6 +25,7 @@ public class CurrentContextOrganizationCustomization : ICustomization
}
}
[AttributeUsage(AttributeTargets.Method)]
public class CurrentContextOrganizationCustomizeAttribute : BitCustomizeAttribute
{
public Guid Id { get; set; }
@@ -38,3 +41,19 @@ public class CurrentContextOrganizationCustomizeAttribute : BitCustomizeAttribut
AccessSecretsManager = AccessSecretsManager
};
}
public class CurrentContextOrganizationAttribute : CustomizeAttribute
{
public Guid Id { get; set; }
public OrganizationUserType Type { get; set; } = OrganizationUserType.User;
public Permissions Permissions { get; set; } = new();
public bool AccessSecretsManager { get; set; } = false;
public override ICustomization GetCustomization(ParameterInfo _) => new CurrentContextOrganizationCustomization
{
Id = Id,
Type = Type,
Permissions = Permissions,
AccessSecretsManager = AccessSecretsManager
};
}

View File

@@ -0,0 +1,296 @@
using AutoFixture;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Test.AutoFixture.OrganizationUserFixtures;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Identity;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.AccountRecovery;
[SutProviderCustomize]
public class AdminRecoverAccountCommandTests
{
[Theory]
[BitAutoData]
public async Task RecoverAccountAsync_Success(
string newMasterPassword,
string key,
Organization organization,
OrganizationUser organizationUser,
User user,
SutProvider<AdminRecoverAccountCommand> sutProvider)
{
// Arrange
SetupValidOrganization(sutProvider, organization);
SetupValidPolicy(sutProvider, organization);
SetupValidOrganizationUser(organizationUser, organization.Id);
SetupValidUser(sutProvider, user, organizationUser);
SetupSuccessfulPasswordUpdate(sutProvider, user, newMasterPassword);
// Act
var result = await sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key);
// Assert
Assert.True(result.Succeeded);
await AssertSuccessAsync(sutProvider, user, key, organization, organizationUser);
}
[Theory]
[BitAutoData]
public async Task RecoverAccountAsync_OrganizationDoesNotExist_ThrowsBadRequest(
[OrganizationUser] OrganizationUser organizationUser,
string newMasterPassword,
string key,
SutProvider<AdminRecoverAccountCommand> sutProvider)
{
// Arrange
var orgId = Guid.NewGuid();
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(orgId)
.Returns((Organization)null);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.RecoverAccountAsync(orgId, organizationUser, newMasterPassword, key));
Assert.Equal("Organization does not allow password reset.", exception.Message);
}
[Theory]
[BitAutoData]
public async Task RecoverAccountAsync_OrganizationDoesNotAllowResetPassword_ThrowsBadRequest(
string newMasterPassword,
string key,
Organization organization,
[OrganizationUser] OrganizationUser organizationUser,
SutProvider<AdminRecoverAccountCommand> sutProvider)
{
// Arrange
organization.UseResetPassword = false;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key));
Assert.Equal("Organization does not allow password reset.", exception.Message);
}
public static IEnumerable<object[]> InvalidPolicies => new object[][]
{
[new Policy { Type = PolicyType.ResetPassword, Enabled = false }], [null]
};
[Theory]
[BitMemberAutoData(nameof(InvalidPolicies))]
public async Task RecoverAccountAsync_InvalidPolicy_ThrowsBadRequest(
Policy resetPasswordPolicy,
string newMasterPassword,
string key,
Organization organization,
SutProvider<AdminRecoverAccountCommand> sutProvider)
{
// Arrange
SetupValidOrganization(sutProvider, organization);
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword)
.Returns(resetPasswordPolicy);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.RecoverAccountAsync(organization.Id, new OrganizationUser { Id = Guid.NewGuid() },
newMasterPassword, key));
Assert.Equal("Organization does not have the password reset policy enabled.", exception.Message);
}
public static IEnumerable<object[]> InvalidOrganizationUsers()
{
// Make an organization so we can use its Id
var organization = new Fixture().Create<Organization>();
var nonConfirmed = new OrganizationUser
{
Id = Guid.NewGuid(),
OrganizationId = organization.Id,
Status = OrganizationUserStatusType.Invited
};
yield return [nonConfirmed, organization];
var wrongOrganization = new OrganizationUser
{
Status = OrganizationUserStatusType.Confirmed,
OrganizationId = Guid.NewGuid(), // Different org
ResetPasswordKey = "test-key",
UserId = Guid.NewGuid(),
};
yield return [wrongOrganization, organization];
var nullResetPasswordKey = new OrganizationUser
{
Status = OrganizationUserStatusType.Confirmed,
OrganizationId = organization.Id,
ResetPasswordKey = null,
UserId = Guid.NewGuid(),
};
yield return [nullResetPasswordKey, organization];
var emptyResetPasswordKey = new OrganizationUser
{
Status = OrganizationUserStatusType.Confirmed,
OrganizationId = organization.Id,
ResetPasswordKey = "",
UserId = Guid.NewGuid(),
};
yield return [emptyResetPasswordKey, organization];
var nullUserId = new OrganizationUser
{
Status = OrganizationUserStatusType.Confirmed,
OrganizationId = organization.Id,
ResetPasswordKey = "test-key",
UserId = null,
};
yield return [nullUserId, organization];
}
[Theory]
[BitMemberAutoData(nameof(InvalidOrganizationUsers))]
public async Task RecoverAccountAsync_OrganizationUserIsInvalid_ThrowsBadRequest(
OrganizationUser organizationUser,
Organization organization,
string newMasterPassword,
string key,
SutProvider<AdminRecoverAccountCommand> sutProvider)
{
// Arrange
SetupValidOrganization(sutProvider, organization);
SetupValidPolicy(sutProvider, organization);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key));
Assert.Equal("Organization User not valid", exception.Message);
}
[Theory]
[BitAutoData]
public async Task RecoverAccountAsync_UserDoesNotExist_ThrowsNotFoundException(
string newMasterPassword,
string key,
Organization organization,
OrganizationUser organizationUser,
SutProvider<AdminRecoverAccountCommand> sutProvider)
{
// Arrange
SetupValidOrganization(sutProvider, organization);
SetupValidPolicy(sutProvider, organization);
SetupValidOrganizationUser(organizationUser, organization.Id);
sutProvider.GetDependency<IUserService>()
.GetUserByIdAsync(organizationUser.UserId!.Value)
.Returns((User)null);
// Act & Assert
await Assert.ThrowsAsync<NotFoundException>(() =>
sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key));
}
[Theory]
[BitAutoData]
public async Task RecoverAccountAsync_UserUsesKeyConnector_ThrowsBadRequest(
string newMasterPassword,
string key,
Organization organization,
OrganizationUser organizationUser,
User user,
SutProvider<AdminRecoverAccountCommand> sutProvider)
{
// Arrange
SetupValidOrganization(sutProvider, organization);
SetupValidPolicy(sutProvider, organization);
SetupValidOrganizationUser(organizationUser, organization.Id);
user.UsesKeyConnector = true;
sutProvider.GetDependency<IUserService>()
.GetUserByIdAsync(organizationUser.UserId!.Value)
.Returns(user);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key));
Assert.Equal("Cannot reset password of a user with Key Connector.", exception.Message);
}
private static void SetupValidOrganization(SutProvider<AdminRecoverAccountCommand> sutProvider, Organization organization)
{
organization.UseResetPassword = true;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
}
private static void SetupValidPolicy(SutProvider<AdminRecoverAccountCommand> sutProvider, Organization organization)
{
var policy = new Policy { Type = PolicyType.ResetPassword, Enabled = true };
sutProvider.GetDependency<IPolicyRepository>()
.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword)
.Returns(policy);
}
private static void SetupValidOrganizationUser(OrganizationUser organizationUser, Guid orgId)
{
organizationUser.Status = OrganizationUserStatusType.Confirmed;
organizationUser.OrganizationId = orgId;
organizationUser.ResetPasswordKey = "test-key";
organizationUser.Type = OrganizationUserType.User;
}
private static void SetupValidUser(SutProvider<AdminRecoverAccountCommand> sutProvider, User user, OrganizationUser organizationUser)
{
user.Id = organizationUser.UserId!.Value;
user.UsesKeyConnector = false;
sutProvider.GetDependency<IUserService>()
.GetUserByIdAsync(user.Id)
.Returns(user);
}
private static void SetupSuccessfulPasswordUpdate(SutProvider<AdminRecoverAccountCommand> sutProvider, User user, string newMasterPassword)
{
sutProvider.GetDependency<IUserService>()
.UpdatePasswordHash(user, newMasterPassword)
.Returns(IdentityResult.Success);
}
private static async Task AssertSuccessAsync(SutProvider<AdminRecoverAccountCommand> sutProvider, User user, string key,
Organization organization, OrganizationUser organizationUser)
{
await sutProvider.GetDependency<IUserRepository>().Received(1).ReplaceAsync(
Arg.Is<User>(u =>
u.Id == user.Id &&
u.Key == key &&
u.ForcePasswordReset == true &&
u.RevisionDate == u.AccountRevisionDate &&
u.LastPasswordChangeDate == u.RevisionDate));
await sutProvider.GetDependency<IMailService>().Received(1).SendAdminResetPasswordEmailAsync(
Arg.Is(user.Email),
Arg.Is(user.Name),
Arg.Is(organization.DisplayName()));
await sutProvider.GetDependency<IEventService>().Received(1).LogOrganizationUserEventAsync(
Arg.Is(organizationUser),
Arg.Is(EventType.OrganizationUser_AdminResetPassword));
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushLogOutAsync(
Arg.Is(user.Id));
}
}

View File

@@ -0,0 +1,28 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
public class UriMatchDefaultPolicyValidatorTests
{
private readonly UriMatchDefaultPolicyValidator _validator = new();
[Fact]
// Test that the Type property returns the correct PolicyType for this validator
public void Type_ReturnsUriMatchDefaults()
{
Assert.Equal(PolicyType.UriMatchDefaults, _validator.Type);
}
[Fact]
// Test that the RequiredPolicies property returns exactly one policy (SingleOrg) as a prerequisite
// for enabling the UriMatchDefaults policy, ensuring proper policy dependency enforcement
public void RequiredPolicies_ReturnsSingleOrgPolicy()
{
var requiredPolicies = _validator.RequiredPolicies.ToList();
Assert.Single(requiredPolicies);
Assert.Contains(PolicyType.SingleOrg, requiredPolicies);
}
}

View File

@@ -0,0 +1,59 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Exceptions;
using Xunit;
namespace Bit.Core.Test.AdminConsole.Utilities;
public class PolicyDataValidatorTests
{
[Fact]
public void ValidateAndSerialize_NullData_ReturnsNull()
{
var result = PolicyDataValidator.ValidateAndSerialize(null, PolicyType.MasterPassword);
Assert.Null(result);
}
[Fact]
public void ValidateAndSerialize_ValidData_ReturnsSerializedJson()
{
var data = new Dictionary<string, object> { { "minLength", 12 } };
var result = PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword);
Assert.NotNull(result);
Assert.Contains("\"minLength\":12", result);
}
[Fact]
public void ValidateAndSerialize_InvalidDataType_ThrowsBadRequestException()
{
var data = new Dictionary<string, object> { { "minLength", "not a number" } };
var exception = Assert.Throws<BadRequestException>(() =>
PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword));
Assert.Contains("Invalid data for MasterPassword policy", exception.Message);
Assert.Contains("minLength", exception.Message);
}
[Fact]
public void ValidateAndDeserializeMetadata_NullMetadata_ReturnsEmptyMetadataModel()
{
var result = PolicyDataValidator.ValidateAndDeserializeMetadata(null, PolicyType.SingleOrg);
Assert.IsType<EmptyMetadataModel>(result);
}
[Fact]
public void ValidateAndDeserializeMetadata_ValidMetadata_ReturnsModel()
{
var metadata = new Dictionary<string, object> { { "defaultUserCollectionName", "collection name" } };
var result = PolicyDataValidator.ValidateAndDeserializeMetadata(metadata, PolicyType.OrganizationDataOwnership);
Assert.IsType<OrganizationModelOwnershipPolicyModel>(result);
}
}

View File

@@ -2,7 +2,9 @@
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
@@ -34,6 +36,8 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
private readonly IUserService _userService = Substitute.For<IUserService>();
private readonly IPushNotificationService _pushNotificationService = Substitute.For<IPushNotificationService>();
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
private readonly IHasPaymentMethodQuery _hasPaymentMethodQuery = Substitute.For<IHasPaymentMethodQuery>();
private readonly IUpdatePaymentMethodCommand _updatePaymentMethodCommand = Substitute.For<IUpdatePaymentMethodCommand>();
private readonly CreatePremiumCloudHostedSubscriptionCommand _command;
public CreatePremiumCloudHostedSubscriptionCommandTests()
@@ -62,7 +66,9 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
_userService,
_pushNotificationService,
Substitute.For<ILogger<CreatePremiumCloudHostedSubscriptionCommand>>(),
_pricingClient);
_pricingClient,
_hasPaymentMethodQuery,
_updatePaymentMethodCommand);
}
[Theory, BitAutoData]
@@ -314,7 +320,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
}
[Theory, BitAutoData]
public async Task Run_UserHasExistingGatewayCustomerId_UsesExistingCustomer(
public async Task Run_UserHasExistingGatewayCustomerIdAndPaymentMethod_UsesExistingCustomer(
User user,
TokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
@@ -347,6 +353,8 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
var mockInvoice = Substitute.For<Invoice>();
// Mock that the user has a payment method (this is the key difference from the credit purchase case)
_hasPaymentMethodQuery.Run(Arg.Any<User>()).Returns(true);
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
_stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(mockSubscription);
_stripeAdapter.InvoiceUpdateAsync(Arg.Any<string>(), Arg.Any<InvoiceUpdateOptions>()).Returns(mockInvoice);
@@ -358,6 +366,75 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
Assert.True(result.IsT0);
await _subscriberService.Received(1).GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>());
await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any<CustomerCreateOptions>());
await _updatePaymentMethodCommand.DidNotReceive().Run(Arg.Any<User>(), Arg.Any<TokenizedPaymentMethod>(), Arg.Any<BillingAddress>());
}
[Theory, BitAutoData]
public async Task Run_UserPreviouslyPurchasedCreditWithoutPaymentMethod_UpdatesPaymentMethodAndCreatesSubscription(
User user,
TokenizedPaymentMethod paymentMethod,
BillingAddress billingAddress)
{
// Arrange
user.Premium = false;
user.GatewayCustomerId = "existing_customer_123"; // Customer exists from previous credit purchase
paymentMethod.Type = TokenizablePaymentMethodType.Card;
paymentMethod.Token = "card_token_123";
billingAddress.Country = "US";
billingAddress.PostalCode = "12345";
var mockCustomer = Substitute.For<StripeCustomer>();
mockCustomer.Id = "existing_customer_123";
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
mockCustomer.Metadata = new Dictionary<string, string>();
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "active";
mockSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
}
]
};
var mockInvoice = Substitute.For<Invoice>();
MaskedPaymentMethod mockMaskedPaymentMethod = new MaskedCard
{
Brand = "visa",
Last4 = "1234",
Expiration = "12/2025"
};
// Mock that the user does NOT have a payment method (simulating credit purchase scenario)
_hasPaymentMethodQuery.Run(Arg.Any<User>()).Returns(false);
_updatePaymentMethodCommand.Run(Arg.Any<User>(), Arg.Any<TokenizedPaymentMethod>(), Arg.Any<BillingAddress>())
.Returns(mockMaskedPaymentMethod);
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
_stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(mockSubscription);
_stripeAdapter.InvoiceUpdateAsync(Arg.Any<string>(), Arg.Any<InvoiceUpdateOptions>()).Returns(mockInvoice);
// Act
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
// Assert
Assert.True(result.IsT0);
// Verify that update payment method was called (new behavior for credit purchase case)
await _updatePaymentMethodCommand.Received(1).Run(user, paymentMethod, billingAddress);
// Verify GetCustomerOrThrow was called after updating payment method
await _subscriberService.Received(1).GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>());
// Verify no new customer was created
await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any<CustomerCreateOptions>());
// Verify subscription was created
await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
// Verify user was updated correctly
Assert.True(user.Premium);
await _userService.Received(1).SaveUserAsync(user);
await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id);
}
[Theory, BitAutoData]

View File

@@ -1,4 +1,4 @@
using Bit.Core.Platform.Mailer;
using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Test.Platform.Mailer.TestMail;
using Xunit;

View File

@@ -1,19 +1,18 @@
using Bit.Core.Models.Mail;
using Bit.Core.Platform.Mailer;
using Bit.Core.Services;
using Bit.Core.Platform.Mail.Delivery;
using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Test.Platform.Mailer.TestMail;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Platform.Mailer;
public class MailerTest
{
[Fact]
public async Task SendEmailAsync()
{
var deliveryService = Substitute.For<IMailDeliveryService>();
var mailer = new Core.Platform.Mailer.Mailer(new HandlebarMailRenderer(), deliveryService);
var mailer = new Core.Platform.Mail.Mailer.Mailer(new HandlebarMailRenderer(), deliveryService);
var mail = new TestMail.TestMail()
{

View File

@@ -1,4 +1,4 @@
using Bit.Core.Platform.Mailer;
using Bit.Core.Platform.Mail.Mailer;
namespace Bit.Core.Test.Platform.Mailer.TestMail;

View File

@@ -1,7 +1,7 @@
using Amazon.SimpleEmail;
using Amazon.SimpleEmail.Model;
using Bit.Core.Models.Mail;
using Bit.Core.Services;
using Bit.Core.Platform.Mail.Delivery;
using Bit.Core.Settings;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;

View File

@@ -6,7 +6,10 @@ using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Entities;
using Bit.Core.Models.Mail;
using Bit.Core.Platform.Mail.Delivery;
using Bit.Core.Platform.Mail.Enqueuing;
using Bit.Core.Services;
using Bit.Core.Services.Mail;
using Bit.Core.Settings;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;

View File

@@ -1,4 +1,4 @@
using Bit.Core.Services;
using Bit.Core.Platform.Mail.Delivery;
using Bit.Core.Settings;
using Microsoft.Extensions.Logging;
using NSubstitute;

View File

@@ -1,5 +1,5 @@
using Bit.Core.Models.Mail;
using Bit.Core.Services;
using Bit.Core.Platform.Mail.Delivery;
using Bit.Core.Settings;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;

View File

@@ -1,21 +1,63 @@
using System.Net.Http.Json;
using System.Net;
using System.Net.Http.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Enums;
using Bit.Core.Vault.Repositories;
using Bit.Events.Models;
namespace Bit.Events.IntegrationTest.Controllers;
public class CollectControllerTests
public class CollectControllerTests : IAsyncLifetime
{
// This is a very simple test, and should be updated to assert more things, but for now
// it ensures that the events startup doesn't throw any errors with fairly basic configuration.
[Fact]
public async Task Post_Works()
{
var eventsApplicationFactory = new EventsApplicationFactory();
var (accessToken, _) = await eventsApplicationFactory.LoginWithNewAccount();
var client = eventsApplicationFactory.CreateAuthedClient(accessToken);
private EventsApplicationFactory _factory = null!;
private HttpClient _client = null!;
private string _ownerEmail = null!;
private Guid _ownerId;
var response = await client.PostAsJsonAsync<IEnumerable<EventModel>>("collect",
public async Task InitializeAsync()
{
_factory = new EventsApplicationFactory();
_ownerEmail = $"integration-test+{Guid.NewGuid()}@bitwarden.com";
var (accessToken, _) = await _factory.LoginWithNewAccount(_ownerEmail);
_client = _factory.CreateAuthedClient(accessToken);
// Get the user ID
var userRepository = _factory.GetService<IUserRepository>();
var user = await userRepository.GetByEmailAsync(_ownerEmail);
_ownerId = user!.Id;
}
public Task DisposeAsync()
{
_client?.Dispose();
_factory?.Dispose();
return Task.CompletedTask;
}
[Fact]
public async Task Post_NullModel_ReturnsBadRequest()
{
var response = await _client.PostAsJsonAsync<IEnumerable<EventModel>?>("collect", null);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Post_EmptyModel_ReturnsBadRequest()
{
var response = await _client.PostAsJsonAsync("collect", Array.Empty<EventModel>());
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Post_UserClientExportedVault_Success()
{
var response = await _client.PostAsJsonAsync<IEnumerable<EventModel>>("collect",
[
new EventModel
{
@@ -26,4 +68,425 @@ public class CollectControllerTests
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Post_CipherClientAutofilled_WithValidCipher_Success()
{
var cipher = await CreateCipherForUserAsync(_ownerId);
var response = await _client.PostAsJsonAsync<IEnumerable<EventModel>>("collect",
[
new EventModel
{
Type = EventType.Cipher_ClientAutofilled,
CipherId = cipher.Id,
Date = DateTime.UtcNow,
},
]);
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Post_CipherClientCopiedPassword_WithValidCipher_Success()
{
var cipher = await CreateCipherForUserAsync(_ownerId);
var response = await _client.PostAsJsonAsync<IEnumerable<EventModel>>("collect",
[
new EventModel
{
Type = EventType.Cipher_ClientCopiedPassword,
CipherId = cipher.Id,
Date = DateTime.UtcNow,
},
]);
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Post_CipherClientCopiedHiddenField_WithValidCipher_Success()
{
var cipher = await CreateCipherForUserAsync(_ownerId);
var response = await _client.PostAsJsonAsync<IEnumerable<EventModel>>("collect",
[
new EventModel
{
Type = EventType.Cipher_ClientCopiedHiddenField,
CipherId = cipher.Id,
Date = DateTime.UtcNow,
},
]);
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Post_CipherClientCopiedCardCode_WithValidCipher_Success()
{
var cipher = await CreateCipherForUserAsync(_ownerId);
var response = await _client.PostAsJsonAsync<IEnumerable<EventModel>>("collect",
[
new EventModel
{
Type = EventType.Cipher_ClientCopiedCardCode,
CipherId = cipher.Id,
Date = DateTime.UtcNow,
},
]);
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Post_CipherClientToggledCardNumberVisible_WithValidCipher_Success()
{
var cipher = await CreateCipherForUserAsync(_ownerId);
var response = await _client.PostAsJsonAsync<IEnumerable<EventModel>>("collect",
[
new EventModel
{
Type = EventType.Cipher_ClientToggledCardNumberVisible,
CipherId = cipher.Id,
Date = DateTime.UtcNow,
},
]);
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Post_CipherClientToggledCardCodeVisible_WithValidCipher_Success()
{
var cipher = await CreateCipherForUserAsync(_ownerId);
var response = await _client.PostAsJsonAsync<IEnumerable<EventModel>>("collect",
[
new EventModel
{
Type = EventType.Cipher_ClientToggledCardCodeVisible,
CipherId = cipher.Id,
Date = DateTime.UtcNow,
},
]);
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Post_CipherClientToggledHiddenFieldVisible_WithValidCipher_Success()
{
var cipher = await CreateCipherForUserAsync(_ownerId);
var response = await _client.PostAsJsonAsync<IEnumerable<EventModel>>("collect",
[
new EventModel
{
Type = EventType.Cipher_ClientToggledHiddenFieldVisible,
CipherId = cipher.Id,
Date = DateTime.UtcNow,
},
]);
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Post_CipherClientToggledPasswordVisible_WithValidCipher_Success()
{
var cipher = await CreateCipherForUserAsync(_ownerId);
var response = await _client.PostAsJsonAsync<IEnumerable<EventModel>>("collect",
[
new EventModel
{
Type = EventType.Cipher_ClientToggledPasswordVisible,
CipherId = cipher.Id,
Date = DateTime.UtcNow,
},
]);
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Post_CipherClientViewed_WithValidCipher_Success()
{
var cipher = await CreateCipherForUserAsync(_ownerId);
var response = await _client.PostAsJsonAsync<IEnumerable<EventModel>>("collect",
[
new EventModel
{
Type = EventType.Cipher_ClientViewed,
CipherId = cipher.Id,
Date = DateTime.UtcNow,
},
]);
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Post_CipherEvent_WithoutCipherId_Success()
{
var response = await _client.PostAsJsonAsync<IEnumerable<EventModel>>("collect",
[
new EventModel
{
Type = EventType.Cipher_ClientAutofilled,
Date = DateTime.UtcNow,
},
]);
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Post_CipherEvent_WithInvalidCipherId_Success()
{
var response = await _client.PostAsJsonAsync<IEnumerable<EventModel>>("collect",
[
new EventModel
{
Type = EventType.Cipher_ClientAutofilled,
CipherId = Guid.NewGuid(),
Date = DateTime.UtcNow,
},
]);
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Post_OrganizationClientExportedVault_WithValidOrganization_Success()
{
var organization = await CreateOrganizationAsync(_ownerId);
var response = await _client.PostAsJsonAsync<IEnumerable<EventModel>>("collect",
[
new EventModel
{
Type = EventType.Organization_ClientExportedVault,
OrganizationId = organization.Id,
Date = DateTime.UtcNow,
},
]);
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Post_OrganizationClientExportedVault_WithoutOrganizationId_Success()
{
var response = await _client.PostAsJsonAsync<IEnumerable<EventModel>>("collect",
[
new EventModel
{
Type = EventType.Organization_ClientExportedVault,
Date = DateTime.UtcNow,
},
]);
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Post_OrganizationClientExportedVault_WithInvalidOrganizationId_Success()
{
var response = await _client.PostAsJsonAsync<IEnumerable<EventModel>>("collect",
[
new EventModel
{
Type = EventType.Organization_ClientExportedVault,
OrganizationId = Guid.NewGuid(),
Date = DateTime.UtcNow,
},
]);
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Post_MultipleEvents_Success()
{
var cipher = await CreateCipherForUserAsync(_ownerId);
var organization = await CreateOrganizationAsync(_ownerId);
var response = await _client.PostAsJsonAsync<IEnumerable<EventModel>>("collect",
[
new EventModel
{
Type = EventType.User_ClientExportedVault,
Date = DateTime.UtcNow,
},
new EventModel
{
Type = EventType.Cipher_ClientAutofilled,
CipherId = cipher.Id,
Date = DateTime.UtcNow,
},
new EventModel
{
Type = EventType.Cipher_ClientViewed,
CipherId = cipher.Id,
Date = DateTime.UtcNow,
},
new EventModel
{
Type = EventType.Organization_ClientExportedVault,
OrganizationId = organization.Id,
Date = DateTime.UtcNow,
},
]);
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Post_CipherEventsBatch_MoreThan50Items_Success()
{
var cipher = await CreateCipherForUserAsync(_ownerId);
// Create 60 cipher events to test batching logic (should be processed in 2 batches of 50)
var events = Enumerable.Range(0, 60)
.Select(_ => new EventModel
{
Type = EventType.Cipher_ClientViewed,
CipherId = cipher.Id,
Date = DateTime.UtcNow,
})
.ToList();
var response = await _client.PostAsJsonAsync<IEnumerable<EventModel>>("collect", events);
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Post_UnsupportedEventType_Success()
{
// Testing with an event type not explicitly handled in the switch statement
var response = await _client.PostAsJsonAsync<IEnumerable<EventModel>>("collect",
[
new EventModel
{
Type = EventType.User_LoggedIn,
Date = DateTime.UtcNow,
},
]);
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Post_MixedValidAndInvalidEvents_Success()
{
var cipher = await CreateCipherForUserAsync(_ownerId);
var response = await _client.PostAsJsonAsync<IEnumerable<EventModel>>("collect",
[
new EventModel
{
Type = EventType.User_ClientExportedVault,
Date = DateTime.UtcNow,
},
new EventModel
{
Type = EventType.Cipher_ClientAutofilled,
CipherId = Guid.NewGuid(), // Invalid cipher ID
Date = DateTime.UtcNow,
},
new EventModel
{
Type = EventType.Cipher_ClientViewed,
CipherId = cipher.Id, // Valid cipher ID
Date = DateTime.UtcNow,
},
]);
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task Post_CipherCaching_MultipleEventsForSameCipher_Success()
{
var cipher = await CreateCipherForUserAsync(_ownerId);
// Multiple events for the same cipher should use caching
var response = await _client.PostAsJsonAsync<IEnumerable<EventModel>>("collect",
[
new EventModel
{
Type = EventType.Cipher_ClientAutofilled,
CipherId = cipher.Id,
Date = DateTime.UtcNow,
},
new EventModel
{
Type = EventType.Cipher_ClientViewed,
CipherId = cipher.Id,
Date = DateTime.UtcNow,
},
new EventModel
{
Type = EventType.Cipher_ClientCopiedPassword,
CipherId = cipher.Id,
Date = DateTime.UtcNow,
},
]);
response.EnsureSuccessStatusCode();
}
private async Task<Cipher> CreateCipherForUserAsync(Guid userId)
{
var cipherRepository = _factory.GetService<ICipherRepository>();
var cipher = new Cipher
{
Type = CipherType.Login,
UserId = userId,
Data = "{\"name\":\"Test Cipher\"}",
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
};
await cipherRepository.CreateAsync(cipher);
return cipher;
}
private async Task<Organization> CreateOrganizationAsync(Guid ownerId)
{
var organizationRepository = _factory.GetService<IOrganizationRepository>();
var organizationUserRepository = _factory.GetService<IOrganizationUserRepository>();
var organization = new Organization
{
Name = "Test Organization",
BillingEmail = _ownerEmail,
Plan = "Free",
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
};
await organizationRepository.CreateAsync(organization);
// Add the user as an owner of the organization
var organizationUser = new OrganizationUser
{
OrganizationId = organization.Id,
UserId = ownerId,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.Owner,
};
await organizationUserRepository.CreateAsync(organizationUser);
return organization;
}
}

View File

@@ -0,0 +1,715 @@
using AutoFixture.Xunit2;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Models.Data;
using Bit.Core.Vault.Repositories;
using Bit.Events.Controllers;
using Bit.Events.Models;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
namespace Events.Test.Controllers;
public class CollectControllerTests
{
private readonly CollectController _sut;
private readonly ICurrentContext _currentContext;
private readonly IEventService _eventService;
private readonly ICipherRepository _cipherRepository;
private readonly IOrganizationRepository _organizationRepository;
public CollectControllerTests()
{
_currentContext = Substitute.For<ICurrentContext>();
_eventService = Substitute.For<IEventService>();
_cipherRepository = Substitute.For<ICipherRepository>();
_organizationRepository = Substitute.For<IOrganizationRepository>();
_sut = new CollectController(
_currentContext,
_eventService,
_cipherRepository,
_organizationRepository
);
}
[Fact]
public async Task Post_NullModel_ReturnsBadRequest()
{
var result = await _sut.Post(null);
Assert.IsType<BadRequestResult>(result);
}
[Fact]
public async Task Post_EmptyModel_ReturnsBadRequest()
{
var result = await _sut.Post(new List<EventModel>());
Assert.IsType<BadRequestResult>(result);
}
[Theory]
[AutoData]
public async Task Post_UserClientExportedVault_LogsUserEvent(Guid userId)
{
_currentContext.UserId.Returns(userId);
var eventDate = DateTime.UtcNow;
var events = new List<EventModel>
{
new EventModel
{
Type = EventType.User_ClientExportedVault,
Date = eventDate
}
};
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _eventService.Received(1).LogUserEventAsync(userId, EventType.User_ClientExportedVault, eventDate);
}
[Theory]
[AutoData]
public async Task Post_CipherAutofilled_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails)
{
_currentContext.UserId.Returns(userId);
cipherDetails.Id = cipherId;
_cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails);
var eventDate = DateTime.UtcNow;
var events = new List<EventModel>
{
new EventModel
{
Type = EventType.Cipher_ClientAutofilled,
CipherId = cipherId,
Date = eventDate
}
};
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _cipherRepository.Received(1).GetByIdAsync(cipherId, userId);
await _eventService.Received(1).LogCipherEventsAsync(
Arg.Is<IEnumerable<Tuple<Cipher, EventType, DateTime?>>>(
tuples => tuples.Count() == 1 &&
tuples.First().Item1 == cipherDetails &&
tuples.First().Item2 == EventType.Cipher_ClientAutofilled &&
tuples.First().Item3 == eventDate
)
);
}
[Theory]
[AutoData]
public async Task Post_CipherClientCopiedPassword_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails)
{
_currentContext.UserId.Returns(userId);
cipherDetails.Id = cipherId;
_cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails);
var eventDate = DateTime.UtcNow;
var events = new List<EventModel>
{
new EventModel
{
Type = EventType.Cipher_ClientCopiedPassword,
CipherId = cipherId,
Date = eventDate
}
};
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _eventService.Received(1).LogCipherEventsAsync(
Arg.Is<IEnumerable<Tuple<Cipher, EventType, DateTime?>>>(
tuples => tuples.First().Item2 == EventType.Cipher_ClientCopiedPassword
)
);
}
[Theory]
[AutoData]
public async Task Post_CipherClientCopiedHiddenField_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails)
{
_currentContext.UserId.Returns(userId);
cipherDetails.Id = cipherId;
_cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails);
var eventDate = DateTime.UtcNow;
var events = new List<EventModel>
{
new EventModel
{
Type = EventType.Cipher_ClientCopiedHiddenField,
CipherId = cipherId,
Date = eventDate
}
};
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _eventService.Received(1).LogCipherEventsAsync(
Arg.Is<IEnumerable<Tuple<Cipher, EventType, DateTime?>>>(
tuples => tuples.First().Item2 == EventType.Cipher_ClientCopiedHiddenField
)
);
}
[Theory]
[AutoData]
public async Task Post_CipherClientCopiedCardCode_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails)
{
_currentContext.UserId.Returns(userId);
cipherDetails.Id = cipherId;
_cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails);
var eventDate = DateTime.UtcNow;
var events = new List<EventModel>
{
new EventModel
{
Type = EventType.Cipher_ClientCopiedCardCode,
CipherId = cipherId,
Date = eventDate
}
};
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _eventService.Received(1).LogCipherEventsAsync(
Arg.Is<IEnumerable<Tuple<Cipher, EventType, DateTime?>>>(
tuples => tuples.First().Item2 == EventType.Cipher_ClientCopiedCardCode
)
);
}
[Theory]
[AutoData]
public async Task Post_CipherClientToggledCardNumberVisible_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails)
{
_currentContext.UserId.Returns(userId);
cipherDetails.Id = cipherId;
_cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails);
var eventDate = DateTime.UtcNow;
var events = new List<EventModel>
{
new EventModel
{
Type = EventType.Cipher_ClientToggledCardNumberVisible,
CipherId = cipherId,
Date = eventDate
}
};
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _eventService.Received(1).LogCipherEventsAsync(
Arg.Is<IEnumerable<Tuple<Cipher, EventType, DateTime?>>>(
tuples => tuples.First().Item2 == EventType.Cipher_ClientToggledCardNumberVisible
)
);
}
[Theory]
[AutoData]
public async Task Post_CipherClientToggledCardCodeVisible_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails)
{
_currentContext.UserId.Returns(userId);
cipherDetails.Id = cipherId;
_cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails);
var eventDate = DateTime.UtcNow;
var events = new List<EventModel>
{
new EventModel
{
Type = EventType.Cipher_ClientToggledCardCodeVisible,
CipherId = cipherId,
Date = eventDate
}
};
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _eventService.Received(1).LogCipherEventsAsync(
Arg.Is<IEnumerable<Tuple<Cipher, EventType, DateTime?>>>(
tuples => tuples.First().Item2 == EventType.Cipher_ClientToggledCardCodeVisible
)
);
}
[Theory]
[AutoData]
public async Task Post_CipherClientToggledHiddenFieldVisible_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails)
{
_currentContext.UserId.Returns(userId);
cipherDetails.Id = cipherId;
_cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails);
var eventDate = DateTime.UtcNow;
var events = new List<EventModel>
{
new EventModel
{
Type = EventType.Cipher_ClientToggledHiddenFieldVisible,
CipherId = cipherId,
Date = eventDate
}
};
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _eventService.Received(1).LogCipherEventsAsync(
Arg.Is<IEnumerable<Tuple<Cipher, EventType, DateTime?>>>(
tuples => tuples.First().Item2 == EventType.Cipher_ClientToggledHiddenFieldVisible
)
);
}
[Theory]
[AutoData]
public async Task Post_CipherClientToggledPasswordVisible_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails)
{
_currentContext.UserId.Returns(userId);
cipherDetails.Id = cipherId;
_cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails);
var eventDate = DateTime.UtcNow;
var events = new List<EventModel>
{
new EventModel
{
Type = EventType.Cipher_ClientToggledPasswordVisible,
CipherId = cipherId,
Date = eventDate
}
};
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _eventService.Received(1).LogCipherEventsAsync(
Arg.Is<IEnumerable<Tuple<Cipher, EventType, DateTime?>>>(
tuples => tuples.First().Item2 == EventType.Cipher_ClientToggledPasswordVisible
)
);
}
[Theory]
[AutoData]
public async Task Post_CipherClientViewed_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails)
{
_currentContext.UserId.Returns(userId);
cipherDetails.Id = cipherId;
_cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails);
var eventDate = DateTime.UtcNow;
var events = new List<EventModel>
{
new EventModel
{
Type = EventType.Cipher_ClientViewed,
CipherId = cipherId,
Date = eventDate
}
};
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _eventService.Received(1).LogCipherEventsAsync(
Arg.Is<IEnumerable<Tuple<Cipher, EventType, DateTime?>>>(
tuples => tuples.First().Item2 == EventType.Cipher_ClientViewed
)
);
}
[Theory]
[AutoData]
public async Task Post_CipherEvent_WithoutCipherId_SkipsEvent(Guid userId)
{
_currentContext.UserId.Returns(userId);
var events = new List<EventModel>
{
new EventModel
{
Type = EventType.Cipher_ClientAutofilled,
CipherId = null,
Date = DateTime.UtcNow
}
};
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _cipherRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default, default);
await _eventService.DidNotReceiveWithAnyArgs().LogCipherEventsAsync(default);
}
[Theory]
[AutoData]
public async Task Post_CipherEvent_WithNullCipher_WithoutOrgId_SkipsEvent(Guid userId, Guid cipherId)
{
_currentContext.UserId.Returns(userId);
_cipherRepository.GetByIdAsync(cipherId, userId).Returns((CipherDetails?)null);
var events = new List<EventModel>
{
new EventModel
{
Type = EventType.Cipher_ClientAutofilled,
CipherId = cipherId,
OrganizationId = null,
Date = DateTime.UtcNow
}
};
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _cipherRepository.Received(1).GetByIdAsync(cipherId, userId);
await _cipherRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(cipherId);
await _eventService.DidNotReceiveWithAnyArgs().LogCipherEventsAsync(default);
}
[Theory]
[AutoData]
public async Task Post_CipherEvent_WithNullCipher_WithOrgId_ChecksOrgCipher(
Guid userId, Guid cipherId, Guid orgId, Cipher cipher, CurrentContextOrganization org)
{
_currentContext.UserId.Returns(userId);
cipher.Id = cipherId;
cipher.OrganizationId = orgId;
_cipherRepository.GetByIdAsync(cipherId, userId).Returns((CipherDetails?)null);
_cipherRepository.GetByIdAsync(cipherId).Returns(cipher);
_currentContext.GetOrganization(orgId).Returns(org);
var eventDate = DateTime.UtcNow;
var events = new List<EventModel>
{
new EventModel
{
Type = EventType.Cipher_ClientAutofilled,
CipherId = cipherId,
OrganizationId = orgId,
Date = eventDate
}
};
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _cipherRepository.Received(1).GetByIdAsync(cipherId, userId);
await _cipherRepository.Received(1).GetByIdAsync(cipherId);
await _eventService.Received(1).LogCipherEventsAsync(
Arg.Is<IEnumerable<Tuple<Cipher, EventType, DateTime?>>>(
tuples => tuples.First().Item1 == cipher
)
);
}
[Theory]
[AutoData]
public async Task Post_CipherEvent_WithNullCipher_OrgCipherNotFound_SkipsEvent(
Guid userId, Guid cipherId, Guid orgId)
{
_currentContext.UserId.Returns(userId);
_cipherRepository.GetByIdAsync(cipherId, userId).Returns((CipherDetails?)null);
_cipherRepository.GetByIdAsync(cipherId).Returns((CipherDetails?)null);
var events = new List<EventModel>
{
new EventModel
{
Type = EventType.Cipher_ClientAutofilled,
CipherId = cipherId,
OrganizationId = orgId,
Date = DateTime.UtcNow
}
};
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _cipherRepository.Received(1).GetByIdAsync(cipherId, userId);
await _cipherRepository.Received(1).GetByIdAsync(cipherId);
await _eventService.DidNotReceiveWithAnyArgs().LogCipherEventsAsync(default);
}
[Theory]
[AutoData]
public async Task Post_CipherEvent_CipherDoesNotBelongToOrg_SkipsEvent(
Guid userId, Guid cipherId, Guid orgId, Guid differentOrgId, Cipher cipher)
{
_currentContext.UserId.Returns(userId);
cipher.Id = cipherId;
cipher.OrganizationId = differentOrgId;
_cipherRepository.GetByIdAsync(cipherId, userId).Returns((CipherDetails?)null);
_cipherRepository.GetByIdAsync(cipherId).Returns(cipher);
var events = new List<EventModel>
{
new EventModel
{
Type = EventType.Cipher_ClientAutofilled,
CipherId = cipherId,
OrganizationId = orgId,
Date = DateTime.UtcNow
}
};
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _eventService.DidNotReceiveWithAnyArgs().LogCipherEventsAsync(default);
}
[Theory]
[AutoData]
public async Task Post_CipherEvent_OrgNotFound_SkipsEvent(
Guid userId, Guid cipherId, Guid orgId, Cipher cipher)
{
_currentContext.UserId.Returns(userId);
cipher.Id = cipherId;
cipher.OrganizationId = orgId;
_cipherRepository.GetByIdAsync(cipherId, userId).Returns((CipherDetails?)null);
_cipherRepository.GetByIdAsync(cipherId).Returns(cipher);
_currentContext.GetOrganization(orgId).Returns((CurrentContextOrganization)null);
var events = new List<EventModel>
{
new EventModel
{
Type = EventType.Cipher_ClientAutofilled,
CipherId = cipherId,
OrganizationId = orgId,
Date = DateTime.UtcNow
}
};
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _eventService.DidNotReceiveWithAnyArgs().LogCipherEventsAsync(default);
}
[Theory]
[AutoData]
public async Task Post_MultipleCipherEvents_WithSameCipherId_UsesCachedCipher(
Guid userId, Guid cipherId, CipherDetails cipherDetails)
{
_currentContext.UserId.Returns(userId);
cipherDetails.Id = cipherId;
_cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails);
var events = new List<EventModel>
{
new EventModel
{
Type = EventType.Cipher_ClientAutofilled,
CipherId = cipherId,
Date = DateTime.UtcNow
},
new EventModel
{
Type = EventType.Cipher_ClientViewed,
CipherId = cipherId,
Date = DateTime.UtcNow
}
};
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _cipherRepository.Received(1).GetByIdAsync(cipherId, userId);
await _eventService.Received(1).LogCipherEventsAsync(
Arg.Is<IEnumerable<Tuple<Cipher, EventType, DateTime?>>>(tuples => tuples.Count() == 2)
);
}
[Theory]
[AutoData]
public async Task Post_OrganizationClientExportedVault_WithValidOrg_LogsOrgEvent(
Guid userId, Guid orgId, Organization organization)
{
_currentContext.UserId.Returns(userId);
organization.Id = orgId;
_organizationRepository.GetByIdAsync(orgId).Returns(organization);
var eventDate = DateTime.UtcNow;
var events = new List<EventModel>
{
new EventModel
{
Type = EventType.Organization_ClientExportedVault,
OrganizationId = orgId,
Date = eventDate
}
};
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _organizationRepository.Received(1).GetByIdAsync(orgId);
await _eventService.Received(1).LogOrganizationEventAsync(organization, EventType.Organization_ClientExportedVault, eventDate);
}
[Theory]
[AutoData]
public async Task Post_OrganizationClientExportedVault_WithoutOrgId_SkipsEvent(Guid userId)
{
_currentContext.UserId.Returns(userId);
var events = new List<EventModel>
{
new EventModel
{
Type = EventType.Organization_ClientExportedVault,
OrganizationId = null,
Date = DateTime.UtcNow
}
};
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _organizationRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default);
await _eventService.DidNotReceiveWithAnyArgs().LogOrganizationEventAsync(default, default, default);
}
[Theory]
[AutoData]
public async Task Post_OrganizationClientExportedVault_WithNullOrg_SkipsEvent(Guid userId, Guid orgId)
{
_currentContext.UserId.Returns(userId);
_organizationRepository.GetByIdAsync(orgId).Returns((Organization)null);
var events = new List<EventModel>
{
new EventModel
{
Type = EventType.Organization_ClientExportedVault,
OrganizationId = orgId,
Date = DateTime.UtcNow
}
};
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _organizationRepository.Received(1).GetByIdAsync(orgId);
await _eventService.DidNotReceiveWithAnyArgs().LogOrganizationEventAsync(default, default, default);
}
[Theory]
[AutoData]
public async Task Post_UnsupportedEventType_SkipsEvent(Guid userId)
{
_currentContext.UserId.Returns(userId);
var events = new List<EventModel>
{
new EventModel
{
Type = EventType.User_LoggedIn,
Date = DateTime.UtcNow
}
};
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _eventService.DidNotReceiveWithAnyArgs().LogUserEventAsync(default, default, default);
}
[Theory]
[AutoData]
public async Task Post_MixedEventTypes_ProcessesAllEvents(
Guid userId, Guid cipherId, Guid orgId, CipherDetails cipherDetails, Organization organization)
{
_currentContext.UserId.Returns(userId);
cipherDetails.Id = cipherId;
organization.Id = orgId;
_cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails);
_organizationRepository.GetByIdAsync(orgId).Returns(organization);
var events = new List<EventModel>
{
new EventModel
{
Type = EventType.User_ClientExportedVault,
Date = DateTime.UtcNow
},
new EventModel
{
Type = EventType.Cipher_ClientAutofilled,
CipherId = cipherId,
Date = DateTime.UtcNow
},
new EventModel
{
Type = EventType.Organization_ClientExportedVault,
OrganizationId = orgId,
Date = DateTime.UtcNow
}
};
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _eventService.Received(1).LogUserEventAsync(userId, EventType.User_ClientExportedVault, Arg.Any<DateTime?>());
await _eventService.Received(1).LogCipherEventsAsync(
Arg.Is<IEnumerable<Tuple<Cipher, EventType, DateTime?>>>(tuples => tuples.Count() == 1)
);
await _eventService.Received(1).LogOrganizationEventAsync(organization, EventType.Organization_ClientExportedVault, Arg.Any<DateTime?>());
}
[Theory]
[AutoData]
public async Task Post_MoreThan50CipherEvents_LogsInBatches(Guid userId, List<CipherDetails> ciphers)
{
_currentContext.UserId.Returns(userId);
var events = new List<EventModel>();
for (int i = 0; i < 100; i++)
{
var cipher = ciphers[i % ciphers.Count];
_cipherRepository.GetByIdAsync(cipher.Id, userId).Returns(cipher);
events.Add(new EventModel
{
Type = EventType.Cipher_ClientAutofilled,
CipherId = cipher.Id,
Date = DateTime.UtcNow
});
}
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _eventService.Received(2).LogCipherEventsAsync(
Arg.Is<IEnumerable<Tuple<Cipher, EventType, DateTime?>>>(tuples => tuples.Count() == 50)
);
}
[Theory]
[AutoData]
public async Task Post_Exactly50CipherEvents_LogsInSingleBatch(Guid userId, List<CipherDetails> ciphers)
{
_currentContext.UserId.Returns(userId);
var events = new List<EventModel>();
for (int i = 0; i < 50; i++)
{
var cipher = ciphers[i % ciphers.Count];
_cipherRepository.GetByIdAsync(cipher.Id, userId).Returns(cipher);
events.Add(new EventModel
{
Type = EventType.Cipher_ClientAutofilled,
CipherId = cipher.Id,
Date = DateTime.UtcNow
});
}
var result = await _sut.Post(events);
Assert.IsType<OkResult>(result);
await _eventService.Received(1).LogCipherEventsAsync(
Arg.Is<IEnumerable<Tuple<Cipher, EventType, DateTime?>>>(tuples => tuples.Count() == 50)
);
}
}

View File

@@ -9,14 +9,18 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio"
Version="$(XUnitRunnerVisualStudioVersion)">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="AutoFixture.Xunit2" Version="$(AutoFixtureXUnit2Version)" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Events\Events.csproj" />
<ProjectReference Include="..\..\src\Core\Core.csproj" />
<ProjectReference Include="..\Common\Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,10 +0,0 @@
namespace Events.Test;
// Delete this file once you have real tests
public class PlaceholderUnitTest
{
[Fact]
public void Test1()
{
}
}

View File

@@ -1,6 +1,7 @@
using System.Reflection;
using AutoFixture;
using AutoFixture.Xunit2;
using Bit.Identity.IdentityServer;
using Duende.IdentityServer.Validation;
namespace Bit.Identity.Test.AutoFixture;
@@ -8,7 +9,8 @@ namespace Bit.Identity.Test.AutoFixture;
internal class ValidatedTokenRequestCustomization : ICustomization
{
public ValidatedTokenRequestCustomization()
{ }
{
}
public void Customize(IFixture fixture)
{
@@ -22,10 +24,45 @@ internal class ValidatedTokenRequestCustomization : ICustomization
public class ValidatedTokenRequestAttribute : CustomizeAttribute
{
public ValidatedTokenRequestAttribute()
{ }
{
}
public override ICustomization GetCustomization(ParameterInfo parameter)
{
return new ValidatedTokenRequestCustomization();
}
}
internal class CustomValidatorRequestContextCustomization : ICustomization
{
public CustomValidatorRequestContextCustomization()
{
}
/// <summary>
/// Specific context members like <see cref="CustomValidatorRequestContext.RememberMeRequested" />,
/// <see cref="CustomValidatorRequestContext.TwoFactorRecoveryRequested"/>, and
/// <see cref="CustomValidatorRequestContext.SsoRequired" /> should initialize false,
/// and are made truthy in context upon evaluation of a request. Do not allow AutoFixture to eagerly make these
/// truthy; that is the responsibility of the <see cref="Bit.Identity.IdentityServer.RequestValidators.BaseRequestValidator{T}" />
/// </summary>
public void Customize(IFixture fixture)
{
fixture.Customize<CustomValidatorRequestContext>(composer => composer
.With(o => o.RememberMeRequested, false)
.With(o => o.TwoFactorRecoveryRequested, false)
.With(o => o.SsoRequired, false));
}
}
public class CustomValidatorRequestContextAttribute : CustomizeAttribute
{
public CustomValidatorRequestContextAttribute()
{
}
public override ICustomization GetCustomization(ParameterInfo parameter)
{
return new CustomValidatorRequestContextCustomization();
}
}

View File

@@ -100,19 +100,30 @@ public class BaseRequestValidatorTests
_userAccountKeysQuery);
}
private void SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(bool recoveryCodeSupportEnabled)
{
_featureService
.IsEnabled(FeatureFlagKeys.RecoveryCodeSupportForSsoRequiredUsers)
.Returns(recoveryCodeSupportEnabled);
}
/* Logic path
* ValidateAsync -> UpdateFailedAuthDetailsAsync -> _mailService.SendFailedLoginAttemptsEmailAsync
* |-> BuildErrorResultAsync -> _eventService.LogUserEventAsync
* (self hosted) |-> _logger.LogWarning()
* |-> SetErrorResult
*/
[Theory, BitAutoData]
[Theory]
[BitAutoData(true)]
[BitAutoData(false)]
public async Task ValidateAsync_ContextNotValid_SelfHosted_ShouldBuildErrorResult_ShouldLogWarning(
bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult);
_globalSettings.SelfHosted = true;
_sut.isValid = false;
@@ -122,18 +133,23 @@ public class BaseRequestValidatorTests
// Assert
var logs = _logger.Collector.GetSnapshot(true);
Assert.Contains(logs, l => l.Level == LogLevel.Warning && l.Message == "Failed login attempt. Is2FARequest: False IpAddress: ");
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);
}
[Theory, BitAutoData]
[Theory]
[BitAutoData(true)]
[BitAutoData(false)]
public async Task ValidateAsync_DeviceNotValidated_ShouldLogError(
bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult);
// 1 -> to pass
_sut.isValid = true;
@@ -141,14 +157,15 @@ public class BaseRequestValidatorTests
// 2 -> will result to false with no extra configuration
// 3 -> set two factor to be false
_twoFactorAuthenticationValidator
.RequiresTwoFactorAsync(Arg.Any<User>(), tokenRequest)
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
.RequiresTwoFactorAsync(Arg.Any<User>(), tokenRequest)
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
// 4 -> set up device validator to fail
requestContext.KnownDevice = false;
tokenRequest.GrantType = "password";
_deviceValidator.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>())
.Returns(Task.FromResult(false));
_deviceValidator
.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>())
.Returns(Task.FromResult(false));
// 5 -> not legacy user
_userService.IsLegacyUser(Arg.Any<string>())
@@ -163,13 +180,17 @@ public class BaseRequestValidatorTests
.LogUserEventAsync(context.CustomValidatorRequestContext.User.Id, EventType.User_FailedLogIn);
}
[Theory, BitAutoData]
[Theory]
[BitAutoData(true)]
[BitAutoData(false)]
public async Task ValidateAsync_DeviceValidated_ShouldSucceed(
bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult);
// 1 -> to pass
_sut.isValid = true;
@@ -177,12 +198,13 @@ public class BaseRequestValidatorTests
// 2 -> will result to false with no extra configuration
// 3 -> set two factor to be false
_twoFactorAuthenticationValidator
.RequiresTwoFactorAsync(Arg.Any<User>(), tokenRequest)
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
.RequiresTwoFactorAsync(Arg.Any<User>(), tokenRequest)
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
// 4 -> set up device validator to pass
_deviceValidator.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>())
.Returns(Task.FromResult(true));
_deviceValidator
.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>())
.Returns(Task.FromResult(true));
// 5 -> not legacy user
_userService.IsLegacyUser(Arg.Any<string>())
@@ -202,13 +224,17 @@ public class BaseRequestValidatorTests
Assert.False(context.GrantResult.IsError);
}
[Theory, BitAutoData]
[Theory]
[BitAutoData(true)]
[BitAutoData(false)]
public async Task ValidateAsync_ValidatedAuthRequest_ConsumedOnSuccess(
bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult);
// 1 -> to pass
_sut.isValid = true;
@@ -235,7 +261,8 @@ public class BaseRequestValidatorTests
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
// 4 -> set up device validator to pass
_deviceValidator.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>())
_deviceValidator
.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>())
.Returns(Task.FromResult(true));
// 5 -> not legacy user
@@ -260,13 +287,17 @@ public class BaseRequestValidatorTests
ar.AuthenticationDate.HasValue));
}
[Theory, BitAutoData]
[Theory]
[BitAutoData(true)]
[BitAutoData(false)]
public async Task ValidateAsync_ValidatedAuthRequest_NotConsumed_When2faRequired(
bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult);
// 1 -> to pass
_sut.isValid = true;
@@ -302,13 +333,17 @@ public class BaseRequestValidatorTests
await _authRequestRepository.DidNotReceive().ReplaceAsync(Arg.Any<AuthRequest>());
}
[Theory, BitAutoData]
[Theory]
[BitAutoData(true)]
[BitAutoData(false)]
public async Task ValidateAsync_TwoFactorTokenInvalid_ShouldSendFailedTwoFactorEmail(
bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult);
var user = requestContext.User;
@@ -345,13 +380,17 @@ public class BaseRequestValidatorTests
Arg.Any<string>());
}
[Theory, BitAutoData]
[Theory]
[BitAutoData(true)]
[BitAutoData(false)]
public async Task ValidateAsync_TwoFactorRememberTokenExpired_ShouldNotSendFailedTwoFactorEmail(
bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult);
var user = requestContext.User;
@@ -391,28 +430,34 @@ public class BaseRequestValidatorTests
// Assert
// Verify that the failed 2FA email was NOT sent for remember token expiration
await _mailService.DidNotReceive()
.SendFailedTwoFactorAttemptEmailAsync(Arg.Any<string>(), Arg.Any<TwoFactorProviderType>(), Arg.Any<DateTime>(), Arg.Any<string>());
.SendFailedTwoFactorAttemptEmailAsync(Arg.Any<string>(), Arg.Any<TwoFactorProviderType>(),
Arg.Any<DateTime>(), Arg.Any<string>());
}
// Test grantTypes that require SSO when a user is in an organization that requires it
[Theory]
[BitAutoData("password")]
[BitAutoData("webauthn")]
[BitAutoData("refresh_token")]
[BitAutoData("password", true)]
[BitAutoData("password", false)]
[BitAutoData("webauthn", true)]
[BitAutoData("webauthn", false)]
[BitAutoData("refresh_token", true)]
[BitAutoData("refresh_token", false)]
public async Task ValidateAsync_GrantTypes_OrgSsoRequiredTrue_ShouldSetSsoResult(
string grantType,
bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult);
_sut.isValid = true;
context.ValidatedTokenRequest.GrantType = grantType;
_policyService.AnyPoliciesApplicableToUserAsync(
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
.Returns(Task.FromResult(true));
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
.Returns(Task.FromResult(true));
// Act
await _sut.ValidateAsync(context);
@@ -425,16 +470,21 @@ public class BaseRequestValidatorTests
// Test grantTypes with RequireSsoPolicyRequirement when feature flag is enabled
[Theory]
[BitAutoData("password")]
[BitAutoData("webauthn")]
[BitAutoData("refresh_token")]
[BitAutoData("password", true)]
[BitAutoData("password", false)]
[BitAutoData("webauthn", true)]
[BitAutoData("webauthn", false)]
[BitAutoData("refresh_token", true)]
[BitAutoData("refresh_token", false)]
public async Task ValidateAsync_GrantTypes_WithPolicyRequirementsEnabled_OrgSsoRequiredTrue_ShouldSetSsoResult(
string grantType,
bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
var context = CreateContext(tokenRequest, requestContext, grantResult);
_sut.isValid = true;
@@ -449,23 +499,28 @@ public class BaseRequestValidatorTests
// Assert
await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync(
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
Assert.True(context.GrantResult.IsError);
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
Assert.Equal("SSO authentication is required.", errorResponse.Message);
}
[Theory]
[BitAutoData("password")]
[BitAutoData("webauthn")]
[BitAutoData("refresh_token")]
[BitAutoData("password", true)]
[BitAutoData("password", false)]
[BitAutoData("webauthn", true)]
[BitAutoData("webauthn", false)]
[BitAutoData("refresh_token", true)]
[BitAutoData("refresh_token", false)]
public async Task ValidateAsync_GrantTypes_WithPolicyRequirementsEnabled_OrgSsoRequiredFalse_ShouldSucceed(
string grantType,
bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
var context = CreateContext(tokenRequest, requestContext, grantResult);
_sut.isValid = true;
@@ -500,24 +555,29 @@ public class BaseRequestValidatorTests
// Test grantTypes where SSO would be required but the user is not in an
// organization that requires it
[Theory]
[BitAutoData("password")]
[BitAutoData("webauthn")]
[BitAutoData("refresh_token")]
[BitAutoData("password", true)]
[BitAutoData("password", false)]
[BitAutoData("webauthn", true)]
[BitAutoData("webauthn", false)]
[BitAutoData("refresh_token", true)]
[BitAutoData("refresh_token", false)]
public async Task ValidateAsync_GrantTypes_OrgSsoRequiredFalse_ShouldSucceed(
string grantType,
bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult);
_sut.isValid = true;
context.ValidatedTokenRequest.GrantType = grantType;
_policyService.AnyPoliciesApplicableToUserAsync(
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
.Returns(Task.FromResult(false));
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
.Returns(Task.FromResult(false));
_twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
@@ -540,20 +600,23 @@ public class BaseRequestValidatorTests
await _userRepository.Received(1).ReplaceAsync(Arg.Any<User>());
Assert.False(context.GrantResult.IsError);
}
// Test the grantTypes where SSO is in progress or not relevant
[Theory]
[BitAutoData("authorization_code")]
[BitAutoData("client_credentials")]
[BitAutoData("authorization_code", true)]
[BitAutoData("authorization_code", false)]
[BitAutoData("client_credentials", true)]
[BitAutoData("client_credentials", false)]
public async Task ValidateAsync_GrantTypes_SsoRequiredFalse_ShouldSucceed(
string grantType,
bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult);
_sut.isValid = true;
@@ -577,7 +640,7 @@ public class BaseRequestValidatorTests
// Assert
await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync(
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
await _eventService.Received(1).LogUserEventAsync(
context.CustomValidatorRequestContext.User.Id, EventType.User_LoggedIn);
await _userRepository.Received(1).ReplaceAsync(Arg.Any<User>());
@@ -588,13 +651,17 @@ public class BaseRequestValidatorTests
/* Logic Path
* ValidateAsync -> UserService.IsLegacyUser -> FailAuthForLegacyUserAsync
*/
[Theory, BitAutoData]
[Theory]
[BitAutoData(true)]
[BitAutoData(false)]
public async Task ValidateAsync_IsLegacyUser_FailAuthForLegacyUserAsync(
bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var context = CreateContext(tokenRequest, requestContext, grantResult);
var user = context.CustomValidatorRequestContext.User;
user.Key = null;
@@ -613,21 +680,27 @@ public class BaseRequestValidatorTests
// Assert
Assert.True(context.GrantResult.IsError);
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
var expectedMessage = "Legacy encryption without a userkey is no longer supported. To recover your account, please contact support";
var expectedMessage =
"Legacy encryption without a userkey is no longer supported. To recover your account, please contact support";
Assert.Equal(expectedMessage, errorResponse.Message);
}
[Theory, BitAutoData]
[Theory]
[BitAutoData(true)]
[BitAutoData(false)]
public async Task ValidateAsync_CustomResponse_NoMasterPassword_ShouldSetUserDecryptionOptions(
bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
_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.WithWebAuthnLoginCredential(Arg.Any<WebAuthnCredential>())
.Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions
{
HasMasterPassword = false,
@@ -663,19 +736,24 @@ public class BaseRequestValidatorTests
}
[Theory]
[BitAutoData(KdfType.PBKDF2_SHA256, 654_321, null, null)]
[BitAutoData(KdfType.Argon2id, 11, 128, 5)]
[BitAutoData(true, KdfType.PBKDF2_SHA256, 654_321, null, null)]
[BitAutoData(false, KdfType.PBKDF2_SHA256, 654_321, null, null)]
[BitAutoData(true, KdfType.Argon2id, 11, 128, 5)]
[BitAutoData(false, KdfType.Argon2id, 11, 128, 5)]
public async Task ValidateAsync_CustomResponse_MasterPassword_ShouldSetUserDecryptionOptions(
bool featureFlagValue,
KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
_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.WithWebAuthnLoginCredential(Arg.Any<WebAuthnCredential>())
.Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions
{
HasMasterPassword = true,
@@ -728,13 +806,17 @@ public class BaseRequestValidatorTests
Assert.Equal("test@example.com", userDecryptionOptions.MasterPasswordUnlock.Salt);
}
[Theory, BitAutoData]
[Theory]
[BitAutoData(true)]
[BitAutoData(false)]
public async Task ValidateAsync_CustomResponse_ShouldIncludeAccountKeys(
bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var mockAccountKeys = new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
@@ -747,11 +829,7 @@ public class BaseRequestValidatorTests
"test-wrapped-signing-key",
"test-verifying-key"
),
SecurityStateData = new SecurityStateData
{
SecurityState = "test-security-state",
SecurityVersion = 2
}
SecurityStateData = new SecurityStateData { SecurityState = "test-security-state", SecurityVersion = 2 }
};
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(mockAccountKeys);
@@ -759,7 +837,8 @@ public class BaseRequestValidatorTests
_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.WithWebAuthnLoginCredential(Arg.Any<WebAuthnCredential>())
.Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions
{
HasMasterPassword = true,
@@ -808,13 +887,18 @@ public class BaseRequestValidatorTests
Assert.Equal("test-security-state", accountKeysResponse.SecurityState.SecurityState);
Assert.Equal(2, accountKeysResponse.SecurityState.SecurityVersion);
}
[Theory, BitAutoData]
[Theory]
[BitAutoData(true)]
[BitAutoData(false)]
public async Task ValidateAsync_CustomResponse_AccountKeysQuery_SkippedWhenPrivateKeyIsNull(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
requestContext.User.PrivateKey = null;
var context = CreateContext(tokenRequest, requestContext, grantResult);
@@ -833,13 +917,18 @@ public class BaseRequestValidatorTests
// Verify that the account keys query wasn't called.
await _userAccountKeysQuery.Received(0).Run(Arg.Any<User>());
}
[Theory, BitAutoData]
[Theory]
[BitAutoData(true)]
[BitAutoData(false)]
public async Task ValidateAsync_CustomResponse_AccountKeysQuery_CalledWithCorrectUser(
bool featureFlagValue,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue);
var expectedUser = requestContext.User;
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData
@@ -853,7 +942,8 @@ public class BaseRequestValidatorTests
_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.WithWebAuthnLoginCredential(Arg.Any<WebAuthnCredential>())
.Returns(_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions()));
var context = CreateContext(tokenRequest, requestContext, grantResult);
@@ -874,6 +964,285 @@ public class BaseRequestValidatorTests
await _userAccountKeysQuery.Received(1).Run(Arg.Is<User>(u => u.Id == expectedUser.Id));
}
/// <summary>
/// Tests the core PM-21153 feature: SSO-required users can use recovery codes to disable 2FA,
/// but must then authenticate via SSO with a descriptive message about the recovery.
/// This test validates:
/// 1. Validation order is changed (2FA before SSO) when recovery code is provided
/// 2. Recovery code successfully validates and sets TwoFactorRecoveryRequested flag
/// 3. SSO validation then fails with recovery-specific message
/// 4. User is NOT logged in (must authenticate via IdP)
/// </summary>
[Theory]
[BitAutoData(true)] // Feature flag ON - new behavior
[BitAutoData(false)] // Feature flag OFF - should fail at SSO before 2FA recovery
public async Task ValidateAsync_RecoveryCodeForSsoRequiredUser_BlocksWithDescriptiveMessage(
bool featureFlagEnabled,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagEnabled);
var context = CreateContext(tokenRequest, requestContext, grantResult);
var user = requestContext.User;
// Reset state that AutoFixture may have populated
requestContext.TwoFactorRecoveryRequested = false;
requestContext.RememberMeRequested = false;
// 1. Master password is valid
_sut.isValid = true;
// 2. SSO is required (this user is in an org that requires SSO)
_policyService.AnyPoliciesApplicableToUserAsync(
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
.Returns(Task.FromResult(true));
// 3. 2FA is required
_twoFactorAuthenticationValidator
.RequiresTwoFactorAsync(user, tokenRequest)
.Returns(Task.FromResult(new Tuple<bool, Organization>(true, null)));
// 4. Provide a RECOVERY CODE (this triggers the special validation order)
tokenRequest.Raw["TwoFactorProvider"] = ((int)TwoFactorProviderType.RecoveryCode).ToString();
tokenRequest.Raw["TwoFactorToken"] = "valid-recovery-code-12345";
// 5. Recovery code is valid (UserService.RecoverTwoFactorAsync will be called internally)
_twoFactorAuthenticationValidator
.VerifyTwoFactorAsync(user, null, TwoFactorProviderType.RecoveryCode, "valid-recovery-code-12345")
.Returns(Task.FromResult(true));
// Act
await _sut.ValidateAsync(context);
// Assert
Assert.True(context.GrantResult.IsError, "Authentication should fail - SSO required after recovery");
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
if (featureFlagEnabled)
{
// NEW BEHAVIOR: Recovery succeeds, then SSO blocks with descriptive message
Assert.Equal(
"Two-factor recovery has been performed. SSO authentication is required.",
errorResponse.Message);
// Verify recovery was marked
Assert.True(requestContext.TwoFactorRecoveryRequested,
"TwoFactorRecoveryRequested flag should be set");
}
else
{
// LEGACY BEHAVIOR: SSO blocks BEFORE recovery can happen
Assert.Equal(
"SSO authentication is required.",
errorResponse.Message);
// Recovery never happened because SSO checked first
Assert.False(requestContext.TwoFactorRecoveryRequested,
"TwoFactorRecoveryRequested should be false (SSO blocked first)");
}
// In both cases: User is NOT logged in
await _eventService.DidNotReceive().LogUserEventAsync(user.Id, EventType.User_LoggedIn);
}
/// <summary>
/// Tests that validation order changes when a recovery code is PROVIDED (even if invalid).
/// This ensures the RecoveryCodeRequestForSsoRequiredUserScenario() logic is based on
/// request structure, not validation outcome. An SSO-required user who provides an
/// INVALID recovery code should:
/// 1. Have 2FA validated BEFORE SSO (new order)
/// 2. Get a 2FA error (invalid token)
/// 3. NOT get the recovery-specific SSO message (because recovery didn't complete)
/// 4. NOT be logged in
/// </summary>
[Theory]
[BitAutoData(true)]
[BitAutoData(false)]
public async Task ValidateAsync_InvalidRecoveryCodeForSsoRequiredUser_FailsAt2FA(
bool featureFlagEnabled,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagEnabled);
var context = CreateContext(tokenRequest, requestContext, grantResult);
var user = requestContext.User;
// 1. Master password is valid
_sut.isValid = true;
// 2. SSO is required
_policyService.AnyPoliciesApplicableToUserAsync(
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
.Returns(Task.FromResult(true));
// 3. 2FA is required
_twoFactorAuthenticationValidator
.RequiresTwoFactorAsync(user, tokenRequest)
.Returns(Task.FromResult(new Tuple<bool, Organization>(true, null)));
// 4. Provide a RECOVERY CODE (triggers validation order change)
tokenRequest.Raw["TwoFactorProvider"] = ((int)TwoFactorProviderType.RecoveryCode).ToString();
tokenRequest.Raw["TwoFactorToken"] = "INVALID-recovery-code";
// 5. Recovery code is INVALID
_twoFactorAuthenticationValidator
.VerifyTwoFactorAsync(user, null, TwoFactorProviderType.RecoveryCode, "INVALID-recovery-code")
.Returns(Task.FromResult(false));
// 6. Setup for failed 2FA email (if feature flag enabled)
_featureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail).Returns(true);
// Act
await _sut.ValidateAsync(context);
// Assert
Assert.True(context.GrantResult.IsError, "Authentication should fail - invalid recovery code");
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
if (featureFlagEnabled)
{
// NEW BEHAVIOR: 2FA is checked first (due to recovery code request), fails with 2FA error
Assert.Equal(
"Two-step token is invalid. Try again.",
errorResponse.Message);
// Recovery was attempted but failed - flag should NOT be set
Assert.False(requestContext.TwoFactorRecoveryRequested,
"TwoFactorRecoveryRequested should be false (recovery failed)");
// Verify failed 2FA email was sent
await _mailService.Received(1).SendFailedTwoFactorAttemptEmailAsync(
user.Email,
TwoFactorProviderType.RecoveryCode,
Arg.Any<DateTime>(),
Arg.Any<string>());
// Verify failed login event was logged
await _eventService.Received(1).LogUserEventAsync(user.Id, EventType.User_FailedLogIn2fa);
}
else
{
// LEGACY BEHAVIOR: SSO is checked first, blocks before 2FA
Assert.Equal(
"SSO authentication is required.",
errorResponse.Message);
// 2FA validation never happened
await _mailService.DidNotReceive().SendFailedTwoFactorAttemptEmailAsync(
Arg.Any<string>(),
Arg.Any<TwoFactorProviderType>(),
Arg.Any<DateTime>(),
Arg.Any<string>());
}
// In both cases: User is NOT logged in
await _eventService.DidNotReceive().LogUserEventAsync(user.Id, EventType.User_LoggedIn);
// Verify user failed login count was updated (in new behavior path)
if (featureFlagEnabled)
{
await _userRepository.Received(1).ReplaceAsync(Arg.Is<User>(u =>
u.Id == user.Id && u.FailedLoginCount > 0));
}
}
/// <summary>
/// Tests that non-SSO users can successfully use recovery codes to disable 2FA and log in.
/// This validates:
/// 1. Validation order changes to 2FA-first when recovery code is provided
/// 2. Recovery code validates successfully
/// 3. SSO check passes (user not in SSO-required org)
/// 4. User successfully logs in
/// 5. TwoFactorRecoveryRequested flag is set (for logging/audit purposes)
/// This is the "happy path" for recovery code usage.
/// </summary>
[Theory]
[BitAutoData(true)]
[BitAutoData(false)]
public async Task ValidateAsync_RecoveryCodeForNonSsoUser_SuccessfulLogin(
bool featureFlagEnabled,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagEnabled);
var context = CreateContext(tokenRequest, requestContext, grantResult);
var user = requestContext.User;
// 1. Master password is valid
_sut.isValid = true;
// 2. SSO is NOT required (this is a regular user, not in SSO org)
_policyService.AnyPoliciesApplicableToUserAsync(
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
.Returns(Task.FromResult(false));
// 3. 2FA is required
_twoFactorAuthenticationValidator
.RequiresTwoFactorAsync(user, tokenRequest)
.Returns(Task.FromResult(new Tuple<bool, Organization>(true, null)));
// 4. Provide a RECOVERY CODE
tokenRequest.Raw["TwoFactorProvider"] = ((int)TwoFactorProviderType.RecoveryCode).ToString();
tokenRequest.Raw["TwoFactorToken"] = "valid-recovery-code-67890";
// 5. Recovery code is valid
_twoFactorAuthenticationValidator
.VerifyTwoFactorAsync(user, null, TwoFactorProviderType.RecoveryCode, "valid-recovery-code-67890")
.Returns(Task.FromResult(true));
// 6. Device validation passes
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
.Returns(Task.FromResult(true));
// 7. User is not legacy
_userService.IsLegacyUser(Arg.Any<string>())
.Returns(false);
// 8. Setup user account keys for successful login response
_userAccountKeysQuery.Run(Arg.Any<User>()).Returns(new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(
"test-private-key",
"test-public-key"
)
});
// Act
await _sut.ValidateAsync(context);
// Assert
Assert.False(context.GrantResult.IsError, "Authentication should succeed for non-SSO user with valid recovery code");
// Verify user successfully logged in
await _eventService.Received(1).LogUserEventAsync(user.Id, EventType.User_LoggedIn);
// Verify failed login count was reset (successful login)
await _userRepository.Received(1).ReplaceAsync(Arg.Is<User>(u =>
u.Id == user.Id && u.FailedLoginCount == 0));
if (featureFlagEnabled)
{
// NEW BEHAVIOR: Recovery flag should be set for audit purposes
Assert.True(requestContext.TwoFactorRecoveryRequested,
"TwoFactorRecoveryRequested flag should be set for audit/logging");
}
else
{
// LEGACY BEHAVIOR: Recovery flag doesn't exist, but login still succeeds
// (SSO check happens before 2FA in legacy, but user is not SSO-required so both pass)
Assert.False(requestContext.TwoFactorRecoveryRequested,
"TwoFactorRecoveryRequested should be false in legacy mode");
}
}
private BaseRequestValidationContextFake CreateContext(
ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,

View File

@@ -1,6 +1,7 @@
using AutoFixture;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Dirt.Entities;
using Bit.Core.Dirt.Reports.Models.Data;
using Bit.Core.Dirt.Repositories;
using Bit.Core.Repositories;
using Bit.Core.Test.AutoFixture.Attributes;
@@ -489,6 +490,49 @@ public class OrganizationReportRepositoryTests
Assert.Null(result);
}
[CiSkippedTheory, EfOrganizationReportAutoData]
public async Task UpdateMetricsAsync_ShouldUpdateMetricsCorrectly(
OrganizationReportRepository sqlOrganizationReportRepo,
SqlRepo.OrganizationRepository sqlOrganizationRepo)
{
// Arrange
var (org, report) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlOrganizationReportRepo);
var metrics = new OrganizationReportMetricsData
{
ApplicationCount = 10,
ApplicationAtRiskCount = 2,
CriticalApplicationCount = 5,
CriticalApplicationAtRiskCount = 1,
MemberCount = 20,
MemberAtRiskCount = 4,
CriticalMemberCount = 10,
CriticalMemberAtRiskCount = 2,
PasswordCount = 100,
PasswordAtRiskCount = 15,
CriticalPasswordCount = 50,
CriticalPasswordAtRiskCount = 5
};
// Act
await sqlOrganizationReportRepo.UpdateMetricsAsync(report.Id, metrics);
var updatedReport = await sqlOrganizationReportRepo.GetByIdAsync(report.Id);
// Assert
Assert.Equal(metrics.ApplicationCount, updatedReport.ApplicationCount);
Assert.Equal(metrics.ApplicationAtRiskCount, updatedReport.ApplicationAtRiskCount);
Assert.Equal(metrics.CriticalApplicationCount, updatedReport.CriticalApplicationCount);
Assert.Equal(metrics.CriticalApplicationAtRiskCount, updatedReport.CriticalApplicationAtRiskCount);
Assert.Equal(metrics.MemberCount, updatedReport.MemberCount);
Assert.Equal(metrics.MemberAtRiskCount, updatedReport.MemberAtRiskCount);
Assert.Equal(metrics.CriticalMemberCount, updatedReport.CriticalMemberCount);
Assert.Equal(metrics.CriticalMemberAtRiskCount, updatedReport.CriticalMemberAtRiskCount);
Assert.Equal(metrics.PasswordCount, updatedReport.PasswordCount);
Assert.Equal(metrics.PasswordAtRiskCount, updatedReport.PasswordAtRiskCount);
Assert.Equal(metrics.CriticalPasswordCount, updatedReport.CriticalPasswordCount);
Assert.Equal(metrics.CriticalPasswordAtRiskCount, updatedReport.CriticalPasswordAtRiskCount);
}
private async Task<(Organization, OrganizationReport)> CreateOrganizationAndReportAsync(
IOrganizationRepository orgRepo,
IOrganizationReportRepository orgReportRepo)

View File

@@ -1,6 +1,7 @@
using AspNetCoreRateLimit;
using Bit.Core.Billing.Organizations.Services;
using Bit.Core.Billing.Services;
using Bit.Core.Platform.Mail.Delivery;
using Bit.Core.Platform.Push;
using Bit.Core.Platform.PushRegistration.Internal;
using Bit.Core.Repositories;