using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.KeyManagement.Validators; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; namespace Bit.Api.Test.KeyManagement.Validators; [SutProviderCustomize] public class OrganizationUserRotationValidatorTests { [Theory] [BitAutoData] public async Task ValidateAsync_Success_ReturnsValid( SutProvider sutProvider, User user, IEnumerable resetPasswordKeys) { var existingUserResetPassword = resetPasswordKeys .Select(a => new OrganizationUser { Id = new Guid(), ResetPasswordKey = a.ResetPasswordKey, OrganizationId = a.OrganizationId }).ToList(); sutProvider.GetDependency().GetManyByUserAsync(user.Id) .Returns(existingUserResetPassword); var result = await sutProvider.Sut.ValidateAsync(user, resetPasswordKeys); Assert.Equal(result.Select(r => r.OrganizationId), resetPasswordKeys.Select(a => a.OrganizationId)); } [Theory] [BitAutoData] public async Task ValidateAsync_NullResetPasswordKeys_ReturnsEmptyList( SutProvider sutProvider, User user) { // Arrange IEnumerable resetPasswordKeys = null; // Act var result = await sutProvider.Sut.ValidateAsync(user, resetPasswordKeys); // Assert Assert.NotNull(result); Assert.Empty(result); } [Theory] [BitAutoData] public async Task ValidateAsync_NoOrgUsers_ReturnsEmptyList( SutProvider sutProvider, User user, IEnumerable resetPasswordKeys) { // Arrange sutProvider.GetDependency().GetManyByUserAsync(user.Id) .Returns(new List()); // Return an empty list // Act var result = await sutProvider.Sut.ValidateAsync(user, resetPasswordKeys); // Assert Assert.NotNull(result); Assert.Empty(result); } [Theory] [BitAutoData([null])] [BitAutoData("")] public async Task ValidateAsync_OrgUsersWithNullOrEmptyResetPasswordKey_FiltersOutInvalidKeys( string? invalidResetPasswordKey, SutProvider sutProvider, User user, ResetPasswordWithOrgIdRequestModel validResetPasswordKey) { // Arrange var existingUserResetPassword = new List { // Valid org user with reset password key new OrganizationUser { Id = Guid.NewGuid(), OrganizationId = validResetPasswordKey.OrganizationId, ResetPasswordKey = validResetPasswordKey.ResetPasswordKey }, // Invalid org user with null or empty reset password key - should be filtered out new OrganizationUser { Id = Guid.NewGuid(), OrganizationId = Guid.NewGuid(), ResetPasswordKey = invalidResetPasswordKey } }; sutProvider.GetDependency().GetManyByUserAsync(user.Id) .Returns(existingUserResetPassword); // Act var result = await sutProvider.Sut.ValidateAsync(user, new[] { validResetPasswordKey }); // Assert Assert.NotNull(result); Assert.Single(result); Assert.Equal(validResetPasswordKey.OrganizationId, result[0].OrganizationId); } [Theory] [BitAutoData] public async Task ValidateAsync_MissingResetPassword_Throws( SutProvider sutProvider, User user, IEnumerable resetPasswordKeys) { var existingUserResetPassword = resetPasswordKeys .Select(a => new OrganizationUser { Id = new Guid(), ResetPasswordKey = a.ResetPasswordKey, OrganizationId = a.OrganizationId }).ToList(); existingUserResetPassword.Add(new OrganizationUser { Id = Guid.NewGuid(), ResetPasswordKey = "Missing ResetPasswordKey" }); sutProvider.GetDependency().GetManyByUserAsync(user.Id) .Returns(existingUserResetPassword); await Assert.ThrowsAsync(async () => await sutProvider.Sut.ValidateAsync(user, resetPasswordKeys)); } [Theory] [BitAutoData] public async Task ValidateAsync_ResetPasswordDoesNotBelongToUser_NotReturned( SutProvider sutProvider, User user, IEnumerable resetPasswordKeys) { var existingUserResetPassword = resetPasswordKeys .Select(a => new OrganizationUser { Id = new Guid(), ResetPasswordKey = a.ResetPasswordKey, OrganizationId = a.OrganizationId }).ToList(); existingUserResetPassword.RemoveAt(0); sutProvider.GetDependency().GetManyByUserAsync(user.Id) .Returns(existingUserResetPassword); var result = await sutProvider.Sut.ValidateAsync(user, resetPasswordKeys); Assert.DoesNotContain(result, c => c.Id == resetPasswordKeys.First().OrganizationId); } [Theory] [BitAutoData] public async Task ValidateAsync_AttemptToSetKeyToNull_Throws( SutProvider sutProvider, User user, IEnumerable resetPasswordKeys) { var existingUserResetPassword = resetPasswordKeys .Select(a => new OrganizationUser { Id = new Guid(), ResetPasswordKey = a.ResetPasswordKey, OrganizationId = a.OrganizationId }).ToList(); sutProvider.GetDependency().GetManyByUserAsync(user.Id) .Returns(existingUserResetPassword); resetPasswordKeys.First().ResetPasswordKey = null; await Assert.ThrowsAsync(async () => await sutProvider.Sut.ValidateAsync(user, resetPasswordKeys)); } [Theory] [BitAutoData] public async Task ValidateAsync_NoOrganizationsInRequestButInDatabase_Throws( SutProvider sutProvider, User user, IEnumerable resetPasswordKeys) { var existingUserResetPassword = resetPasswordKeys .Select(a => new OrganizationUser { Id = new Guid(), ResetPasswordKey = a.ResetPasswordKey, OrganizationId = a.OrganizationId }).ToList(); sutProvider.GetDependency().GetManyByUserAsync(user.Id) .Returns(existingUserResetPassword); await Assert.ThrowsAsync(async () => await sutProvider.Sut.ValidateAsync(user, Enumerable.Empty())); } // TODO: Remove this test after https://bitwarden.atlassian.net/browse/PM-31001 is resolved. // Clients currently send "" as a reset password key value during rotation due to a client-side bug. // The server must accept "" to avoid blocking key rotation for affected users. // After PM-31001 is fixed, this should be replaced with a test asserting that "" throws BadRequestException. [Theory] [BitAutoData("")] [BitAutoData(" ")] public async Task ValidateAsync_EmptyOrWhitespaceKey_AcceptedDueToClientBug( string emptyKey, SutProvider sutProvider, User user, ResetPasswordWithOrgIdRequestModel validResetPasswordKey) { // Arrange var existingUserResetPassword = new List { new OrganizationUser { Id = Guid.NewGuid(), OrganizationId = validResetPasswordKey.OrganizationId, ResetPasswordKey = "existing-valid-key" } }; sutProvider.GetDependency().GetManyByUserAsync(user.Id) .Returns(existingUserResetPassword); // Set the incoming key to empty/whitespace (simulating client bug) validResetPasswordKey.ResetPasswordKey = emptyKey; // Act — rotation should succeed (not throw) to preserve backward compatibility var result = await sutProvider.Sut.ValidateAsync(user, new[] { validResetPasswordKey }); // Assert Assert.NotNull(result); Assert.Single(result); Assert.Equal(emptyKey, result[0].ResetPasswordKey); } [Theory] [BitAutoData(" ")] public async Task ValidateAsync_WhitespaceOnlyExistingKey_FiltersOut( string whitespaceKey, SutProvider sutProvider, User user, ResetPasswordWithOrgIdRequestModel validResetPasswordKey) { // Arrange var existingUserResetPassword = new List { new OrganizationUser { Id = Guid.NewGuid(), OrganizationId = validResetPasswordKey.OrganizationId, ResetPasswordKey = validResetPasswordKey.ResetPasswordKey }, // Whitespace-only key should be filtered out new OrganizationUser { Id = Guid.NewGuid(), OrganizationId = Guid.NewGuid(), ResetPasswordKey = whitespaceKey } }; sutProvider.GetDependency().GetManyByUserAsync(user.Id) .Returns(existingUserResetPassword); // Act var result = await sutProvider.Sut.ValidateAsync(user, new[] { validResetPasswordKey }); // Assert Assert.NotNull(result); Assert.Single(result); Assert.Equal(validResetPasswordKey.OrganizationId, result[0].OrganizationId); } }