From 98a191a5e80ccf54c87a2342091d946f2d45c125 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Sun, 26 May 2024 20:56:52 -0500 Subject: [PATCH] Allow for bulk processing new login device requests (#4064) * Define a model for updating many auth requests In order to facilitate a command method that can update many auth requests at one time a new model must be defined that accepts valid input for the command's needs. To achieve this a new file has been created at `Core/AdminConsole/OrganizationAuth/Models/OrganizationAuthRequestUpdateCommandModel.cs` that contains a class of the same name. It's properties match those that need to come from any calling API request models to fulfill the request. * Declare a new command interface method Calling API functions of the `UpdateOrganizationAuthRequestCommand` need a function that can accept many auth request response objects and process them as approved or denied. To achieve this a new function has been added to `IUpdateOrganizationAuthRequestCommand` called `UpdateManyAsync()` that accepts an `IEnumberable` and returns a `Task`. Implementations of this interface method will be used to bulk process auth requests as approved or denied. * Stub out method implementation for unit testing To facilitate a bulk device login request approval workflow in the admin console `UpdateOrganizationAuthRequestCommand` needs to be updated to include an `UpdateMany()` method. It should accept a list of `OrganizationAuthRequestUpdateCommandModel` objects, perform some simple data validation checks, and then pass those along to `AuthRequestRepository` for updating in the database. This commit stubs out this method for the purpose of writing unit tests. At this stage the method throws a `NotImplementedException()`. It will be expand after writing assertions. * Inject `IAuthRequestRepository` into `UpdateOrganizationAuthCommand` The updates to `UpdateOrganizationAuthRequestCommand` require a new direct dependency on `IAuthRequestRepository`. This commit simply registers this dependency in the `UpdateOrganizationAuthRequest` constructor for use in unit tests and the `UpdateManyAsync()` implementation. * Write tests * Rename `UpdateManyAsync()` to `UpdateAsync` * Drop the `CommandModel` suffix * Invert business logic update filters * Rework everything to be more model-centric * Bulk send push notifications * Write tests that validate the command as a whole * Fix a test that I broke by mistake * Swap to using await instead of chained methods for processing * Seperate a function arguement into a variable declaration * Ungeneric-ify the processor * Adjust ternary formatting * Adjust naming of methods regarding logging organization events * Throw an exception if Process is called with no auth request loaded * Rename `_updates` -> `_update` * Rename email methods * Stop returning `this` * Allow callbacks to be null * Make some assertions about the state of a processed auth request * Be more terse about arguements in happy path test * Remove unneeded null check * Expose an endpoint for bulk processing of organization auth requests (#4077) --------- Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> --- .../OrganizationAuthRequestsController.cs | 12 +- ...zationAuthRequestUpdateManyRequestModel.cs | 24 ++ .../IUpdateOrganizationAuthRequestCommand.cs | 5 +- ...pprovedAuthRequestIsMissingKeyException.cs | 9 + ...questUpdateCouldNotBeProcessedException.cs | 14 + .../AuthRequestUpdateProcessingException.cs | 9 + .../Models/AuthRequestUpdateProcessor.cs | 105 +++++++ ...AuthRequestUpdateProcessorConfiguration.cs | 8 + .../Models/BatchAuthRequestUpdateProcessor.cs | 86 ++++++ .../Models/OrganizationAuthRequestUpdate.cs | 8 + .../UpdateOrganizationAuthRequestCommand.cs | 88 +++++- ...OrganizationAuthRequestsControllerTests.cs | 64 +++++ .../Models/AuthRequestUpdateProcessorTests.cs | 258 ++++++++++++++++++ .../BatchAuthRequestUpdateProcessorTests.cs | 205 ++++++++++++++ ...dateOrganizationAuthRequestCommandTests.cs | 180 ++++++++++-- 15 files changed, 1054 insertions(+), 21 deletions(-) create mode 100644 src/Api/AdminConsole/Models/Request/OrganizationAuthRequestUpdateManyRequestModel.cs create mode 100644 src/Core/AdminConsole/OrganizationAuth/Models/ApprovedAuthRequestIsMissingKeyException.cs create mode 100644 src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateCouldNotBeProcessedException.cs create mode 100644 src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessingException.cs create mode 100644 src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessor.cs create mode 100644 src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessorConfiguration.cs create mode 100644 src/Core/AdminConsole/OrganizationAuth/Models/BatchAuthRequestUpdateProcessor.cs create mode 100644 src/Core/AdminConsole/OrganizationAuth/Models/OrganizationAuthRequestUpdate.cs create mode 100644 test/Api.Test/AdminConsole/Controllers/OrganizationAuthRequestsControllerTests.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessorTests.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationAuth/Models/BatchAuthRequestUpdateProcessorTests.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationAuthRequestsController.cs b/src/Api/AdminConsole/Controllers/OrganizationAuthRequestsController.cs index cb06b6211..dbb73f870 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationAuthRequestsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationAuthRequestsController.cs @@ -1,12 +1,14 @@ using Bit.Api.AdminConsole.Models.Request; using Bit.Api.AdminConsole.Models.Response; using Bit.Api.Models.Response; +using Bit.Core; using Bit.Core.AdminConsole.OrganizationAuth.Interfaces; using Bit.Core.Auth.Models.Api.Request.AuthRequest; using Bit.Core.Auth.Services; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Repositories; +using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -73,7 +75,15 @@ public class OrganizationAuthRequestsController : Controller } } - private async Task ValidateAdminRequest(Guid orgId) + [RequireFeature(FeatureFlagKeys.BulkDeviceApproval)] + [HttpPost("")] + public async Task UpdateManyAuthRequests(Guid orgId, [FromBody] IEnumerable model) + { + await ValidateAdminRequest(orgId); + await _updateOrganizationAuthRequestCommand.UpdateAsync(orgId, model.Select(x => x.ToOrganizationAuthRequestUpdate())); + } + + public async Task ValidateAdminRequest(Guid orgId) { if (!await _currentContext.ManageResetPassword(orgId)) { diff --git a/src/Api/AdminConsole/Models/Request/OrganizationAuthRequestUpdateManyRequestModel.cs b/src/Api/AdminConsole/Models/Request/OrganizationAuthRequestUpdateManyRequestModel.cs new file mode 100644 index 000000000..34a45369b --- /dev/null +++ b/src/Api/AdminConsole/Models/Request/OrganizationAuthRequestUpdateManyRequestModel.cs @@ -0,0 +1,24 @@ +using Bit.Core.AdminConsole.OrganizationAuth.Models; +using Bit.Core.Utilities; + +namespace Bit.Api.AdminConsole.Models.Request; + +public class OrganizationAuthRequestUpdateManyRequestModel +{ + public Guid Id { get; set; } + + [EncryptedString] + public string Key { get; set; } + + public bool Approved { get; set; } + + public OrganizationAuthRequestUpdate ToOrganizationAuthRequestUpdate() + { + return new OrganizationAuthRequestUpdate + { + Id = Id, + Key = Key, + Approved = Approved + }; + } +} diff --git a/src/Core/AdminConsole/OrganizationAuth/Interfaces/IUpdateOrganizationAuthRequestCommand.cs b/src/Core/AdminConsole/OrganizationAuth/Interfaces/IUpdateOrganizationAuthRequestCommand.cs index 4b119d1e8..936ae08c0 100644 --- a/src/Core/AdminConsole/OrganizationAuth/Interfaces/IUpdateOrganizationAuthRequestCommand.cs +++ b/src/Core/AdminConsole/OrganizationAuth/Interfaces/IUpdateOrganizationAuthRequestCommand.cs @@ -1,6 +1,9 @@ -namespace Bit.Core.AdminConsole.OrganizationAuth.Interfaces; +using Bit.Core.AdminConsole.OrganizationAuth.Models; + +namespace Bit.Core.AdminConsole.OrganizationAuth.Interfaces; public interface IUpdateOrganizationAuthRequestCommand { Task UpdateAsync(Guid requestId, Guid userId, bool requestApproved, string encryptedUserKey); + Task UpdateAsync(Guid organizationId, IEnumerable authRequestUpdates); } diff --git a/src/Core/AdminConsole/OrganizationAuth/Models/ApprovedAuthRequestIsMissingKeyException.cs b/src/Core/AdminConsole/OrganizationAuth/Models/ApprovedAuthRequestIsMissingKeyException.cs new file mode 100644 index 000000000..a51bec56e --- /dev/null +++ b/src/Core/AdminConsole/OrganizationAuth/Models/ApprovedAuthRequestIsMissingKeyException.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.AdminConsole.OrganizationAuth.Models; + +public class ApprovedAuthRequestIsMissingKeyException : AuthRequestUpdateProcessingException +{ + public ApprovedAuthRequestIsMissingKeyException(Guid id) + : base($"An auth request with id {id} was approved, but no key was provided. This auth request can not be approved.") + { + } +} diff --git a/src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateCouldNotBeProcessedException.cs b/src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateCouldNotBeProcessedException.cs new file mode 100644 index 000000000..40d39b247 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateCouldNotBeProcessedException.cs @@ -0,0 +1,14 @@ +namespace Bit.Core.AdminConsole.OrganizationAuth.Models; + +public class AuthRequestUpdateCouldNotBeProcessedException : AuthRequestUpdateProcessingException +{ + public AuthRequestUpdateCouldNotBeProcessedException() + : base($"An auth request could not be processed.") + { + } + + public AuthRequestUpdateCouldNotBeProcessedException(Guid id) + : base($"An auth request with id {id} could not be processed.") + { + } +} diff --git a/src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessingException.cs b/src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessingException.cs new file mode 100644 index 000000000..b08476ecb --- /dev/null +++ b/src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessingException.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.AdminConsole.OrganizationAuth.Models; + +public class AuthRequestUpdateProcessingException : Exception +{ + public AuthRequestUpdateProcessingException() { } + + public AuthRequestUpdateProcessingException(string message) + : base(message) { } +} diff --git a/src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessor.cs b/src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessor.cs new file mode 100644 index 000000000..59b5025ee --- /dev/null +++ b/src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessor.cs @@ -0,0 +1,105 @@ +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.OrganizationAuth.Models; + +public class AuthRequestUpdateProcessor +{ + public OrganizationAdminAuthRequest ProcessedAuthRequest { get; private set; } + + private OrganizationAdminAuthRequest _unprocessedAuthRequest { get; } + private OrganizationAuthRequestUpdate _update { get; } + private AuthRequestUpdateProcessorConfiguration _configuration { get; } + + public EventType OrganizationEventType => ProcessedAuthRequest?.Approved.Value ?? false + ? EventType.OrganizationUser_ApprovedAuthRequest + : EventType.OrganizationUser_RejectedAuthRequest; + + public AuthRequestUpdateProcessor( + OrganizationAdminAuthRequest authRequest, + OrganizationAuthRequestUpdate update, + AuthRequestUpdateProcessorConfiguration configuration + ) + { + _unprocessedAuthRequest = authRequest; + _update = update; + _configuration = configuration; + } + + public void Process() + { + if (_unprocessedAuthRequest == null) + { + throw new AuthRequestUpdateCouldNotBeProcessedException(); + } + var isExpired = DateTime.UtcNow > + _unprocessedAuthRequest.CreationDate + .Add(_configuration.AuthRequestExpiresAfter); + var isSpent = _unprocessedAuthRequest.Approved != null || + _unprocessedAuthRequest.ResponseDate.HasValue || + _unprocessedAuthRequest.AuthenticationDate.HasValue; + var canBeProcessed = !isExpired && + !isSpent && + _unprocessedAuthRequest.Id == _update.Id && + _unprocessedAuthRequest.OrganizationId == _configuration.OrganizationId; + if (!canBeProcessed) + { + throw new AuthRequestUpdateCouldNotBeProcessedException(_unprocessedAuthRequest.Id); + } + if (_update.Approved) + { + Approve(); + return; + } + Deny(); + } + + public async Task SendPushNotification(Func callback) + { + if (!ProcessedAuthRequest?.Approved ?? false) + { + return; + } + await callback(ProcessedAuthRequest); + } + + public async Task SendApprovalEmail(Func callback) + { + if (!ProcessedAuthRequest?.Approved ?? false) + { + return; + } + var deviceTypeDisplayName = _unprocessedAuthRequest.RequestDeviceType.GetType() + .GetMember(_unprocessedAuthRequest.RequestDeviceType.ToString()) + .FirstOrDefault()? + // This unknown case can't be unit tested without adding an enum + // with no display attribute. Faith and trust are required! + .GetCustomAttribute()?.Name ?? "Unknown Device Type"; + var deviceTypeAndIdentifierDisplayString = + string.IsNullOrWhiteSpace(_unprocessedAuthRequest.RequestDeviceIdentifier) + ? deviceTypeDisplayName + : $"{deviceTypeDisplayName} - {_unprocessedAuthRequest.RequestDeviceIdentifier}"; + await callback(ProcessedAuthRequest, deviceTypeAndIdentifierDisplayString); + } + + private void Approve() + { + if (string.IsNullOrWhiteSpace(_update.Key)) + { + throw new ApprovedAuthRequestIsMissingKeyException(_update.Id); + } + ProcessedAuthRequest = _unprocessedAuthRequest; + ProcessedAuthRequest.Key = _update.Key; + ProcessedAuthRequest.Approved = true; + ProcessedAuthRequest.ResponseDate = DateTime.UtcNow; + } + + private void Deny() + { + ProcessedAuthRequest = _unprocessedAuthRequest; + ProcessedAuthRequest.Approved = false; + ProcessedAuthRequest.ResponseDate = DateTime.UtcNow; + } +} diff --git a/src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessorConfiguration.cs b/src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessorConfiguration.cs new file mode 100644 index 000000000..301fbb552 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessorConfiguration.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.AdminConsole.OrganizationAuth.Models; + +public class AuthRequestUpdateProcessorConfiguration +{ + public Guid OrganizationId { get; set; } + public TimeSpan AuthRequestExpiresAfter { get; set; } +} + diff --git a/src/Core/AdminConsole/OrganizationAuth/Models/BatchAuthRequestUpdateProcessor.cs b/src/Core/AdminConsole/OrganizationAuth/Models/BatchAuthRequestUpdateProcessor.cs new file mode 100644 index 000000000..3ebcd1fb5 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationAuth/Models/BatchAuthRequestUpdateProcessor.cs @@ -0,0 +1,86 @@ +using Bit.Core.Auth.Models.Data; +using Bit.Core.Enums; + +namespace Bit.Core.AdminConsole.OrganizationAuth.Models; + +public class BatchAuthRequestUpdateProcessor +{ + public List Processors { get; } = new List(); + private List _processed => Processors + .Where(p => p.ProcessedAuthRequest != null) + .ToList(); + + public BatchAuthRequestUpdateProcessor( + ICollection authRequests, + IEnumerable updates, + AuthRequestUpdateProcessorConfiguration configuration + ) + { + Processors = authRequests?.Select(ar => + { + return new AuthRequestUpdateProcessor( + ar, + updates.FirstOrDefault(u => u.Id == ar.Id), + configuration + ); + }).ToList() ?? Processors; + } + + public BatchAuthRequestUpdateProcessor Process(Action errorHandlerCallback) + { + foreach (var processor in Processors) + { + try + { + processor.Process(); + } + catch (AuthRequestUpdateProcessingException e) + { + errorHandlerCallback(e); + } + } + return this; + } + + public async Task Save(Func, Task> callback) + { + if (_processed.Any()) + { + await callback(_processed.Select(p => p.ProcessedAuthRequest)); + } + } + + // Currently push notifications and emails are still done per-request in + // a loop, which is different than saving updates to the database and + // raising organization events. These can be done in bulk all the way + // through to the repository. + // + // Adding bulk notification and email methods is being tracked as tech + // debt on https://bitwarden.atlassian.net/browse/AC-2629 + public async Task SendPushNotifications(Func callback) + { + foreach (var processor in _processed) + { + await processor.SendPushNotification(callback); + } + } + + public async Task SendApprovalEmailsForProcessedRequests(Func callback) + { + foreach (var processor in _processed) + { + await processor.SendApprovalEmail(callback); + } + } + + public async Task LogOrganizationEventsForProcessedRequests(Func, Task> callback) + { + if (_processed.Any()) + { + await callback(_processed.Select(p => + { + return (p.ProcessedAuthRequest, p.OrganizationEventType); + })); + } + } +} diff --git a/src/Core/AdminConsole/OrganizationAuth/Models/OrganizationAuthRequestUpdate.cs b/src/Core/AdminConsole/OrganizationAuth/Models/OrganizationAuthRequestUpdate.cs new file mode 100644 index 000000000..5a4b4ed76 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationAuth/Models/OrganizationAuthRequestUpdate.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.AdminConsole.OrganizationAuth.Models; + +public class OrganizationAuthRequestUpdate +{ + public Guid Id { get; set; } + public bool Approved { get; set; } + public string Key { get; set; } +} diff --git a/src/Core/AdminConsole/OrganizationAuth/UpdateOrganizationAuthRequestCommand.cs b/src/Core/AdminConsole/OrganizationAuth/UpdateOrganizationAuthRequestCommand.cs index 79d2d70e8..407ca61c4 100644 --- a/src/Core/AdminConsole/OrganizationAuth/UpdateOrganizationAuthRequestCommand.cs +++ b/src/Core/AdminConsole/OrganizationAuth/UpdateOrganizationAuthRequestCommand.cs @@ -1,10 +1,15 @@ using System.ComponentModel.DataAnnotations; using System.Reflection; using Bit.Core.AdminConsole.OrganizationAuth.Interfaces; +using Bit.Core.AdminConsole.OrganizationAuth.Models; +using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Api.Request.AuthRequest; +using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Services; +using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Settings; using Microsoft.Extensions.Logging; namespace Bit.Core.AdminConsole.OrganizationAuth; @@ -15,19 +20,38 @@ public class UpdateOrganizationAuthRequestCommand : IUpdateOrganizationAuthReque private readonly IMailService _mailService; private readonly IUserRepository _userRepository; private readonly ILogger _logger; + private readonly IAuthRequestRepository _authRequestRepository; + private readonly IGlobalSettings _globalSettings; + private readonly IPushNotificationService _pushNotificationService; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IEventService _eventService; public UpdateOrganizationAuthRequestCommand( IAuthRequestService authRequestService, IMailService mailService, IUserRepository userRepository, - ILogger logger) + ILogger logger, + IAuthRequestRepository authRequestRepository, + IGlobalSettings globalSettings, + IPushNotificationService pushNotificationService, + IOrganizationUserRepository organizationUserRepository, + IEventService eventService) { _authRequestService = authRequestService; _mailService = mailService; _userRepository = userRepository; _logger = logger; + _authRequestRepository = authRequestRepository; + _globalSettings = globalSettings; + _pushNotificationService = pushNotificationService; + _organizationUserRepository = organizationUserRepository; + _eventService = eventService; } + // TODO: When refactoring this method as a part of Bulk Device Approval + // post-release cleanup we should be able to construct a single + // AuthRequestProcessor and run its Process() Save() methods, and the + // various calls to send notifications. public async Task UpdateAsync(Guid requestId, Guid userId, bool requestApproved, string encryptedUserKey) { var updatedAuthRequest = await _authRequestService.UpdateAuthRequestAsync(requestId, userId, @@ -51,5 +75,65 @@ public class UpdateOrganizationAuthRequestCommand : IUpdateOrganizationAuthReque updatedAuthRequest.RequestIpAddress, deviceTypeAndIdentifier); } } -} + public async Task UpdateAsync(Guid organizationId, IEnumerable authRequestUpdates) + { + var authRequestEntities = await FetchManyOrganizationAuthRequestsFromTheDatabase(organizationId, authRequestUpdates.Select(aru => aru.Id)); + var processor = new BatchAuthRequestUpdateProcessor( + authRequestEntities, + authRequestUpdates, + new AuthRequestUpdateProcessorConfiguration() + { + OrganizationId = organizationId, + AuthRequestExpiresAfter = _globalSettings.PasswordlessAuth.AdminRequestExpiration + } + ); + processor.Process((Exception e) => _logger.LogError(e.Message)); + await processor.Save((IEnumerable authRequests) => _authRequestRepository.UpdateManyAsync(authRequests)); + await processor.SendPushNotifications((ar) => _pushNotificationService.PushAuthRequestResponseAsync(ar)); + await processor.SendApprovalEmailsForProcessedRequests(SendApprovalEmail); + await processor.LogOrganizationEventsForProcessedRequests(LogOrganizationEvents); + } + + async Task> FetchManyOrganizationAuthRequestsFromTheDatabase(Guid organizationId, IEnumerable authRequestIds) + { + return authRequestIds != null && authRequestIds.Any() + ? await _authRequestRepository + .GetManyAdminApprovalRequestsByManyIdsAsync( + organizationId, + authRequestIds + ) + : new List(); + } + + async Task SendApprovalEmail(T authRequest, string identifier) where T : AuthRequest + { + var user = await _userRepository.GetByIdAsync(authRequest.UserId); + + // This should be impossible + if (user == null) + { + _logger.LogError($"User {authRequest.UserId} not found. Trusted device admin approval email not sent."); + return; + } + + await _mailService.SendTrustedDeviceAdminApprovalEmailAsync( + user.Email, + authRequest.ResponseDate ?? DateTime.UtcNow, + authRequest.RequestIpAddress, + identifier + ); + } + + async Task LogOrganizationEvents(IEnumerable<(OrganizationAdminAuthRequest AuthRequest, EventType EventType)> events) + { + var organizationUsers = await _organizationUserRepository.GetManyAsync(events.Select(e => e.AuthRequest.OrganizationUserId)); + await _eventService.LogOrganizationUserEventsAsync( + organizationUsers.Select(ou => + { + var e = events.FirstOrDefault(e => e.AuthRequest.OrganizationUserId == ou.Id); + return (ou, e.EventType, e.AuthRequest.ResponseDate); + }) + ); + } +} diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationAuthRequestsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationAuthRequestsControllerTests.cs new file mode 100644 index 000000000..0008f6fe6 --- /dev/null +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationAuthRequestsControllerTests.cs @@ -0,0 +1,64 @@ +using Bit.Api.AdminConsole.Controllers; +using Bit.Api.AdminConsole.Models.Request; +using Bit.Core.Context; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Controllers; + +[ControllerCustomize(typeof(OrganizationAuthRequestsController))] +[SutProviderCustomize] +public class OrganizationAuthRequestsControllerTests +{ + + [Theory] + [BitAutoData] + public async Task ValidateAdminRequest_UserDoesNotHaveManageResetPasswordPermissions_ThrowsUnauthorized( + SutProvider sutProvider, + Guid organizationId + ) + { + sutProvider.GetDependency().ManageResetPassword(organizationId).Returns(false); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.ValidateAdminRequest(organizationId)); + } + + [Theory] + [BitAutoData] + public async Task ValidateAdminRequest_UserHasManageResetPasswordPermissions_DoesNotThrow( + SutProvider sutProvider, + Guid organizationId + ) + { + sutProvider.GetDependency().ManageResetPassword(organizationId).Returns(true); + await sutProvider.Sut.ValidateAdminRequest(organizationId); + } + + [Theory] + [BitAutoData] + public async Task UpdateManyAuthRequests_ValidInput_DoesNotThrow( + SutProvider sutProvider, + IEnumerable request, + Guid organizationId + ) + { + sutProvider.GetDependency().ManageResetPassword(organizationId).Returns(true); + await sutProvider.Sut.UpdateManyAuthRequests(organizationId, request); + } + + [Theory] + [BitAutoData] + public async Task UpdateManyAuthRequests_NotPermissioned_ThrowsUnauthorized( + SutProvider sutProvider, + IEnumerable request, + Guid organizationId + ) + { + sutProvider.GetDependency().ManageResetPassword(organizationId).Returns(false); + await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateManyAuthRequests(organizationId, request)); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessorTests.cs b/test/Core.Test/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessorTests.cs new file mode 100644 index 000000000..1312fad43 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationAuth/Models/AuthRequestUpdateProcessorTests.cs @@ -0,0 +1,258 @@ +using Bit.Core.AdminConsole.OrganizationAuth.Models; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Enums; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationAuth.Models; + +[SutProviderCustomize] +public class AuthRequestUpdateProcessorTests +{ + [Theory] + [BitAutoData] + public void Process_NoAuthRequestLoaded_Throws( + OrganizationAuthRequestUpdate update, + AuthRequestUpdateProcessorConfiguration processorConfiguration + ) + { + var sut = new AuthRequestUpdateProcessor(null, update, processorConfiguration); + Assert.ThrowsAny(() => sut.Process()); + } + + [Theory] + [BitAutoData] + public void Process_RequestIsAlreadyApproved_Throws( + OrganizationAdminAuthRequest authRequest, + OrganizationAuthRequestUpdate update, + AuthRequestUpdateProcessorConfiguration processorConfiguration + ) + { + (authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration); + authRequest = Approve(authRequest); + var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration); + Assert.ThrowsAny(() => sut.Process()); + } + + [Theory] + [BitAutoData] + public void Process_RequestIsAlreadyDenied_Throws( + OrganizationAdminAuthRequest authRequest, + OrganizationAuthRequestUpdate update, + AuthRequestUpdateProcessorConfiguration processorConfiguration + ) + { + (authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration); + authRequest = Deny(authRequest); + var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration); + Assert.ThrowsAny(() => sut.Process()); + } + + [Theory] + [BitAutoData] + public void Process_RequestIsExpired_Throws( + OrganizationAdminAuthRequest authRequest, + OrganizationAuthRequestUpdate update, + AuthRequestUpdateProcessorConfiguration processorConfiguration + ) + { + (authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration); + processorConfiguration.AuthRequestExpiresAfter = new TimeSpan(0, 10, 0); + authRequest.CreationDate = DateTime.UtcNow.AddMinutes(-60); + var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration); + Assert.ThrowsAny(() => sut.Process()); + } + + [Theory] + [BitAutoData] + public void Process_UpdateDoesNotMatch_Throws( + OrganizationAdminAuthRequest authRequest, + OrganizationAuthRequestUpdate update, + AuthRequestUpdateProcessorConfiguration processorConfiguration + ) + { + (authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration); + while (authRequest.Id == update.Id) + { + authRequest.Id = new Guid(); + } + var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration); + Assert.ThrowsAny(() => sut.Process()); + } + + [Theory] + [BitAutoData] + public void Process_AuthRequestAndOrganizationIdMismatch_Throws( + OrganizationAdminAuthRequest authRequest, + OrganizationAuthRequestUpdate update, + AuthRequestUpdateProcessorConfiguration processorConfiguration + ) + { + (authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration); + while (authRequest.OrganizationId == processorConfiguration.OrganizationId) + { + authRequest.OrganizationId = new Guid(); + } + var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration); + Assert.ThrowsAny(() => sut.Process()); + } + + [Theory] + [BitAutoData] + public void Process_RequestApproved_NoKey_Throws( + OrganizationAdminAuthRequest authRequest, + OrganizationAuthRequestUpdate update, + AuthRequestUpdateProcessorConfiguration processorConfiguration + ) + { + (authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration); + update.Approved = true; + update.Key = null; + var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration); + Assert.ThrowsAny(() => sut.Process()); + } + + [Theory] + [BitAutoData] + public void Process_RequestApproved_ValidInput_Works( + OrganizationAdminAuthRequest authRequest, + OrganizationAuthRequestUpdate update, + AuthRequestUpdateProcessorConfiguration processorConfiguration + ) + { + (authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration); + update.Approved = true; + update.Key = "key"; + var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration); + sut.Process(); + Assert.True(sut.ProcessedAuthRequest.Approved); + Assert.Equal(sut.ProcessedAuthRequest.Key, update.Key); + Assert.NotNull(sut.ProcessedAuthRequest.ResponseDate); + } + + [Theory] + [BitAutoData] + public void Process_RequestDenied_ValidInput_Works( + OrganizationAdminAuthRequest authRequest, + OrganizationAuthRequestUpdate update, + AuthRequestUpdateProcessorConfiguration processorConfiguration + ) + { + (authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration); + update.Approved = false; + var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration); + sut.Process(); + Assert.False(sut.ProcessedAuthRequest.Approved); + Assert.Null(sut.ProcessedAuthRequest.Key); + Assert.NotNull(sut.ProcessedAuthRequest.ResponseDate); + } + + [Theory] + [BitAutoData] + public async Task SendPushNotification_RequestIsDenied_DoesNotSend( + OrganizationAdminAuthRequest authRequest, + OrganizationAuthRequestUpdate update, + AuthRequestUpdateProcessorConfiguration processorConfiguration + ) + { + (authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration); + update.Approved = false; + var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration); + var callback = Substitute.For>(); + sut.Process(); + await sut.SendPushNotification(callback); + await callback.DidNotReceiveWithAnyArgs()(sut.ProcessedAuthRequest); + } + + [Theory] + [BitAutoData] + public async Task SendPushNotification_RequestIsApproved_DoesSend( + OrganizationAdminAuthRequest authRequest, + OrganizationAuthRequestUpdate update, + AuthRequestUpdateProcessorConfiguration processorConfiguration + ) + { + (authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration); + update.Approved = true; + update.Key = "key"; + var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration); + var callback = Substitute.For>(); + sut.Process(); + await sut.SendPushNotification(callback); + await callback.Received()(sut.ProcessedAuthRequest); + } + + [Theory] + [BitAutoData] + public async Task SendApprovalEmail_RequestIsDenied_DoesNotSend( + OrganizationAdminAuthRequest authRequest, + OrganizationAuthRequestUpdate update, + AuthRequestUpdateProcessorConfiguration processorConfiguration + ) + { + (authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration); + update.Approved = false; + var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration); + var callback = Substitute.For>(); + sut.Process(); + await sut.SendApprovalEmail(callback); + await callback.DidNotReceiveWithAnyArgs()(sut.ProcessedAuthRequest, "string"); + } + + [Theory] + [BitAutoData] + public async Task SendApprovalEmail_RequestIsApproved_DoesSend( + OrganizationAdminAuthRequest authRequest, + OrganizationAuthRequestUpdate update, + AuthRequestUpdateProcessorConfiguration processorConfiguration + ) + { + (authRequest, processorConfiguration) = UnrespondAndEnsureValid(authRequest, update, processorConfiguration); + authRequest.RequestDeviceType = DeviceType.iOS; + authRequest.RequestDeviceIdentifier = "device-id"; + update.Approved = true; + update.Key = "key"; + var sut = new AuthRequestUpdateProcessor(authRequest, update, processorConfiguration); + var callback = Substitute.For>(); + sut.Process(); + await sut.SendApprovalEmail(callback); + await callback.Received()(sut.ProcessedAuthRequest, "iOS - device-id"); + } + + private static T Approve(T authRequest) where T : AuthRequest + { + authRequest.Key = "key"; + authRequest.Approved = true; + authRequest.ResponseDate = DateTime.UtcNow; + return authRequest; + } + + private static T Deny(T authRequest) where T : AuthRequest + { + authRequest.Approved = false; + authRequest.ResponseDate = DateTime.UtcNow; + return authRequest; + } + + private ( + T AuthRequest, + AuthRequestUpdateProcessorConfiguration ProcessorConfiguration + ) UnrespondAndEnsureValid( + T authRequest, + OrganizationAuthRequestUpdate update, + AuthRequestUpdateProcessorConfiguration processorConfiguration + ) where T : AuthRequest + { + authRequest.Id = update.Id; + authRequest.OrganizationId = processorConfiguration.OrganizationId; + authRequest.Key = null; + authRequest.Approved = null; + authRequest.ResponseDate = null; + authRequest.AuthenticationDate = null; + authRequest.CreationDate = DateTime.UtcNow.AddMinutes(-1); + processorConfiguration.AuthRequestExpiresAfter = new TimeSpan(1, 0, 0); + return (authRequest, processorConfiguration); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationAuth/Models/BatchAuthRequestUpdateProcessorTests.cs b/test/Core.Test/AdminConsole/OrganizationAuth/Models/BatchAuthRequestUpdateProcessorTests.cs new file mode 100644 index 000000000..f65ac20fe --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationAuth/Models/BatchAuthRequestUpdateProcessorTests.cs @@ -0,0 +1,205 @@ +using Bit.Core.AdminConsole.OrganizationAuth.Models; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Enums; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationAuth.Models; + +[SutProviderCustomize] +public class BatchAuthRequestUpdateProcessorTests +{ + [Theory] + [BitAutoData] + public void Process_NoProcessors_Handled( + IEnumerable updates, + AuthRequestUpdateProcessorConfiguration configuration, + Action errorHandler + ) + { + var sut = new BatchAuthRequestUpdateProcessor(null, updates, configuration); + sut.Process(errorHandler); + } + + [Theory] + [BitAutoData] + public void Process_BadInput_CallsHandler( + List authRequests, + IEnumerable updates, + AuthRequestUpdateProcessorConfiguration configuration + ) + { + // An already approved auth request should break the processor + // immediately. + authRequests[0].Approved = true; + var sut = new BatchAuthRequestUpdateProcessor(authRequests, updates, configuration); + var errorHandler = Substitute.For>(); + sut.Process(errorHandler); + errorHandler.ReceivedWithAnyArgs()(new AuthRequestUpdateProcessingException()); + } + + [Theory] + [BitAutoData] + public void Process_ValidInput_Works( + List authRequests, + List updates, + AuthRequestUpdateProcessorConfiguration configuration, + Action errorHandler + ) + { + (authRequests[0], updates[0], configuration) = UnrespondAndEnsureValid(authRequests[0], updates[0], configuration); + var sut = new BatchAuthRequestUpdateProcessor(authRequests, updates, configuration); + Assert.NotEmpty(sut.Processors); + sut.Process(errorHandler); + Assert.NotEmpty(sut.Processors.Where(p => p.ProcessedAuthRequest != null)); + } + + [Theory] + [BitAutoData] + public async Task Save_NoProcessedAuthRequests_IsHandled( + List updates, + AuthRequestUpdateProcessorConfiguration configuration, + Func, Task> saveCallback + ) + { + var sut = new BatchAuthRequestUpdateProcessor(null, updates, configuration); + Assert.Empty(sut.Processors); + await sut.Save(saveCallback); + } + + [Theory] + [BitAutoData] + public async Task Save_ProcessedAuthRequests_IsHandled( + List authRequests, + List updates, + AuthRequestUpdateProcessorConfiguration configuration, + Action errorHandler + ) + { + (authRequests[0], updates[0], configuration) = UnrespondAndEnsureValid(authRequests[0], updates[0], configuration); + var sut = new BatchAuthRequestUpdateProcessor(authRequests, updates, configuration); + var saveCallback = Substitute.For, Task>>(); + await sut.Process(errorHandler).Save(saveCallback); + await saveCallback.ReceivedWithAnyArgs()(Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task SendPushNotifications_NoProcessors_IsHandled + ( + List updates, + AuthRequestUpdateProcessorConfiguration configuration, + Func callback + ) + { + var sut = new BatchAuthRequestUpdateProcessor(null, updates, configuration); + Assert.Empty(sut.Processors); + await sut.SendPushNotifications(callback); + } + + [Theory] + [BitAutoData] + public async Task SendPushNotifications_HasProcessors_Sends + ( + List authRequests, + List updates, + AuthRequestUpdateProcessorConfiguration configuration, + Action errorHandler + ) + { + (authRequests[0], updates[0], configuration) = UnrespondAndEnsureValid(authRequests[0], updates[0], configuration); + var sut = new BatchAuthRequestUpdateProcessor(authRequests, updates, configuration); + var callback = Substitute.For>(); + await sut.Process(errorHandler).SendPushNotifications(callback); + await callback.ReceivedWithAnyArgs()(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task SendApprovalEmailsForProcessedRequests_NoProcessors_IsHandled + ( + List updates, + AuthRequestUpdateProcessorConfiguration configuration, + Func callback + ) + { + var sut = new BatchAuthRequestUpdateProcessor(null, updates, configuration); + Assert.Empty(sut.Processors); + await sut.SendApprovalEmailsForProcessedRequests(callback); + } + + [Theory] + [BitAutoData] + public async Task SendApprovalEmailsForProcessedRequests_HasProcessors_Sends + ( + List authRequests, + List updates, + AuthRequestUpdateProcessorConfiguration configuration, + Action errorHandler + ) + { + (authRequests[0], updates[0], configuration) = UnrespondAndEnsureValid(authRequests[0], updates[0], configuration); + var sut = new BatchAuthRequestUpdateProcessor(authRequests, updates, configuration); + var callback = Substitute.For>(); + await sut.Process(errorHandler).SendApprovalEmailsForProcessedRequests(callback); + await callback.ReceivedWithAnyArgs()(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task LogOrganizationEventsForProcessedRequests_NoProcessedAuthRequests_IsHandled + ( + List updates, + AuthRequestUpdateProcessorConfiguration configuration + ) + { + var sut = new BatchAuthRequestUpdateProcessor(null, updates, configuration); + var callback = Substitute.For, Task>>(); + Assert.Empty(sut.Processors); + await sut.LogOrganizationEventsForProcessedRequests(callback); + await callback.DidNotReceiveWithAnyArgs()(Arg.Any>()); + } + + [Theory] + [BitAutoData] + public async Task LogOrganizationEventsForProcessedRequests_HasProcessedAuthRequests_IsHandled + ( + List authRequests, + List updates, + AuthRequestUpdateProcessorConfiguration configuration, + Action errorHandler + ) + { + (authRequests[0], updates[0], configuration) = UnrespondAndEnsureValid(authRequests[0], updates[0], configuration); + var sut = new BatchAuthRequestUpdateProcessor(authRequests, updates, configuration); + var callback = Substitute.For, Task>>(); + await sut.Process(errorHandler).LogOrganizationEventsForProcessedRequests(callback); + await callback.ReceivedWithAnyArgs()(Arg.Any>()); + } + + private ( + T authRequest, + OrganizationAuthRequestUpdate update, + AuthRequestUpdateProcessorConfiguration ProcessorConfiguration + ) UnrespondAndEnsureValid( + T authRequest, + OrganizationAuthRequestUpdate update, + AuthRequestUpdateProcessorConfiguration processorConfiguration + ) where T : AuthRequest + { + authRequest.Id = update.Id; + authRequest.OrganizationId = processorConfiguration.OrganizationId; + authRequest.Key = null; + authRequest.Approved = null; + authRequest.ResponseDate = null; + authRequest.AuthenticationDate = null; + authRequest.CreationDate = DateTime.UtcNow.AddMinutes(-1); + processorConfiguration.AuthRequestExpiresAfter = new TimeSpan(1, 0, 0); + + update.Approved = true; + update.Key = "key"; + return (authRequest, update, processorConfiguration); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationAuth/UpdateOrganizationAuthRequestCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationAuth/UpdateOrganizationAuthRequestCommandTests.cs index ac37158c5..9dcfee78a 100644 --- a/test/Core.Test/AdminConsole/OrganizationAuth/UpdateOrganizationAuthRequestCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationAuth/UpdateOrganizationAuthRequestCommandTests.cs @@ -1,15 +1,17 @@ using Bit.Core.AdminConsole.OrganizationAuth; +using Bit.Core.AdminConsole.OrganizationAuth.Models; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Api.Request.AuthRequest; +using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Settings; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; -using NSubstitute.ReturnsExtensions; using Xunit; namespace Bit.Core.Test.AdminConsole.OrganizationAuth; @@ -76,25 +78,169 @@ public class UpdateOrganizationAuthRequestCommandTests [Theory] [BitAutoData] - public async Task UpdateOrgAuthRequest_Approved_UserNotFound( - SutProvider sutProvider, Guid requestId, Guid userId, - bool requestApproved, string encryptedUserKey) + public async Task UpdateAsync_BatchUpdate_AuthRequestForOrganizationNotFound_DoesNotExecute( + SutProvider sutProvider, + List updates, + AuthRequestUpdateProcessorConfiguration configuration) { - sutProvider.GetDependency() - .UpdateAuthRequestAsync(requestId, userId, - Arg.Is(x => - x.RequestApproved == requestApproved && x.Key == encryptedUserKey)) - .Returns(new AuthRequest() { Approved = true, }); + sutProvider.GetDependency().GetManyAdminApprovalRequestsByManyIdsAsync( + configuration.OrganizationId, + updates.Select(ar => ar.Id) + ).ReturnsForAnyArgs((ICollection)null); - sutProvider.GetDependency() - .GetByIdAsync(userId) - .ReturnsNull(); + await sutProvider.Sut.UpdateAsync(configuration.OrganizationId, updates); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().UpdateManyAsync(Arg.Any>()); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().PushAuthRequestResponseAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().SendTrustedDeviceAdminApprovalEmailAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().LogOrganizationUserEventsAsync( + Arg.Any>() + ); + } - await sutProvider.Sut.UpdateAsync(requestId, userId, requestApproved, encryptedUserKey); + [Theory] + [BitAutoData] + public async Task UpdateAsync_BatchUpdate_ValidRequest_SavesAndFiresAllEvents( + SutProvider sutProvider, + List updates, + List unprocessedAuthRequests, + AuthRequestUpdateProcessorConfiguration configuration, + List organizationUsers, + List users + ) + { + // For this command to work we need the following from external + // classes: + // 1. A configured expiration timespan for organization auth requests + // 2. Some unresponded to auth requests that match the ids provided + // 3. A valid user to send emails to + // 4. A valid organization user to log events for - await sutProvider.GetDependency().Received(1).GetByIdAsync(userId); - await sutProvider.GetDependency().DidNotReceive() - .SendTrustedDeviceAdminApprovalEmailAsync(Arg.Any(), Arg.Any(), Arg.Any(), - Arg.Any()); + for (int i = 0; i < updates.Count(); i++) + { + unprocessedAuthRequests[i] = UnrespondAndEnsureValid(unprocessedAuthRequests[i], configuration.OrganizationId); + updates[i].Approved = true; + updates[i].Key = "key"; + unprocessedAuthRequests[i].Id = updates[i].Id; + unprocessedAuthRequests[i].RequestDeviceType = DeviceType.iOS; + unprocessedAuthRequests[i].OrganizationUserId = organizationUsers[i].Id; + organizationUsers[i].OrganizationId = configuration.OrganizationId; + users[i].Id = unprocessedAuthRequests[i].UserId; + organizationUsers[i].UserId = unprocessedAuthRequests[i].UserId; + + sutProvider.GetDependency().GetByIdAsync(Arg.Is(users[i].Id)).Returns(users[i]); + }; + + sutProvider.GetDependency().PasswordlessAuth.AdminRequestExpiration.Returns(TimeSpan.FromDays(7)); + + sutProvider.GetDependency().GetManyAdminApprovalRequestsByManyIdsAsync( + configuration.OrganizationId, + updates.Select(ar => ar.Id) + ).ReturnsForAnyArgs(unprocessedAuthRequests); + + sutProvider.GetDependency().GetManyAsync(Arg.Is>( + list => list.All(x => organizationUsers.Select(y => y.Id).Contains(x)))).Returns(organizationUsers); + + // Call the SUT + await sutProvider.Sut.UpdateAsync(configuration.OrganizationId, updates); + + // Assert that because we passed in good data we call a save + // operation and raise all events + await sutProvider.GetDependency() + .Received() + .UpdateManyAsync( + Arg.Is>(list => + list.Any() && + list.All(x => + x.Approved.Value && + x.Key == "key" && + x.ResponseDate != null && + unprocessedAuthRequests.Select(y => y.Id).Contains(x.Id)))); + + foreach (var authRequest in unprocessedAuthRequests) + { + await sutProvider.GetDependency().Received() + .PushAuthRequestResponseAsync(Arg.Is + (ar => ar.Id == authRequest.Id && ar.Approved == true && ar.Key == "key")); + + await sutProvider.GetDependency().Received().SendTrustedDeviceAdminApprovalEmailAsync( + users.FirstOrDefault(x => x.Id == authRequest.UserId).Email, + Arg.Any(), + authRequest.RequestIpAddress, + $"iOS - {authRequest.RequestDeviceIdentifier}" + ); + } + + await sutProvider.GetDependency().Received().LogOrganizationUserEventsAsync( + Arg.Is>(list => + list.Any() && list.All(x => organizationUsers.Any(y => y.Id == x.o.Id) && x.e == EventType.OrganizationUser_ApprovedAuthRequest) + )); + } + + [Theory] + [BitAutoData] + public async Task UpdateAsync_BatchUpdate_AuthRequestIsDenied_DoesNotLeakRejection( + SutProvider sutProvider, + List updates, + OrganizationAdminAuthRequest unprocessedAuthRequest, + AuthRequestUpdateProcessorConfiguration configuration, + User user + ) + { + // For this command to work we need the following from external + // classes: + // 1. A configured expiration timespan for organization auth requests + // 2. Some unresponded to auth requests that match the ids provided + // 3. A valid user to send emails to + + var unprocessedAuthRequests = new List(); + unprocessedAuthRequest = UnrespondAndEnsureValid(unprocessedAuthRequest, configuration.OrganizationId); + foreach (var update in updates) + { + update.Approved = false; + unprocessedAuthRequest.Id = update.Id; + unprocessedAuthRequests.Add(unprocessedAuthRequest); + }; + + sutProvider.GetDependency().PasswordlessAuth.AdminRequestExpiration.Returns(TimeSpan.FromDays(7)); + + sutProvider.GetDependency().GetManyAdminApprovalRequestsByManyIdsAsync( + configuration.OrganizationId, + updates.Select(ar => ar.Id) + ).ReturnsForAnyArgs(unprocessedAuthRequests); + + sutProvider.GetDependency().GetByIdAsync(Arg.Any()).Returns(user); + + // Call the SUT + await sutProvider.Sut.UpdateAsync(configuration.OrganizationId, updates); + + // Assert that because we passed in good data we call a save + // operation and raise all events + await sutProvider.GetDependency().ReceivedWithAnyArgs().UpdateManyAsync(Arg.Any>()); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().PushAuthRequestResponseAsync(Arg.Any()); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().SendTrustedDeviceAdminApprovalEmailAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ); + await sutProvider.GetDependency().ReceivedWithAnyArgs().LogOrganizationUserEventsAsync( + Arg.Any>() + ); + } + + private T UnrespondAndEnsureValid(T authRequest, Guid organizationId) where T : AuthRequest + { + authRequest.OrganizationId = organizationId; + authRequest.Key = null; + authRequest.Approved = null; + authRequest.ResponseDate = null; + authRequest.AuthenticationDate = null; + authRequest.CreationDate = DateTime.UtcNow.AddMinutes(-10); + return authRequest; } }