From 0eeea7df636e98d729ae5452ec72f25b4fa3c68e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Wed, 11 Jun 2025 15:26:00 -0400 Subject: [PATCH] Revert "PM-18939 refactoring send service to 'cqrs' (#5652)" This reverts commit 818934487f03062723152f771ffd3c38e6360ceb. --- src/Admin/Tools/Jobs/DeleteSendsJob.cs | 6 +- .../Validators/SendRotationValidator.cs | 10 +- src/Api/Startup.cs | 2 - src/Api/Tools/Controllers/SendsController.cs | 129 +- .../Tools/Models/Request/SendRequestModel.cs | 16 +- .../Tools/Models/Data/SendAccessResult.cs | 19 - .../Commands/AnonymousSendCommand.cs | 52 - .../Interfaces/IAnonymousSendCommand.cs | 21 - .../Interfaces/INonAnonymousSendCommand.cs | 53 - .../Commands/NonAnonymousSendCommand.cs | 160 --- .../SendServiceCollectionExtension.cs | 21 - .../Interfaces/ISendAuthorizationService.cs | 28 - .../Interfaces/ISendCoreHelperService.cs | 17 - .../Interfaces/ISendStorageService.cs | 71 -- .../Interfaces/ISendValidationService.cs | 35 - .../Services/SendAuthorizationService.cs | 81 -- .../Services/SendCoreHelperService.cs | 12 - .../Services/SendFileSettingHelper.cs | 26 - .../Services/SendValidationService.cs | 142 --- src/Core/Tools/Services/ISendService.cs | 16 + .../Tools/Services/ISendStorageService.cs | 16 + .../AzureSendFileStorageService.cs | 0 .../LocalSendStorageService.cs | 0 .../Services/Implementations/SendService.cs | 361 ++++++ .../Utilities/ServiceCollectionExtensions.cs | 4 +- .../Validators/SendRotationValidatorTests.cs | 12 +- .../Tools/Controllers/SendsControllerTests.cs | 17 +- .../Models/Request/SendRequestModelTests.cs | 6 +- .../Services/AnonymousSendCommandTests.cs | 118 -- .../Services/NonAnonymousSendCommandTests.cs | 1064 ----------------- .../Services/SendAuthorizationServiceTests.cs | 168 --- .../Tools/Services/SendServiceTests.cs | 867 ++++++++++++++ 32 files changed, 1346 insertions(+), 2204 deletions(-) delete mode 100644 src/Core/Tools/Models/Data/SendAccessResult.cs delete mode 100644 src/Core/Tools/SendFeatures/Commands/AnonymousSendCommand.cs delete mode 100644 src/Core/Tools/SendFeatures/Commands/Interfaces/IAnonymousSendCommand.cs delete mode 100644 src/Core/Tools/SendFeatures/Commands/Interfaces/INonAnonymousSendCommand.cs delete mode 100644 src/Core/Tools/SendFeatures/Commands/NonAnonymousSendCommand.cs delete mode 100644 src/Core/Tools/SendFeatures/SendServiceCollectionExtension.cs delete mode 100644 src/Core/Tools/SendFeatures/Services/Interfaces/ISendAuthorizationService.cs delete mode 100644 src/Core/Tools/SendFeatures/Services/Interfaces/ISendCoreHelperService.cs delete mode 100644 src/Core/Tools/SendFeatures/Services/Interfaces/ISendStorageService.cs delete mode 100644 src/Core/Tools/SendFeatures/Services/Interfaces/ISendValidationService.cs delete mode 100644 src/Core/Tools/SendFeatures/Services/SendAuthorizationService.cs delete mode 100644 src/Core/Tools/SendFeatures/Services/SendCoreHelperService.cs delete mode 100644 src/Core/Tools/SendFeatures/Services/SendFileSettingHelper.cs delete mode 100644 src/Core/Tools/SendFeatures/Services/SendValidationService.cs create mode 100644 src/Core/Tools/Services/ISendService.cs create mode 100644 src/Core/Tools/Services/ISendStorageService.cs rename src/Core/Tools/{SendFeatures/Services => Services/Implementations}/AzureSendFileStorageService.cs (100%) rename src/Core/Tools/{SendFeatures/Services => Services/Implementations}/LocalSendStorageService.cs (100%) create mode 100644 src/Core/Tools/Services/Implementations/SendService.cs delete mode 100644 test/Core.Test/Tools/Services/AnonymousSendCommandTests.cs delete mode 100644 test/Core.Test/Tools/Services/NonAnonymousSendCommandTests.cs delete mode 100644 test/Core.Test/Tools/Services/SendAuthorizationServiceTests.cs create mode 100644 test/Core.Test/Tools/Services/SendServiceTests.cs diff --git a/src/Admin/Tools/Jobs/DeleteSendsJob.cs b/src/Admin/Tools/Jobs/DeleteSendsJob.cs index 7449d2ea01..dafce03994 100644 --- a/src/Admin/Tools/Jobs/DeleteSendsJob.cs +++ b/src/Admin/Tools/Jobs/DeleteSendsJob.cs @@ -2,7 +2,7 @@ using Bit.Core; using Bit.Core.Jobs; using Bit.Core.Tools.Repositories; -using Bit.Core.Tools.SendFeatures.Commands.Interfaces; +using Bit.Core.Tools.Services; using Quartz; namespace Bit.Admin.Tools.Jobs; @@ -32,10 +32,10 @@ public class DeleteSendsJob : BaseJob } using (var scope = _serviceProvider.CreateScope()) { - var nonAnonymousSendCommand = scope.ServiceProvider.GetRequiredService(); + var sendService = scope.ServiceProvider.GetRequiredService(); foreach (var send in sends) { - await nonAnonymousSendCommand.DeleteSendAsync(send); + await sendService.DeleteSendAsync(send); } } } diff --git a/src/Api/KeyManagement/Validators/SendRotationValidator.cs b/src/Api/KeyManagement/Validators/SendRotationValidator.cs index 10a5d996b7..c39f563b51 100644 --- a/src/Api/KeyManagement/Validators/SendRotationValidator.cs +++ b/src/Api/KeyManagement/Validators/SendRotationValidator.cs @@ -12,17 +12,17 @@ namespace Bit.Api.KeyManagement.Validators; /// public class SendRotationValidator : IRotationValidator, IReadOnlyList> { - private readonly ISendAuthorizationService _sendAuthorizationService; + private readonly ISendService _sendService; private readonly ISendRepository _sendRepository; /// /// Instantiates a new /// - /// Enables conversion of to + /// Enables conversion of to /// Retrieves all user s - public SendRotationValidator(ISendAuthorizationService sendAuthorizationService, ISendRepository sendRepository) + public SendRotationValidator(ISendService sendService, ISendRepository sendRepository) { - _sendAuthorizationService = sendAuthorizationService; + _sendService = sendService; _sendRepository = sendRepository; } @@ -44,7 +44,7 @@ public class SendRotationValidator : IRotationValidator _logger; private readonly GlobalSettings _globalSettings; public SendsController( ISendRepository sendRepository, IUserService userService, - ISendAuthorizationService sendAuthorizationService, - IAnonymousSendCommand anonymousSendCommand, - INonAnonymousSendCommand nonAnonymousSendCommand, + ISendService sendService, ISendFileStorageService sendFileStorageService, ILogger logger, GlobalSettings globalSettings) { _sendRepository = sendRepository; _userService = userService; - _sendAuthorizationService = sendAuthorizationService; - _anonymousSendCommand = anonymousSendCommand; - _nonAnonymousSendCommand = nonAnonymousSendCommand; + _sendService = sendService; _sendFileStorageService = sendFileStorageService; _logger = logger; _globalSettings = globalSettings; } - #region Anonymous endpoints [AllowAnonymous] [HttpPost("access/{id}")] public async Task Access(string id, [FromBody] SendAccessRequestModel model) @@ -66,19 +57,18 @@ public class SendsController : Controller //} var guid = new Guid(CoreHelpers.Base64UrlDecode(id)); - var send = await _sendRepository.GetByIdAsync(guid); - SendAccessResult sendAuthResult = - await _sendAuthorizationService.AccessAsync(send, model.Password); - if (sendAuthResult.Equals(SendAccessResult.PasswordRequired)) + var (send, passwordRequired, passwordInvalid) = + await _sendService.AccessAsync(guid, model.Password); + if (passwordRequired) { return new UnauthorizedResult(); } - if (sendAuthResult.Equals(SendAccessResult.PasswordInvalid)) + if (passwordInvalid) { await Task.Delay(2000); throw new BadRequestException("Invalid password."); } - if (sendAuthResult.Equals(SendAccessResult.Denied)) + if (send == null) { throw new NotFoundException(); } @@ -112,19 +102,19 @@ public class SendsController : Controller throw new BadRequestException("Could not locate send"); } - var (url, result) = await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId, + var (url, passwordRequired, passwordInvalid) = await _sendService.GetSendFileDownloadUrlAsync(send, fileId, model.Password); - if (result.Equals(SendAccessResult.PasswordRequired)) + if (passwordRequired) { return new UnauthorizedResult(); } - if (result.Equals(SendAccessResult.PasswordInvalid)) + if (passwordInvalid) { await Task.Delay(2000); throw new BadRequestException("Invalid password."); } - if (result.Equals(SendAccessResult.Denied)) + if (send == null) { throw new NotFoundException(); } @@ -136,45 +126,6 @@ public class SendsController : Controller }); } - [AllowAnonymous] - [HttpPost("file/validate/azure")] - public async Task AzureValidateFile() - { - return await ApiHelpers.HandleAzureEvents(Request, new Dictionary> - { - { - "Microsoft.Storage.BlobCreated", async (eventGridEvent) => - { - try - { - var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1]; - var sendId = AzureSendFileStorageService.SendIdFromBlobName(blobName); - var send = await _sendRepository.GetByIdAsync(new Guid(sendId)); - if (send == null) - { - if (_sendFileStorageService is AzureSendFileStorageService azureSendFileStorageService) - { - await azureSendFileStorageService.DeleteBlobAsync(blobName); - } - return; - } - - await _nonAnonymousSendCommand.ConfirmFileSize(send); - } - catch (Exception e) - { - _logger.LogError(e, $"Uncaught exception occurred while handling event grid event: {JsonSerializer.Serialize(eventGridEvent)}"); - return; - } - } - } - }); - } - - #endregion - - #region Non-anonymous endpoints - [HttpGet("{id}")] public async Task Get(string id) { @@ -202,8 +153,8 @@ public class SendsController : Controller { model.ValidateCreation(); var userId = _userService.GetProperUserId(User).Value; - var send = model.ToSend(userId, _sendAuthorizationService); - await _nonAnonymousSendCommand.SaveSendAsync(send); + var send = model.ToSend(userId, _sendService); + await _sendService.SaveSendAsync(send); return new SendResponseModel(send, _globalSettings); } @@ -220,15 +171,15 @@ public class SendsController : Controller throw new BadRequestException("Invalid content. File size hint is required."); } - if (model.FileLength.Value > Constants.FileSize501mb) + if (model.FileLength.Value > SendService.MAX_FILE_SIZE) { - throw new BadRequestException($"Max file size is {SendFileSettingHelper.MAX_FILE_SIZE_READABLE}."); + throw new BadRequestException($"Max file size is {SendService.MAX_FILE_SIZE_READABLE}."); } model.ValidateCreation(); var userId = _userService.GetProperUserId(User).Value; - var (send, data) = model.ToSend(userId, model.File.FileName, _sendAuthorizationService); - var uploadUrl = await _nonAnonymousSendCommand.SaveFileSendAsync(send, data, model.FileLength.Value); + var (send, data) = model.ToSend(userId, model.File.FileName, _sendService); + var uploadUrl = await _sendService.SaveFileSendAsync(send, data, model.FileLength.Value); return new SendFileUploadDataResponseModel { Url = uploadUrl, @@ -275,7 +226,41 @@ public class SendsController : Controller var send = await _sendRepository.GetByIdAsync(new Guid(id)); await Request.GetFileAsync(async (stream) => { - await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send); + await _sendService.UploadFileToExistingSendAsync(stream, send); + }); + } + + [AllowAnonymous] + [HttpPost("file/validate/azure")] + public async Task AzureValidateFile() + { + return await ApiHelpers.HandleAzureEvents(Request, new Dictionary> + { + { + "Microsoft.Storage.BlobCreated", async (eventGridEvent) => + { + try + { + var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1]; + var sendId = AzureSendFileStorageService.SendIdFromBlobName(blobName); + var send = await _sendRepository.GetByIdAsync(new Guid(sendId)); + if (send == null) + { + if (_sendFileStorageService is AzureSendFileStorageService azureSendFileStorageService) + { + await azureSendFileStorageService.DeleteBlobAsync(blobName); + } + return; + } + await _sendService.ValidateSendFile(send); + } + catch (Exception e) + { + _logger.LogError(e, $"Uncaught exception occurred while handling event grid event: {JsonSerializer.Serialize(eventGridEvent)}"); + return; + } + } + } }); } @@ -290,7 +275,7 @@ public class SendsController : Controller throw new NotFoundException(); } - await _nonAnonymousSendCommand.SaveSendAsync(model.ToSend(send, _sendAuthorizationService)); + await _sendService.SaveSendAsync(model.ToSend(send, _sendService)); return new SendResponseModel(send, _globalSettings); } @@ -305,7 +290,7 @@ public class SendsController : Controller } send.Password = null; - await _nonAnonymousSendCommand.SaveSendAsync(send); + await _sendService.SaveSendAsync(send); return new SendResponseModel(send, _globalSettings); } @@ -319,8 +304,6 @@ public class SendsController : Controller throw new NotFoundException(); } - await _nonAnonymousSendCommand.DeleteSendAsync(send); + await _sendService.DeleteSendAsync(send); } - - #endregion } diff --git a/src/Api/Tools/Models/Request/SendRequestModel.cs b/src/Api/Tools/Models/Request/SendRequestModel.cs index 5b3fd7ba31..660ff41e3a 100644 --- a/src/Api/Tools/Models/Request/SendRequestModel.cs +++ b/src/Api/Tools/Models/Request/SendRequestModel.cs @@ -36,31 +36,31 @@ public class SendRequestModel public bool? Disabled { get; set; } public bool? HideEmail { get; set; } - public Send ToSend(Guid userId, ISendAuthorizationService sendAuthorizationService) + public Send ToSend(Guid userId, ISendService sendService) { var send = new Send { Type = Type, UserId = (Guid?)userId }; - ToSend(send, sendAuthorizationService); + ToSend(send, sendService); return send; } - public (Send, SendFileData) ToSend(Guid userId, string fileName, ISendAuthorizationService sendAuthorizationService) + public (Send, SendFileData) ToSend(Guid userId, string fileName, ISendService sendService) { var send = ToSendBase(new Send { Type = Type, UserId = (Guid?)userId - }, sendAuthorizationService); + }, sendService); var data = new SendFileData(Name, Notes, fileName); return (send, data); } - public Send ToSend(Send existingSend, ISendAuthorizationService sendAuthorizationService) + public Send ToSend(Send existingSend, ISendService sendService) { - existingSend = ToSendBase(existingSend, sendAuthorizationService); + existingSend = ToSendBase(existingSend, sendService); switch (existingSend.Type) { case SendType.File: @@ -125,7 +125,7 @@ public class SendRequestModel } } - private Send ToSendBase(Send existingSend, ISendAuthorizationService authorizationService) + private Send ToSendBase(Send existingSend, ISendService sendService) { existingSend.Key = Key; existingSend.ExpirationDate = ExpirationDate; @@ -133,7 +133,7 @@ public class SendRequestModel existingSend.MaxAccessCount = MaxAccessCount; if (!string.IsNullOrWhiteSpace(Password)) { - existingSend.Password = authorizationService.HashPassword(Password); + existingSend.Password = sendService.HashPassword(Password); } existingSend.Disabled = Disabled.GetValueOrDefault(); existingSend.HideEmail = HideEmail.GetValueOrDefault(); diff --git a/src/Core/Tools/Models/Data/SendAccessResult.cs b/src/Core/Tools/Models/Data/SendAccessResult.cs deleted file mode 100644 index 4516f0d9a2..0000000000 --- a/src/Core/Tools/Models/Data/SendAccessResult.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Bit.Core.Tools.Entities; - -namespace Bit.Core.Tools.Models.Data; - -/// -/// This enum represents the possible results when attempting to access a . -/// -/// name="Granted">Access is granted for the . -/// name="PasswordRequired">Access is denied, but a password is required to access the . -/// -/// name="PasswordInvalid">Access is denied due to an invalid password. -/// name="Denied">Access is denied for the . -public enum SendAccessResult -{ - Granted, - PasswordRequired, - PasswordInvalid, - Denied -} diff --git a/src/Core/Tools/SendFeatures/Commands/AnonymousSendCommand.cs b/src/Core/Tools/SendFeatures/Commands/AnonymousSendCommand.cs deleted file mode 100644 index f41c62f409..0000000000 --- a/src/Core/Tools/SendFeatures/Commands/AnonymousSendCommand.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Bit.Core.Exceptions; -using Bit.Core.Platform.Push; -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Data; -using Bit.Core.Tools.Repositories; -using Bit.Core.Tools.SendFeatures.Commands.Interfaces; -using Bit.Core.Tools.Services; - -namespace Bit.Core.Tools.SendFeatures.Commands; - -public class AnonymousSendCommand : IAnonymousSendCommand -{ - private readonly ISendRepository _sendRepository; - private readonly ISendFileStorageService _sendFileStorageService; - private readonly IPushNotificationService _pushNotificationService; - private readonly ISendAuthorizationService _sendAuthorizationService; - - public AnonymousSendCommand( - ISendRepository sendRepository, - ISendFileStorageService sendFileStorageService, - IPushNotificationService pushNotificationService, - ISendAuthorizationService sendAuthorizationService - ) - { - _sendRepository = sendRepository; - _sendFileStorageService = sendFileStorageService; - _pushNotificationService = pushNotificationService; - _sendAuthorizationService = sendAuthorizationService; - } - - // Response: Send, password required, password invalid - public async Task<(string, SendAccessResult)> GetSendFileDownloadUrlAsync(Send send, string fileId, string password) - { - if (send.Type != SendType.File) - { - throw new BadRequestException("Can only get a download URL for a file type of Send"); - } - - var result = _sendAuthorizationService.SendCanBeAccessed(send, password); - - if (!result.Equals(SendAccessResult.Granted)) - { - return (null, result); - } - - send.AccessCount++; - await _sendRepository.ReplaceAsync(send); - await _pushNotificationService.PushSyncSendUpdateAsync(send); - return (await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId), result); - } -} diff --git a/src/Core/Tools/SendFeatures/Commands/Interfaces/IAnonymousSendCommand.cs b/src/Core/Tools/SendFeatures/Commands/Interfaces/IAnonymousSendCommand.cs deleted file mode 100644 index ad23d85170..0000000000 --- a/src/Core/Tools/SendFeatures/Commands/Interfaces/IAnonymousSendCommand.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.Models.Data; - -namespace Bit.Core.Tools.SendFeatures.Commands.Interfaces; - -/// -/// AnonymousSendCommand interface provides methods for managing anonymous Sends. -/// -public interface IAnonymousSendCommand -{ - /// - /// Gets the Send file download URL for a Send object. - /// - /// used to help get file download url and validate file - /// FileId get file download url - /// A hashed and base64-encoded password. This is compared with the send's password to authorize access. - /// Async Task object with Tuple containing the string of download url and - /// to determine if the user can access send. - /// - Task<(string, SendAccessResult)> GetSendFileDownloadUrlAsync(Send send, string fileId, string password); -} diff --git a/src/Core/Tools/SendFeatures/Commands/Interfaces/INonAnonymousSendCommand.cs b/src/Core/Tools/SendFeatures/Commands/Interfaces/INonAnonymousSendCommand.cs deleted file mode 100644 index 58693e619c..0000000000 --- a/src/Core/Tools/SendFeatures/Commands/Interfaces/INonAnonymousSendCommand.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.Models.Data; - -namespace Bit.Core.Tools.SendFeatures.Commands.Interfaces; - -/// -/// NonAnonymousSendCommand interface provides methods for managing non-anonymous Sends. -/// -public interface INonAnonymousSendCommand -{ - /// - /// Saves a to the database. - /// - /// that will save to database - /// Task completes as saves to the database - Task SaveSendAsync(Send send); - - /// - /// Saves the and to the database. - /// - /// that will save to the database - /// that will save to file storage - /// Length of file help with saving to file storage - /// Task object for async operations with file upload url - Task SaveFileSendAsync(Send send, SendFileData data, long fileLength); - - /// - /// Upload a file to an existing . - /// - /// of file to be uploaded. The position - /// will be set to 0 before uploading the file. - /// used to help with uploading file - /// Task completes after saving and metadata to the file storage - Task UploadFileToExistingSendAsync(Stream stream, Send send); - - /// - /// Deletes a from the database and file storage. - /// - /// is used to delete from database and file storage - /// Task completes once has been deleted from database and file storage. - Task DeleteSendAsync(Send send); - - /// - /// Stores the confirmed file size of a send; when the file size cannot be confirmed, the send is deleted. - /// - /// The this command acts upon - /// when the file is confirmed, otherwise - /// - /// When a file size cannot be confirmed, we assume we're working with a rogue client. The send is deleted out of - /// an abundance of caution. - /// - Task ConfirmFileSize(Send send); -} diff --git a/src/Core/Tools/SendFeatures/Commands/NonAnonymousSendCommand.cs b/src/Core/Tools/SendFeatures/Commands/NonAnonymousSendCommand.cs deleted file mode 100644 index 87b4e581ca..0000000000 --- a/src/Core/Tools/SendFeatures/Commands/NonAnonymousSendCommand.cs +++ /dev/null @@ -1,160 +0,0 @@ -using System.Text.Json; -using Bit.Core.Exceptions; -using Bit.Core.Platform.Push; -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Data; -using Bit.Core.Tools.Repositories; -using Bit.Core.Tools.SendFeatures.Commands.Interfaces; -using Bit.Core.Tools.Services; -using Bit.Core.Utilities; - -namespace Bit.Core.Tools.SendFeatures.Commands; - -public class NonAnonymousSendCommand : INonAnonymousSendCommand -{ - private readonly ISendRepository _sendRepository; - private readonly ISendFileStorageService _sendFileStorageService; - private readonly IPushNotificationService _pushNotificationService; - private readonly ISendValidationService _sendValidationService; - private readonly ISendCoreHelperService _sendCoreHelperService; - - public NonAnonymousSendCommand(ISendRepository sendRepository, - ISendFileStorageService sendFileStorageService, - IPushNotificationService pushNotificationService, - ISendAuthorizationService sendAuthorizationService, - ISendValidationService sendValidationService, - ISendCoreHelperService sendCoreHelperService) - { - _sendRepository = sendRepository; - _sendFileStorageService = sendFileStorageService; - _pushNotificationService = pushNotificationService; - _sendValidationService = sendValidationService; - _sendCoreHelperService = sendCoreHelperService; - } - - public async Task SaveSendAsync(Send send) - { - // Make sure user can save Sends - await _sendValidationService.ValidateUserCanSaveAsync(send.UserId, send); - - if (send.Id == default(Guid)) - { - await _sendRepository.CreateAsync(send); - await _pushNotificationService.PushSyncSendCreateAsync(send); - } - else - { - send.RevisionDate = DateTime.UtcNow; - await _sendRepository.UpsertAsync(send); - await _pushNotificationService.PushSyncSendUpdateAsync(send); - } - } - - public async Task SaveFileSendAsync(Send send, SendFileData data, long fileLength) - { - if (send.Type != SendType.File) - { - throw new BadRequestException("Send is not of type \"file\"."); - } - - if (fileLength < 1) - { - throw new BadRequestException("No file data."); - } - - var storageBytesRemaining = await _sendValidationService.StorageRemainingForSendAsync(send); - - if (storageBytesRemaining < fileLength) - { - throw new BadRequestException("Not enough storage available."); - } - - var fileId = _sendCoreHelperService.SecureRandomString(32, useUpperCase: false, useSpecial: false); - - try - { - data.Id = fileId; - data.Size = fileLength; - data.Validated = false; - send.Data = JsonSerializer.Serialize(data, - JsonHelpers.IgnoreWritingNull); - await SaveSendAsync(send); - return await _sendFileStorageService.GetSendFileUploadUrlAsync(send, fileId); - } - catch - { - // Clean up since this is not transactional - await _sendFileStorageService.DeleteFileAsync(send, fileId); - throw; - } - } - public async Task UploadFileToExistingSendAsync(Stream stream, Send send) - { - if (stream.Position > 0) - { - stream.Position = 0; - } - - if (send?.Data == null) - { - throw new BadRequestException("Send does not have file data"); - } - - if (send.Type != SendType.File) - { - throw new BadRequestException("Not a File Type Send."); - } - - var data = JsonSerializer.Deserialize(send.Data); - - if (data.Validated) - { - throw new BadRequestException("File has already been uploaded."); - } - - await _sendFileStorageService.UploadNewFileAsync(stream, send, data.Id); - - if (!await ConfirmFileSize(send)) - { - throw new BadRequestException("File received does not match expected file length."); - } - } - public async Task DeleteSendAsync(Send send) - { - await _sendRepository.DeleteAsync(send); - if (send.Type == Enums.SendType.File) - { - var data = JsonSerializer.Deserialize(send.Data); - await _sendFileStorageService.DeleteFileAsync(send, data.Id); - } - await _pushNotificationService.PushSyncSendDeleteAsync(send); - } - - public async Task ConfirmFileSize(Send send) - { - var fileData = JsonSerializer.Deserialize(send.Data); - - var (valid, realSize) = await _sendFileStorageService.ValidateFileAsync(send, fileData.Id, fileData.Size, SendFileSettingHelper.FILE_SIZE_LEEWAY); - - if (!valid || realSize > SendFileSettingHelper.FILE_SIZE_LEEWAY) - { - // File reported differs in size from that promised. Must be a rogue client. Delete Send - await DeleteSendAsync(send); - return false; - } - - // Update Send data if necessary - if (realSize != fileData.Size) - { - fileData.Size = realSize.Value; - } - fileData.Validated = true; - send.Data = JsonSerializer.Serialize(fileData, - JsonHelpers.IgnoreWritingNull); - await SaveSendAsync(send); - - return valid; - } - -} diff --git a/src/Core/Tools/SendFeatures/SendServiceCollectionExtension.cs b/src/Core/Tools/SendFeatures/SendServiceCollectionExtension.cs deleted file mode 100644 index 3dca1cb482..0000000000 --- a/src/Core/Tools/SendFeatures/SendServiceCollectionExtension.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Bit.Core.Tools.SendFeatures.Commands; -using Bit.Core.Tools.SendFeatures.Commands.Interfaces; -using Bit.Core.Tools.SendFeatures.Queries; -using Bit.Core.Tools.SendFeatures.Queries.Interfaces; -using Bit.Core.Tools.Services; -using Microsoft.Extensions.DependencyInjection; - -namespace Bit.Core.Tools.SendFeatures; - -public static class SendServiceCollectionExtension -{ - public static void AddSendServices(this IServiceCollection services) - { - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - } -} diff --git a/src/Core/Tools/SendFeatures/Services/Interfaces/ISendAuthorizationService.cs b/src/Core/Tools/SendFeatures/Services/Interfaces/ISendAuthorizationService.cs deleted file mode 100644 index 9acf987ac5..0000000000 --- a/src/Core/Tools/SendFeatures/Services/Interfaces/ISendAuthorizationService.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.Models.Data; - -namespace Bit.Core.Tools.Services; - -/// -/// Send Authorization service is responsible for checking if a Send can be accessed. -/// -public interface ISendAuthorizationService -{ - /// - /// Checks if a can be accessed while updating the , pushing a notification, and sending a reference event. - /// - /// used to determine access - /// A hashed and base64-encoded password. This is compared with the send's password to authorize access. - /// will be returned to determine if the user can access send. - /// - Task AccessAsync(Send send, string password); - SendAccessResult SendCanBeAccessed(Send send, - string password); - - /// - /// Hashes the password using the password hasher. - /// - /// Password to be hashed - /// Hashed password of the password given - string HashPassword(string password); -} diff --git a/src/Core/Tools/SendFeatures/Services/Interfaces/ISendCoreHelperService.cs b/src/Core/Tools/SendFeatures/Services/Interfaces/ISendCoreHelperService.cs deleted file mode 100644 index a09d7c3c60..0000000000 --- a/src/Core/Tools/SendFeatures/Services/Interfaces/ISendCoreHelperService.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Bit.Core.Tools.Services; - -/// -/// This interface provides helper methods for generating secure random strings. Making -/// it easier to mock the service in unit tests. -/// -public interface ISendCoreHelperService -{ - /// - /// Securely generates a random string of the specified length. - /// - /// Desired string length to be returned - /// Desired casing for the string - /// Determines if special characters will be used in string - /// A secure random string with the desired parameters - string SecureRandomString(int length, bool useUpperCase, bool useSpecial); -} diff --git a/src/Core/Tools/SendFeatures/Services/Interfaces/ISendStorageService.cs b/src/Core/Tools/SendFeatures/Services/Interfaces/ISendStorageService.cs deleted file mode 100644 index 29bc0c6a6a..0000000000 --- a/src/Core/Tools/SendFeatures/Services/Interfaces/ISendStorageService.cs +++ /dev/null @@ -1,71 +0,0 @@ -using Bit.Core.Enums; -using Bit.Core.Tools.Entities; - -namespace Bit.Core.Tools.Services; - -/// -/// Send File Storage Service is responsible for uploading, deleting, and validating files -/// whether they are in local storage or in cloud storage. -/// -public interface ISendFileStorageService -{ - FileUploadType FileUploadType { get; } - /// - /// Uploads a new file to the storage. - /// - /// of the file - /// for the file - /// File id - /// Task completes once and have been saved to the database - Task UploadNewFileAsync(Stream stream, Send send, string fileId); - /// - /// Deletes a file from the storage. - /// - /// used to delete file - /// File id of file to be deleted - /// Task completes once has been deleted to the database - Task DeleteFileAsync(Send send, string fileId); - /// - /// Deletes all files for a specific organization. - /// - /// used to delete all files pertaining to organization - /// Task completes after running code to delete files by organization id - Task DeleteFilesForOrganizationAsync(Guid organizationId); - /// - /// Deletes all files for a specific user. - /// - /// used to delete all files pertaining to user - /// Task completes after running code to delete files by user id - Task DeleteFilesForUserAsync(Guid userId); - /// - /// Gets the download URL for a file. - /// - /// used to help get download url for file - /// File id to help get download url for file - /// Download url as a string - Task GetSendFileDownloadUrlAsync(Send send, string fileId); - /// - /// Gets the upload URL for a file. - /// - /// used to help get upload url for file - /// File id to help get upload url for file - /// File upload url as string - Task GetSendFileUploadUrlAsync(Send send, string fileId); - /// - /// Validates the file size of a file in the storage. - /// - /// used to help validate file - /// File id to identify which file to validate - /// Expected file size of the file - /// - /// Send file size tolerance in bytes. When an uploaded file's `expectedFileSize` - /// is outside of the leeway, the storage operation fails. - /// - /// - /// ❌ Fill this in with an explanation of the error thrown when `leeway` is incorrect - /// - /// Task object for async operations with Tuple of boolean that determines if file was valid and long that - /// the actual file size of the file. - /// - Task<(bool, long?)> ValidateFileAsync(Send send, string fileId, long expectedFileSize, long leeway); -} diff --git a/src/Core/Tools/SendFeatures/Services/Interfaces/ISendValidationService.cs b/src/Core/Tools/SendFeatures/Services/Interfaces/ISendValidationService.cs deleted file mode 100644 index 24d31c5cfe..0000000000 --- a/src/Core/Tools/SendFeatures/Services/Interfaces/ISendValidationService.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Bit.Core.Tools.Entities; - -namespace Bit.Core.Tools.Services; - -public interface ISendValidationService -{ - /// - /// Validates a file can be saved by specified user. - /// - /// needed to validate file for specific user - /// needed to help validate file - /// Task completes when a conditional statement has been met it will return out of the method or - /// throw a BadRequestException. - /// - Task ValidateUserCanSaveAsync(Guid? userId, Send send); - - /// - /// Validates a file can be saved by specified user with different policy based on feature flag - /// - /// needed to validate file for specific user - /// needed to help validate file - /// Task completes when a conditional statement has been met it will return out of the method or - /// throw a BadRequestException. - /// - Task ValidateUserCanSaveAsync_vNext(Guid? userId, Send send); - - /// - /// Calculates the remaining storage for a Send. - /// - /// needed to help calculate remaining storage - /// Long with the remaining bytes for storage or will throw a BadRequestException if user cannot access - /// file or email is not verified. - /// - Task StorageRemainingForSendAsync(Send send); -} diff --git a/src/Core/Tools/SendFeatures/Services/SendAuthorizationService.cs b/src/Core/Tools/SendFeatures/Services/SendAuthorizationService.cs deleted file mode 100644 index cf9f2636c0..0000000000 --- a/src/Core/Tools/SendFeatures/Services/SendAuthorizationService.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Bit.Core.Entities; -using Bit.Core.Platform.Push; -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Data; -using Bit.Core.Tools.Repositories; -using Microsoft.AspNetCore.Identity; - -namespace Bit.Core.Tools.Services; - -public class SendAuthorizationService : ISendAuthorizationService -{ - private readonly ISendRepository _sendRepository; - private readonly IPasswordHasher _passwordHasher; - private readonly IPushNotificationService _pushNotificationService; - - public SendAuthorizationService( - ISendRepository sendRepository, - IPasswordHasher passwordHasher, - IPushNotificationService pushNotificationService) - { - _sendRepository = sendRepository; - _passwordHasher = passwordHasher; - _pushNotificationService = pushNotificationService; - } - - public SendAccessResult SendCanBeAccessed(Send send, - string password) - { - var now = DateTime.UtcNow; - if (send == null || send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount || - send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < now || send.Disabled || - send.DeletionDate < now) - { - return SendAccessResult.Denied; - } - if (!string.IsNullOrWhiteSpace(send.Password)) - { - if (string.IsNullOrWhiteSpace(password)) - { - return SendAccessResult.PasswordRequired; - } - var passwordResult = _passwordHasher.VerifyHashedPassword(new User(), send.Password, password); - if (passwordResult == PasswordVerificationResult.SuccessRehashNeeded) - { - send.Password = HashPassword(password); - } - if (passwordResult == PasswordVerificationResult.Failed) - { - return SendAccessResult.PasswordInvalid; - } - } - - return SendAccessResult.Granted; - } - - public async Task AccessAsync(Send sendToBeAccessed, string password) - { - var accessResult = SendCanBeAccessed(sendToBeAccessed, password); - - if (!accessResult.Equals(SendAccessResult.Granted)) - { - return accessResult; - } - - if (sendToBeAccessed.Type != SendType.File) - { - // File sends are incremented during file download - sendToBeAccessed.AccessCount++; - } - - await _sendRepository.ReplaceAsync(sendToBeAccessed); - await _pushNotificationService.PushSyncSendUpdateAsync(sendToBeAccessed); - return accessResult; - } - - public string HashPassword(string password) - { - return _passwordHasher.HashPassword(new User(), password); - } -} diff --git a/src/Core/Tools/SendFeatures/Services/SendCoreHelperService.cs b/src/Core/Tools/SendFeatures/Services/SendCoreHelperService.cs deleted file mode 100644 index 122759f8f0..0000000000 --- a/src/Core/Tools/SendFeatures/Services/SendCoreHelperService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Bit.Core.Utilities; - -namespace Bit.Core.Tools.Services; - -public class SendCoreHelperService : ISendCoreHelperService -{ - public string SecureRandomString(int length, bool useUpperCase, bool useSpecial) - { - return CoreHelpers.SecureRandomString(length, upper: useUpperCase, special: useSpecial); - } - -} diff --git a/src/Core/Tools/SendFeatures/Services/SendFileSettingHelper.cs b/src/Core/Tools/SendFeatures/Services/SendFileSettingHelper.cs deleted file mode 100644 index ef3f210ff8..0000000000 --- a/src/Core/Tools/SendFeatures/Services/SendFileSettingHelper.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Bit.Core.Tools.Entities; - -namespace Bit.Core.Tools.SendFeatures; - -/// -/// SendFileSettingHelper is a static class that provides constants and helper methods (if needed) for managing file -/// settings. -/// -public static class SendFileSettingHelper -{ - /// - /// The leeway for the file size. This is the calculated 1 megabyte of cushion when doing comparisons of file sizes - /// within the system. - /// - public const long FILE_SIZE_LEEWAY = 1024L * 1024L; // 1MB - /// - /// The maximum file size for a file uploaded in a . Units are calculated in bytes but - /// represent 501 megabytes. 1 megabyte is added for cushion to account for file size. - /// - public const long MAX_FILE_SIZE = Constants.FileSize501mb; - - /// - /// String of the expected file size and to be used when needing to communicate the file size to the client/user. - /// - public const string MAX_FILE_SIZE_READABLE = "500 MB"; -} diff --git a/src/Core/Tools/SendFeatures/Services/SendValidationService.cs b/src/Core/Tools/SendFeatures/Services/SendValidationService.cs deleted file mode 100644 index f1e8855def..0000000000 --- a/src/Core/Tools/SendFeatures/Services/SendValidationService.cs +++ /dev/null @@ -1,142 +0,0 @@ -using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; -using Bit.Core.AdminConsole.Services; -using Bit.Core.Context; -using Bit.Core.Exceptions; -using Bit.Core.Repositories; -using Bit.Core.Services; -using Bit.Core.Settings; -using Bit.Core.Tools.Entities; -using Bit.Core.Utilities; - -namespace Bit.Core.Tools.Services; - -public class SendValidationService : ISendValidationService -{ - - private readonly IUserRepository _userRepository; - private readonly IOrganizationRepository _organizationRepository; - private readonly IPolicyService _policyService; - private readonly IFeatureService _featureService; - private readonly IUserService _userService; - private readonly GlobalSettings _globalSettings; - private readonly ICurrentContext _currentContext; - private readonly IPolicyRequirementQuery _policyRequirementQuery; - - - - public SendValidationService( - IUserRepository userRepository, - IOrganizationRepository organizationRepository, - IPolicyService policyService, - IFeatureService featureService, - IUserService userService, - IPolicyRequirementQuery policyRequirementQuery, - GlobalSettings globalSettings, - - ICurrentContext currentContext) - { - _userRepository = userRepository; - _organizationRepository = organizationRepository; - _policyService = policyService; - _featureService = featureService; - _userService = userService; - _policyRequirementQuery = policyRequirementQuery; - _globalSettings = globalSettings; - _currentContext = currentContext; - } - - public async Task ValidateUserCanSaveAsync(Guid? userId, Send send) - { - if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) - { - await ValidateUserCanSaveAsync_vNext(userId, send); - return; - } - - if (!userId.HasValue || (!_currentContext.Organizations?.Any() ?? true)) - { - return; - } - - var anyDisableSendPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(userId.Value, - PolicyType.DisableSend); - if (anyDisableSendPolicies) - { - throw new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send."); - } - - if (send.HideEmail.GetValueOrDefault()) - { - var sendOptionsPolicies = await _policyService.GetPoliciesApplicableToUserAsync(userId.Value, PolicyType.SendOptions); - if (sendOptionsPolicies.Any(p => CoreHelpers.LoadClassFromJsonData(p.PolicyData)?.DisableHideEmail ?? false)) - { - throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send."); - } - } - } - - public async Task ValidateUserCanSaveAsync_vNext(Guid? userId, Send send) - { - if (!userId.HasValue) - { - return; - } - - var disableSendRequirement = await _policyRequirementQuery.GetAsync(userId.Value); - if (disableSendRequirement.DisableSend) - { - throw new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send."); - } - - var sendOptionsRequirement = await _policyRequirementQuery.GetAsync(userId.Value); - if (sendOptionsRequirement.DisableHideEmail && send.HideEmail.GetValueOrDefault()) - { - throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send."); - } - } - - public async Task StorageRemainingForSendAsync(Send send) - { - var storageBytesRemaining = 0L; - if (send.UserId.HasValue) - { - var user = await _userRepository.GetByIdAsync(send.UserId.Value); - if (!await _userService.CanAccessPremium(user)) - { - throw new BadRequestException("You must have premium status to use file Sends."); - } - - if (!user.EmailVerified) - { - throw new BadRequestException("You must confirm your email to use file Sends."); - } - - if (user.Premium) - { - storageBytesRemaining = user.StorageBytesRemaining(); - } - else - { - // Users that get access to file storage/premium from their organization get the default - // 1 GB max storage. - short limit = _globalSettings.SelfHosted ? (short)10240 : (short)1; - storageBytesRemaining = user.StorageBytesRemaining(limit); - } - } - else if (send.OrganizationId.HasValue) - { - var org = await _organizationRepository.GetByIdAsync(send.OrganizationId.Value); - if (!org.MaxStorageGb.HasValue) - { - throw new BadRequestException("This organization cannot use file sends."); - } - - storageBytesRemaining = org.StorageBytesRemaining(); - } - - return storageBytesRemaining; - } -} diff --git a/src/Core/Tools/Services/ISendService.cs b/src/Core/Tools/Services/ISendService.cs new file mode 100644 index 0000000000..2c20851ce8 --- /dev/null +++ b/src/Core/Tools/Services/ISendService.cs @@ -0,0 +1,16 @@ +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Models.Data; + +namespace Bit.Core.Tools.Services; + +public interface ISendService +{ + Task DeleteSendAsync(Send send); + Task SaveSendAsync(Send send); + Task SaveFileSendAsync(Send send, SendFileData data, long fileLength); + Task UploadFileToExistingSendAsync(Stream stream, Send send); + Task<(Send, bool, bool)> AccessAsync(Guid sendId, string password); + string HashPassword(string password); + Task<(string, bool, bool)> GetSendFileDownloadUrlAsync(Send send, string fileId, string password); + Task ValidateSendFile(Send send); +} diff --git a/src/Core/Tools/Services/ISendStorageService.cs b/src/Core/Tools/Services/ISendStorageService.cs new file mode 100644 index 0000000000..4bf2aa3892 --- /dev/null +++ b/src/Core/Tools/Services/ISendStorageService.cs @@ -0,0 +1,16 @@ +using Bit.Core.Enums; +using Bit.Core.Tools.Entities; + +namespace Bit.Core.Tools.Services; + +public interface ISendFileStorageService +{ + FileUploadType FileUploadType { get; } + Task UploadNewFileAsync(Stream stream, Send send, string fileId); + Task DeleteFileAsync(Send send, string fileId); + Task DeleteFilesForOrganizationAsync(Guid organizationId); + Task DeleteFilesForUserAsync(Guid userId); + Task GetSendFileDownloadUrlAsync(Send send, string fileId); + Task GetSendFileUploadUrlAsync(Send send, string fileId); + Task<(bool, long?)> ValidateFileAsync(Send send, string fileId, long expectedFileSize, long leeway); +} diff --git a/src/Core/Tools/SendFeatures/Services/AzureSendFileStorageService.cs b/src/Core/Tools/Services/Implementations/AzureSendFileStorageService.cs similarity index 100% rename from src/Core/Tools/SendFeatures/Services/AzureSendFileStorageService.cs rename to src/Core/Tools/Services/Implementations/AzureSendFileStorageService.cs diff --git a/src/Core/Tools/SendFeatures/Services/LocalSendStorageService.cs b/src/Core/Tools/Services/Implementations/LocalSendStorageService.cs similarity index 100% rename from src/Core/Tools/SendFeatures/Services/LocalSendStorageService.cs rename to src/Core/Tools/Services/Implementations/LocalSendStorageService.cs diff --git a/src/Core/Tools/Services/Implementations/SendService.cs b/src/Core/Tools/Services/Implementations/SendService.cs new file mode 100644 index 0000000000..252a23db86 --- /dev/null +++ b/src/Core/Tools/Services/Implementations/SendService.cs @@ -0,0 +1,361 @@ +using System.Text.Json; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.Repositories; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.Tools.Services; + +public class SendService : ISendService +{ + public const long MAX_FILE_SIZE = Constants.FileSize501mb; + public const string MAX_FILE_SIZE_READABLE = "500 MB"; + private readonly ISendRepository _sendRepository; + private readonly IUserRepository _userRepository; + private readonly IPolicyService _policyService; + private readonly IUserService _userService; + private readonly IOrganizationRepository _organizationRepository; + private readonly ISendFileStorageService _sendFileStorageService; + private readonly IPasswordHasher _passwordHasher; + private readonly IPushNotificationService _pushService; + private readonly GlobalSettings _globalSettings; + private readonly ICurrentContext _currentContext; + private readonly IPolicyRequirementQuery _policyRequirementQuery; + private readonly IFeatureService _featureService; + + private const long _fileSizeLeeway = 1024L * 1024L; // 1MB + + public SendService( + ISendRepository sendRepository, + IUserRepository userRepository, + IUserService userService, + IOrganizationRepository organizationRepository, + ISendFileStorageService sendFileStorageService, + IPasswordHasher passwordHasher, + IPushNotificationService pushService, + GlobalSettings globalSettings, + IPolicyService policyService, + ICurrentContext currentContext, + IPolicyRequirementQuery policyRequirementQuery, + IFeatureService featureService) + { + _sendRepository = sendRepository; + _userRepository = userRepository; + _userService = userService; + _policyService = policyService; + _organizationRepository = organizationRepository; + _sendFileStorageService = sendFileStorageService; + _passwordHasher = passwordHasher; + _pushService = pushService; + _globalSettings = globalSettings; + _currentContext = currentContext; + _policyRequirementQuery = policyRequirementQuery; + _featureService = featureService; + } + + public async Task SaveSendAsync(Send send) + { + // Make sure user can save Sends + await ValidateUserCanSaveAsync(send.UserId, send); + + if (send.Id == default(Guid)) + { + await _sendRepository.CreateAsync(send); + await _pushService.PushSyncSendCreateAsync(send); + } + else + { + send.RevisionDate = DateTime.UtcNow; + await _sendRepository.UpsertAsync(send); + await _pushService.PushSyncSendUpdateAsync(send); + } + } + + public async Task SaveFileSendAsync(Send send, SendFileData data, long fileLength) + { + if (send.Type != SendType.File) + { + throw new BadRequestException("Send is not of type \"file\"."); + } + + if (fileLength < 1) + { + throw new BadRequestException("No file data."); + } + + var storageBytesRemaining = await StorageRemainingForSendAsync(send); + + if (storageBytesRemaining < fileLength) + { + throw new BadRequestException("Not enough storage available."); + } + + var fileId = Utilities.CoreHelpers.SecureRandomString(32, upper: false, special: false); + + try + { + data.Id = fileId; + data.Size = fileLength; + data.Validated = false; + send.Data = JsonSerializer.Serialize(data, + JsonHelpers.IgnoreWritingNull); + await SaveSendAsync(send); + return await _sendFileStorageService.GetSendFileUploadUrlAsync(send, fileId); + } + catch + { + // Clean up since this is not transactional + await _sendFileStorageService.DeleteFileAsync(send, fileId); + throw; + } + } + + public async Task UploadFileToExistingSendAsync(Stream stream, Send send) + { + if (send?.Data == null) + { + throw new BadRequestException("Send does not have file data"); + } + + if (send.Type != SendType.File) + { + throw new BadRequestException("Not a File Type Send."); + } + + var data = JsonSerializer.Deserialize(send.Data); + + if (data.Validated) + { + throw new BadRequestException("File has already been uploaded."); + } + + await _sendFileStorageService.UploadNewFileAsync(stream, send, data.Id); + + if (!await ValidateSendFile(send)) + { + throw new BadRequestException("File received does not match expected file length."); + } + } + + public async Task ValidateSendFile(Send send) + { + var fileData = JsonSerializer.Deserialize(send.Data); + + var (valid, realSize) = await _sendFileStorageService.ValidateFileAsync(send, fileData.Id, fileData.Size, _fileSizeLeeway); + + if (!valid || realSize > MAX_FILE_SIZE) + { + // File reported differs in size from that promised. Must be a rogue client. Delete Send + await DeleteSendAsync(send); + return false; + } + + // Update Send data if necessary + if (realSize != fileData.Size) + { + fileData.Size = realSize.Value; + } + fileData.Validated = true; + send.Data = JsonSerializer.Serialize(fileData, + JsonHelpers.IgnoreWritingNull); + await SaveSendAsync(send); + + return valid; + } + + public async Task DeleteSendAsync(Send send) + { + await _sendRepository.DeleteAsync(send); + if (send.Type == Enums.SendType.File) + { + var data = JsonSerializer.Deserialize(send.Data); + await _sendFileStorageService.DeleteFileAsync(send, data.Id); + } + await _pushService.PushSyncSendDeleteAsync(send); + } + + public (bool grant, bool passwordRequiredError, bool passwordInvalidError) SendCanBeAccessed(Send send, + string password) + { + var now = DateTime.UtcNow; + if (send == null || send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount || + send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < now || send.Disabled || + send.DeletionDate < now) + { + return (false, false, false); + } + if (!string.IsNullOrWhiteSpace(send.Password)) + { + if (string.IsNullOrWhiteSpace(password)) + { + return (false, true, false); + } + var passwordResult = _passwordHasher.VerifyHashedPassword(new User(), send.Password, password); + if (passwordResult == PasswordVerificationResult.SuccessRehashNeeded) + { + send.Password = HashPassword(password); + } + if (passwordResult == PasswordVerificationResult.Failed) + { + return (false, false, true); + } + } + + return (true, false, false); + } + + // Response: Send, password required, password invalid + public async Task<(string, bool, bool)> GetSendFileDownloadUrlAsync(Send send, string fileId, string password) + { + if (send.Type != SendType.File) + { + throw new BadRequestException("Can only get a download URL for a file type of Send"); + } + + var (grantAccess, passwordRequired, passwordInvalid) = SendCanBeAccessed(send, password); + + if (!grantAccess) + { + return (null, passwordRequired, passwordInvalid); + } + + send.AccessCount++; + await _sendRepository.ReplaceAsync(send); + await _pushService.PushSyncSendUpdateAsync(send); + return (await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId), false, false); + } + + // Response: Send, password required, password invalid + public async Task<(Send, bool, bool)> AccessAsync(Guid sendId, string password) + { + var send = await _sendRepository.GetByIdAsync(sendId); + var (grantAccess, passwordRequired, passwordInvalid) = SendCanBeAccessed(send, password); + + if (!grantAccess) + { + return (null, passwordRequired, passwordInvalid); + } + + // TODO: maybe move this to a simple ++ sproc? + if (send.Type != SendType.File) + { + // File sends are incremented during file download + send.AccessCount++; + } + + await _sendRepository.ReplaceAsync(send); + await _pushService.PushSyncSendUpdateAsync(send); + return (send, false, false); + } + + public string HashPassword(string password) + { + return _passwordHasher.HashPassword(new User(), password); + } + + private async Task ValidateUserCanSaveAsync(Guid? userId, Send send) + { + if (_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)) + { + await ValidateUserCanSaveAsync_vNext(userId, send); + return; + } + + if (!userId.HasValue || (!_currentContext.Organizations?.Any() ?? true)) + { + return; + } + + var anyDisableSendPolicies = await _policyService.AnyPoliciesApplicableToUserAsync(userId.Value, + PolicyType.DisableSend); + if (anyDisableSendPolicies) + { + throw new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send."); + } + + if (send.HideEmail.GetValueOrDefault()) + { + var sendOptionsPolicies = await _policyService.GetPoliciesApplicableToUserAsync(userId.Value, PolicyType.SendOptions); + if (sendOptionsPolicies.Any(p => CoreHelpers.LoadClassFromJsonData(p.PolicyData)?.DisableHideEmail ?? false)) + { + throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send."); + } + } + } + + private async Task ValidateUserCanSaveAsync_vNext(Guid? userId, Send send) + { + if (!userId.HasValue) + { + return; + } + + var disableSendRequirement = await _policyRequirementQuery.GetAsync(userId.Value); + if (disableSendRequirement.DisableSend) + { + throw new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send."); + } + + var sendOptionsRequirement = await _policyRequirementQuery.GetAsync(userId.Value); + if (sendOptionsRequirement.DisableHideEmail && send.HideEmail.GetValueOrDefault()) + { + throw new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send."); + } + } + + private async Task StorageRemainingForSendAsync(Send send) + { + var storageBytesRemaining = 0L; + if (send.UserId.HasValue) + { + var user = await _userRepository.GetByIdAsync(send.UserId.Value); + if (!await _userService.CanAccessPremium(user)) + { + throw new BadRequestException("You must have premium status to use file Sends."); + } + + if (!user.EmailVerified) + { + throw new BadRequestException("You must confirm your email to use file Sends."); + } + + if (user.Premium) + { + storageBytesRemaining = user.StorageBytesRemaining(); + } + else + { + // Users that get access to file storage/premium from their organization get the default + // 1 GB max storage. + storageBytesRemaining = user.StorageBytesRemaining( + _globalSettings.SelfHosted ? (short)10240 : (short)1); + } + } + else if (send.OrganizationId.HasValue) + { + var org = await _organizationRepository.GetByIdAsync(send.OrganizationId.Value); + if (!org.MaxStorageGb.HasValue) + { + throw new BadRequestException("This organization cannot use file sends."); + } + + storageBytesRemaining = org.StorageBytesRemaining(); + } + + return storageBytesRemaining; + } +} diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 7ee94d0dce..2e0fa7910b 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -44,7 +44,6 @@ using Bit.Core.Settings; using Bit.Core.Tokens; using Bit.Core.Tools.ImportFeatures; using Bit.Core.Tools.ReportFeatures; -using Bit.Core.Tools.SendFeatures; using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Bit.Core.Vault; @@ -125,7 +124,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddLoginServices(); services.AddScoped(); services.AddVaultServices(); @@ -134,7 +133,6 @@ public static class ServiceCollectionExtensions services.AddNotificationCenterServices(); services.AddPlatformServices(); services.AddImportServices(); - services.AddSendServices(); } public static void AddTokenizers(this IServiceCollection services) diff --git a/test/Api.Test/KeyManagement/Validators/SendRotationValidatorTests.cs b/test/Api.Test/KeyManagement/Validators/SendRotationValidatorTests.cs index 7bab587cf0..842343ba33 100644 --- a/test/Api.Test/KeyManagement/Validators/SendRotationValidatorTests.cs +++ b/test/Api.Test/KeyManagement/Validators/SendRotationValidatorTests.cs @@ -23,11 +23,11 @@ public class SendRotationValidatorTests public async Task ValidateAsync_Success() { // Arrange - var sendAuthorizationService = Substitute.For(); + var sendService = Substitute.For(); var sendRepository = Substitute.For(); var sut = new SendRotationValidator( - sendAuthorizationService, + sendService, sendRepository ); @@ -52,11 +52,11 @@ public class SendRotationValidatorTests public async Task ValidateAsync_SendNotReturnedFromRepository_NotIncludedInOutput() { // Arrange - var sendAuthorizationService = Substitute.For(); + var sendService = Substitute.For(); var sendRepository = Substitute.For(); var sut = new SendRotationValidator( - sendAuthorizationService, + sendService, sendRepository ); @@ -76,11 +76,11 @@ public class SendRotationValidatorTests public async Task ValidateAsync_InputMissingUserSend_Throws() { // Arrange - var sendAuthorizationService = Substitute.For(); + var sendService = Substitute.For(); var sendRepository = Substitute.For(); var sut = new SendRotationValidator( - sendAuthorizationService, + sendService, sendRepository ); diff --git a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs index 7210bddebb..4db165b657 100644 --- a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs +++ b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs @@ -9,9 +9,7 @@ using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tools.Entities; using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.Repositories; -using Bit.Core.Tools.SendFeatures.Commands.Interfaces; using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Mvc; @@ -27,9 +25,7 @@ public class SendsControllerTests : IDisposable private readonly GlobalSettings _globalSettings; private readonly IUserService _userService; private readonly ISendRepository _sendRepository; - private readonly INonAnonymousSendCommand _nonAnonymousSendCommand; - private readonly IAnonymousSendCommand _anonymousSendCommand; - private readonly ISendAuthorizationService _sendAuthorizationService; + private readonly ISendService _sendService; private readonly ISendFileStorageService _sendFileStorageService; private readonly ILogger _logger; @@ -37,9 +33,7 @@ public class SendsControllerTests : IDisposable { _userService = Substitute.For(); _sendRepository = Substitute.For(); - _nonAnonymousSendCommand = Substitute.For(); - _anonymousSendCommand = Substitute.For(); - _sendAuthorizationService = Substitute.For(); + _sendService = Substitute.For(); _sendFileStorageService = Substitute.For(); _globalSettings = new GlobalSettings(); _logger = Substitute.For>(); @@ -47,9 +41,7 @@ public class SendsControllerTests : IDisposable _sut = new SendsController( _sendRepository, _userService, - _sendAuthorizationService, - _anonymousSendCommand, - _nonAnonymousSendCommand, + _sendService, _sendFileStorageService, _logger, _globalSettings @@ -72,8 +64,7 @@ public class SendsControllerTests : IDisposable send.Data = JsonSerializer.Serialize(new Dictionary()); send.HideEmail = true; - _sendRepository.GetByIdAsync(Arg.Any()).Returns(send); - _sendAuthorizationService.AccessAsync(send, null).Returns(SendAccessResult.Granted); + _sendService.AccessAsync(id, null).Returns((send, false, false)); _userService.GetUserByIdAsync(Arg.Any()).Returns(user); var request = new SendAccessRequestModel(); diff --git a/test/Api.Test/Tools/Models/Request/SendRequestModelTests.cs b/test/Api.Test/Tools/Models/Request/SendRequestModelTests.cs index 8049667011..59fb35d32e 100644 --- a/test/Api.Test/Tools/Models/Request/SendRequestModelTests.cs +++ b/test/Api.Test/Tools/Models/Request/SendRequestModelTests.cs @@ -34,11 +34,11 @@ public class SendRequestModelTests Type = SendType.Text, }; - var sendAuthorizationService = Substitute.For(); - sendAuthorizationService.HashPassword(Arg.Any()) + var sendService = Substitute.For(); + sendService.HashPassword(Arg.Any()) .Returns((info) => $"hashed_{(string)info[0]}"); - var send = sendRequest.ToSend(Guid.NewGuid(), sendAuthorizationService); + var send = sendRequest.ToSend(Guid.NewGuid(), sendService); Assert.Equal(deletionDate, send.DeletionDate); Assert.False(send.Disabled); diff --git a/test/Core.Test/Tools/Services/AnonymousSendCommandTests.cs b/test/Core.Test/Tools/Services/AnonymousSendCommandTests.cs deleted file mode 100644 index 3101273225..0000000000 --- a/test/Core.Test/Tools/Services/AnonymousSendCommandTests.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System.Text.Json; -using Bit.Core.Exceptions; -using Bit.Core.Platform.Push; -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Data; -using Bit.Core.Tools.Repositories; -using Bit.Core.Tools.SendFeatures.Commands; -using Bit.Core.Tools.Services; -using NSubstitute; -using Xunit; - -namespace Bit.Core.Test.Tools.Services; - -public class AnonymousSendCommandTests -{ - private readonly ISendRepository _sendRepository; - private readonly ISendFileStorageService _sendFileStorageService; - private readonly IPushNotificationService _pushNotificationService; - private readonly ISendAuthorizationService _sendAuthorizationService; - private readonly AnonymousSendCommand _anonymousSendCommand; - - public AnonymousSendCommandTests() - { - _sendRepository = Substitute.For(); - _sendFileStorageService = Substitute.For(); - _pushNotificationService = Substitute.For(); - _sendAuthorizationService = Substitute.For(); - - _anonymousSendCommand = new AnonymousSendCommand( - _sendRepository, - _sendFileStorageService, - _pushNotificationService, - _sendAuthorizationService); - } - - [Fact] - public async Task GetSendFileDownloadUrlAsync_Success_ReturnsDownloadUrl() - { - // Arrange - var send = new Send - { - Id = Guid.NewGuid(), - Type = SendType.File, - AccessCount = 0, - Data = JsonSerializer.Serialize(new { Id = "fileId123" }) - }; - var fileId = "fileId123"; - var password = "testPassword"; - var expectedUrl = "https://example.com/download"; - - _sendAuthorizationService - .SendCanBeAccessed(send, password) - .Returns(SendAccessResult.Granted); - - _sendFileStorageService - .GetSendFileDownloadUrlAsync(send, fileId) - .Returns(expectedUrl); - - // Act - var result = - await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId, password); - - // Assert - Assert.Equal(expectedUrl, result.Item1); - Assert.Equal(1, send.AccessCount); - - await _sendRepository.Received(1).ReplaceAsync(send); - await _pushNotificationService.Received(1).PushSyncSendUpdateAsync(send); - } - - [Fact] - public async Task GetSendFileDownloadUrlAsync_AccessDenied_ReturnsNullWithReasons() - { - // Arrange - var send = new Send - { - Id = Guid.NewGuid(), - Type = SendType.File, - AccessCount = 0 - }; - var fileId = "fileId123"; - var password = "wrongPassword"; - - _sendAuthorizationService - .SendCanBeAccessed(send, password) - .Returns(SendAccessResult.Denied); - - // Act - var result = - await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId, password); - - // Assert - Assert.Null(result.Item1); - Assert.Equal(SendAccessResult.Denied, result.Item2); - Assert.Equal(0, send.AccessCount); - - await _sendRepository.DidNotReceiveWithAnyArgs().ReplaceAsync(default); - await _pushNotificationService.DidNotReceiveWithAnyArgs().PushSyncSendUpdateAsync(default); - } - - [Fact] - public async Task GetSendFileDownloadUrlAsync_NotFileSend_ThrowsBadRequestException() - { - // Arrange - var send = new Send - { - Id = Guid.NewGuid(), - Type = SendType.Text - }; - var fileId = "fileId123"; - var password = "testPassword"; - - // Act & Assert - await Assert.ThrowsAsync(() => - _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId, password)); - } -} diff --git a/test/Core.Test/Tools/Services/NonAnonymousSendCommandTests.cs b/test/Core.Test/Tools/Services/NonAnonymousSendCommandTests.cs deleted file mode 100644 index 674cca7d5f..0000000000 --- a/test/Core.Test/Tools/Services/NonAnonymousSendCommandTests.cs +++ /dev/null @@ -1,1064 +0,0 @@ -using System.Text.Json; -using Bit.Core.Context; -using Bit.Core.Entities; -using Bit.Core.Exceptions; -using Bit.Core.Platform.Push; -using Bit.Core.Services; -using Bit.Core.Test.AutoFixture.CurrentContextFixtures; -using Bit.Core.Test.Tools.AutoFixture.SendFixtures; -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.Enums; -using Bit.Core.Tools.Models.Data; -using Bit.Core.Tools.Repositories; -using Bit.Core.Tools.SendFeatures; -using Bit.Core.Tools.SendFeatures.Commands; -using Bit.Core.Tools.Services; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using NSubstitute.ExceptionExtensions; -using Xunit; - -namespace Bit.Core.Test.Tools.Services; - -[SutProviderCustomize] -[CurrentContextCustomize] -[UserSendCustomize] -public class NonAnonymousSendCommandTests -{ - private readonly ISendRepository _sendRepository; - private readonly ISendFileStorageService _sendFileStorageService; - private readonly IPushNotificationService _pushNotificationService; - private readonly ISendAuthorizationService _sendAuthorizationService; - private readonly ISendValidationService _sendValidationService; - private readonly IFeatureService _featureService; - private readonly ICurrentContext _currentContext; - private readonly ISendCoreHelperService _sendCoreHelperService; - private readonly NonAnonymousSendCommand _nonAnonymousSendCommand; - - public NonAnonymousSendCommandTests() - { - _sendRepository = Substitute.For(); - _sendFileStorageService = Substitute.For(); - _pushNotificationService = Substitute.For(); - _sendAuthorizationService = Substitute.For(); - _featureService = Substitute.For(); - _sendValidationService = Substitute.For(); - _currentContext = Substitute.For(); - _sendCoreHelperService = Substitute.For(); - - _nonAnonymousSendCommand = new NonAnonymousSendCommand( - _sendRepository, - _sendFileStorageService, - _pushNotificationService, - _sendAuthorizationService, - _sendValidationService, - _sendCoreHelperService - ); - } - - // Disable Send policy check - [Theory] - [InlineData(SendType.File)] - [InlineData(SendType.Text)] - public async Task SaveSendAsync_DisableSend_Applies_throws(SendType sendType) - { - // Arrange - var send = new Send - { - Id = default, - Type = sendType, - UserId = Guid.NewGuid() - }; - - var user = new User - { - Id = send.UserId.Value, - Email = "test@example.com" - }; - - // Configure validation service to throw when DisableSend policy applies - _sendValidationService.ValidateUserCanSaveAsync(send.UserId.Value, send) - .Throws(new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send.")); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - _nonAnonymousSendCommand.SaveSendAsync(send)); - - Assert.Contains("Enterprise Policy", exception.Message); - - // Verify the validation service was called - await _sendValidationService.Received(1).ValidateUserCanSaveAsync(send.UserId.Value, send); - - // Verify repository was not called since exception was thrown - await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); - } - - [Theory] - [InlineData(true)] // New Send (Id is default) - [InlineData(false)] // Existing Send (Id is not default) - public async Task SaveSendAsync_DisableSend_DoesntApply_success(bool isNewSend) - { - // Arrange - var userId = Guid.NewGuid(); - var send = new Send - { - Id = isNewSend ? default : Guid.NewGuid(), - Type = SendType.Text, - UserId = userId, - Data = "Text with Notes" - }; - - var initialDate = DateTime.UtcNow.AddMinutes(-5); - send.RevisionDate = initialDate; - - // Configure validation service to NOT throw (policy doesn't apply) - _sendValidationService.ValidateUserCanSaveAsync(userId, send).Returns(Task.CompletedTask); - - // Set up context for reference event - _currentContext.ClientId.Returns("test-client"); - _currentContext.ClientVersion.Returns(Version.Parse("1.0.0")); - - // Act - await _nonAnonymousSendCommand.SaveSendAsync(send); - - // Assert - // Verify validation was checked - await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); - - if (isNewSend) - { - // For new Sends - await _sendRepository.Received(1).CreateAsync(send); - await _pushNotificationService.Received(1).PushSyncSendCreateAsync(send); - } - else - { - // For existing Sends - await _sendRepository.Received(1).UpsertAsync(send); - Assert.NotEqual(initialDate, send.RevisionDate); - await _pushNotificationService.Received(1).PushSyncSendUpdateAsync(send); - } - } - - [Theory] - [InlineData(true)] // New Send (Id is default) - [InlineData(false)] // Existing Send (Id is not default) - public async Task SaveSendAsync_DisableHideEmail_Applies_throws(bool isNewSend) - { - // Arrange - var userId = Guid.NewGuid(); - var send = new Send - { - Id = isNewSend ? default : Guid.NewGuid(), - Type = SendType.Text, - UserId = userId, - HideEmail = true - }; - - // Configure validation service to throw when HideEmail policy applies - _sendValidationService.ValidateUserCanSaveAsync(userId, send) - .Throws(new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send.")); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - _nonAnonymousSendCommand.SaveSendAsync(send)); - - Assert.Contains("hide your email address", exception.Message); - - // Verify validation was called - await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); - - // Verify repository was not called (exception prevented save) - if (isNewSend) - { - await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); - } - else - { - await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); - } - - // Verify push notification wasn't sent - await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); - } - - [Theory] - [InlineData(true)] // New Send (Id is default) - [InlineData(false)] // Existing Send (Id is not default) - public async Task SaveSendAsync_DisableHideEmail_DoesntApply_success(bool isNewSend) - { - // Arrange - var userId = Guid.NewGuid(); - var send = new Send - { - Id = isNewSend ? default : Guid.NewGuid(), - Type = SendType.Text, - UserId = userId, - HideEmail = true // Setting HideEmail to true - }; - - var initialDate = DateTime.UtcNow.AddMinutes(-5); - send.RevisionDate = initialDate; - - // Configure validation service to NOT throw (policy doesn't apply) - _sendValidationService.ValidateUserCanSaveAsync(userId, send).Returns(Task.CompletedTask); - - // Set up context for reference event - _currentContext.ClientId.Returns("test-client"); - _currentContext.ClientVersion.Returns(Version.Parse("1.0.0")); - - // Act - await _nonAnonymousSendCommand.SaveSendAsync(send); - - // Assert - // Verify validation was checked - await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); - - if (isNewSend) - { - // For new Sends - await _sendRepository.Received(1).CreateAsync(send); - await _pushNotificationService.Received(1).PushSyncSendCreateAsync(send); - } - else - { - // For existing Sends - await _sendRepository.Received(1).UpsertAsync(send); - Assert.NotEqual(initialDate, send.RevisionDate); - await _pushNotificationService.Received(1).PushSyncSendUpdateAsync(send); - } - } - - [Theory] - [InlineData(SendType.File)] - [InlineData(SendType.Text)] - public async Task SaveSendAsync_DisableSend_Applies_Throws_vNext(SendType sendType) - { - // Arrange - var userId = Guid.NewGuid(); - var send = new Send - { - Id = default, - Type = sendType, - UserId = userId - }; - - // Configure validation service to throw when DisableSend policy applies in vNext implementation - _sendValidationService.ValidateUserCanSaveAsync(userId, send) - .Returns(Task.FromException(new BadRequestException("Due to an Enterprise Policy, you are only able to delete an existing Send."))); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - _nonAnonymousSendCommand.SaveSendAsync(send)); - - Assert.Contains("Enterprise Policy", exception.Message); - - // Verify validation service was called - await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); - - // Verify repository and notification methods were not called - await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); - await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); - } - - [Theory] - [InlineData(true)] // New Send (Id is default) - [InlineData(false)] // Existing Send (Id is not default) - public async Task SaveSendAsync_DisableSend_DoesntApply_Success_vNext(bool isNewSend) - { - // Arrange - var userId = Guid.NewGuid(); - var send = new Send - { - Id = isNewSend ? default : Guid.NewGuid(), - Type = SendType.Text, - UserId = userId, - Data = "Text with Notes" - }; - - var initialDate = DateTime.UtcNow.AddMinutes(-5); - send.RevisionDate = initialDate; - - // Configure validation service to return success for vNext implementation - _sendValidationService.ValidateUserCanSaveAsync(userId, send).Returns(Task.CompletedTask); - - // Set up context for reference event - _currentContext.ClientId.Returns("test-client"); - _currentContext.ClientVersion.Returns(Version.Parse("1.0.0")); - - // Enable feature flag for policy requirements (vNext path) - _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); - - // Act - await _nonAnonymousSendCommand.SaveSendAsync(send); - - // Assert - // Verify validation was checked with vNext path - await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); - - if (isNewSend) - { - // For new Sends - await _sendRepository.Received(1).CreateAsync(send); - await _pushNotificationService.Received(1).PushSyncSendCreateAsync(send); - } - else - { - // For existing Sends - await _sendRepository.Received(1).UpsertAsync(send); - Assert.NotEqual(initialDate, send.RevisionDate); - await _pushNotificationService.Received(1).PushSyncSendUpdateAsync(send); - } - } - - // Send Options Policy - Disable Hide Email check - [Theory] - [InlineData(true)] // New Send (Id is default) - [InlineData(false)] // Existing Send (Id is not default) - public async Task SaveSendAsync_DisableHideEmail_Applies_Throws_vNext(bool isNewSend) - { - // Arrange - var userId = Guid.NewGuid(); - var send = new Send - { - Id = isNewSend ? default : Guid.NewGuid(), - Type = SendType.Text, - UserId = userId, - HideEmail = true - }; - - // Enable feature flag for policy requirements (vNext path) - _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); - - // Configure validation service to throw when DisableHideEmail policy applies in vNext implementation - _sendValidationService.ValidateUserCanSaveAsync(userId, send) - .Throws(new BadRequestException("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send.")); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - _nonAnonymousSendCommand.SaveSendAsync(send)); - - Assert.Contains("hide your email address", exception.Message); - - // Verify validation was called - await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); - - // Verify repository was not called (exception prevented save) - await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); - await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); - - // Verify push notification wasn't sent - await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); - } - - [Theory] - [InlineData(true)] // New Send (Id is default) - [InlineData(false)] // Existing Send (Id is not default) - public async Task SaveSendAsync_DisableHideEmail_Applies_ButEmailNotHidden_Success_vNext(bool isNewSend) - { - // Arrange - var userId = Guid.NewGuid(); - var send = new Send - { - Id = isNewSend ? default : Guid.NewGuid(), - Type = SendType.Text, - UserId = userId, - HideEmail = false // Email is not hidden, so policy doesn't block - }; - - var initialDate = DateTime.UtcNow.AddMinutes(-5); - send.RevisionDate = initialDate; - - // Enable feature flag for policy requirements (vNext path) - _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); - - // Configure validation service to allow saves when HideEmail is false - _sendValidationService.ValidateUserCanSaveAsync(userId, send).Returns(Task.CompletedTask); - - // Set up context for reference event - _currentContext.ClientId.Returns("test-client"); - _currentContext.ClientVersion.Returns(Version.Parse("1.0.0")); - - // Act - await _nonAnonymousSendCommand.SaveSendAsync(send); - - // Assert - // Verify validation was called with vNext path - await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); - - if (isNewSend) - { - // For new Sends - await _sendRepository.Received(1).CreateAsync(send); - await _pushNotificationService.Received(1).PushSyncSendCreateAsync(send); - } - else - { - // For existing Sends - await _sendRepository.Received(1).UpsertAsync(send); - Assert.NotEqual(initialDate, send.RevisionDate); - await _pushNotificationService.Received(1).PushSyncSendUpdateAsync(send); - } - } - - [Fact] - public async Task SaveSendAsync_ExistingSend_Updates() - { - // Arrange - var userId = Guid.NewGuid(); - var sendId = Guid.NewGuid(); - var send = new Send - { - Id = sendId, - Type = SendType.Text, - UserId = userId, - Data = "Some text data" - }; - - var initialDate = DateTime.UtcNow.AddMinutes(-5); - send.RevisionDate = initialDate; - - // Act - await _nonAnonymousSendCommand.SaveSendAsync(send); - - // Assert - // Verify validation was called - await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); - - // Verify repository was called with updated send - await _sendRepository.Received(1).UpsertAsync(send); - - // Check that the revision date was updated - Assert.NotEqual(initialDate, send.RevisionDate); - - // Verify push notification was sent for the update - await _pushNotificationService.Received(1).PushSyncSendUpdateAsync(send); - } - - [Fact] - public async Task SaveFileSendAsync_TextType_ThrowsBadRequest() - { - // Arrange - var userId = Guid.NewGuid(); - var send = new Send - { - Id = Guid.NewGuid(), - Type = SendType.Text, // Text type instead of File - UserId = userId - }; - var fileData = new SendFileData(); - var fileLength = 1024L; // 1KB - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); - - Assert.Contains("not of type \"file\"", exception.Message); - - // Verify no further methods were called - await _sendValidationService.DidNotReceive().StorageRemainingForSendAsync(Arg.Any()); - await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); - await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); - await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); - } - - [Fact] - public async Task SaveFileSendAsync_EmptyFile_ThrowsBadRequest() - { - // Arrange - var userId = Guid.NewGuid(); - var send = new Send - { - Id = Guid.NewGuid(), - Type = SendType.File, - UserId = userId - }; - var fileData = new SendFileData(); - var fileLength = 0L; // Empty file - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); - - Assert.Contains("No file data", exception.Message); - - // Verify no methods were called after validation failed - await _sendValidationService.DidNotReceive().StorageRemainingForSendAsync(Arg.Any()); - await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); - await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); - await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); - } - - [Fact] - public async Task SaveFileSendAsync_UserCannotAccessPremium_ThrowsBadRequest() - { - // Arrange - var userId = Guid.NewGuid(); - var send = new Send - { - Id = Guid.NewGuid(), - Type = SendType.File, - UserId = userId - }; - var fileData = new SendFileData(); - var fileLength = 1024L; // 1KB - - // Configure validation service to throw when checking storage - _sendValidationService.StorageRemainingForSendAsync(send) - .Throws(new BadRequestException("You must have premium status to use file Sends.")); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); - - Assert.Contains("premium status", exception.Message); - - // Verify storage validation was called - await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); - - // Verify no further methods were called - await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); - await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); - await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); - } - - [Fact] - public async Task SaveFileSendAsync_UserHasUnconfirmedEmail_ThrowsBadRequest() - { - // Arrange - var userId = Guid.NewGuid(); - var send = new Send - { - Id = Guid.NewGuid(), - Type = SendType.File, - UserId = userId - }; - var fileData = new SendFileData(); - var fileLength = 1024L; // 1KB - - // Configure validation service to pass storage check - _sendValidationService.StorageRemainingForSendAsync(send).Returns(10240L); // 10KB remaining - - // Configure validation service to throw when checking user can save - _sendValidationService.When(x => x.ValidateUserCanSaveAsync(userId, send)) - .Throw(new BadRequestException("You must confirm your email before creating a Send.")); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); - - Assert.Contains("confirm your email", exception.Message); - - // Verify storage validation was called - await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); - - // Verify SaveSendAsync attempted to be called, triggering email validation - await _sendValidationService.Received(1).ValidateUserCanSaveAsync(userId, send); - - // Verify no repository or notification methods were called after validation failed - await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); - await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); - } - - [Fact] - public async Task SaveFileSendAsync_UserCanAccessPremium_HasNoStorage_ThrowsBadRequest() - { - // Arrange - var userId = Guid.NewGuid(); - var send = new Send - { - Id = Guid.NewGuid(), - Type = SendType.File, - UserId = userId - }; - var fileData = new SendFileData(); - var fileLength = 1024L; // 1KB - - // Configure validation service to return 0 storage remaining - _sendValidationService.StorageRemainingForSendAsync(send).Returns(0L); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); - - Assert.Contains("Not enough storage available", exception.Message); - - // Verify storage validation was called - await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); - - // Verify no further methods were called - await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); - await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); - await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); - } - - [Fact] - public async Task SaveFileSendAsync_UserCanAccessPremium_StorageFull_ThrowsBadRequest() - { - // Arrange - var userId = Guid.NewGuid(); - var send = new Send - { - Id = Guid.NewGuid(), - Type = SendType.File, - UserId = userId - }; - var fileData = new SendFileData(); - var fileLength = 1024L; // 1KB - - // Configure validation service to return less storage remaining than needed - _sendValidationService.StorageRemainingForSendAsync(send).Returns(512L); // Only 512 bytes available - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); - - Assert.Contains("Not enough storage available", exception.Message); - - // Verify storage validation was called - await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); - - // Verify no further methods were called - await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); - await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); - await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); - } - - [Fact] - public async Task SaveFileSendAsync_UserCanAccessPremium_IsNotPremium_IsSelfHosted_GiantFile_ThrowsBadRequest() - { - // Arrange - var userId = Guid.NewGuid(); - var send = new Send - { - Id = Guid.NewGuid(), - Type = SendType.File, - UserId = userId - }; - var fileData = new SendFileData(); - var fileLength = 15L * 1024L * 1024L * 1024L; // 15GB - - // Configure validation service to return large but insufficient storage (10GB for self-hosted non-premium) - _sendValidationService.StorageRemainingForSendAsync(send) - .Returns(10L * 1024L * 1024L * 1024L); // 10GB remaining (self-hosted default) - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); - - Assert.Contains("Not enough storage available", exception.Message); - - // Verify storage validation was called - await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); - - // Verify no further methods were called - await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); - await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); - await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); - } - - [Fact] - public async Task SaveFileSendAsync_UserCanAccessPremium_IsNotPremium_IsNotSelfHosted_TwoGigabyteFile_ThrowsBadRequest() - { - // Arrange - var userId = Guid.NewGuid(); - var send = new Send - { - Id = Guid.NewGuid(), - Type = SendType.File, - UserId = userId - }; - var fileData = new SendFileData(); - var fileLength = 2L * 1024L * 1024L * 1024L; // 2GB - - // Configure validation service to return 1GB storage (cloud non-premium default) - _sendValidationService.StorageRemainingForSendAsync(send) - .Returns(1L * 1024L * 1024L * 1024L); // 1GB remaining (cloud default) - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); - - Assert.Contains("Not enough storage available", exception.Message); - - // Verify storage validation was called - await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); - - // Verify no further methods were called - await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); - await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); - await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); - } - - [Fact] - public async Task SaveFileSendAsync_ThroughOrg_MaxStorageIsNull_ThrowsBadRequest() - { - // Arrange - var organizationId = Guid.NewGuid(); - var send = new Send - { - Id = Guid.NewGuid(), - Type = SendType.File, - OrganizationId = organizationId - }; - - var fileData = new SendFileData - { - FileName = "test.txt" - }; - - const long fileLength = 1000; - - // Set up validation service to return 0 storage remaining - // This simulates the case when an organization's max storage is null - _sendValidationService.StorageRemainingForSendAsync(send).Returns(0L); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); - - Assert.Equal("Not enough storage available.", exception.Message); - - // Verify the method was called exactly once - await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); - } - - [Fact] - public async Task SaveFileSendAsync_ThroughOrg_MaxStorageIsNull_TwoGBFile_ThrowsBadRequest() - { - // Arrange - var orgId = Guid.NewGuid(); - var send = new Send - { - Id = Guid.NewGuid(), - Type = SendType.File, - OrganizationId = orgId, - UserId = null - }; - var fileData = new SendFileData(); - var fileLength = 2L * 1024L * 1024L * 1024L; // 2GB - - // Configure validation service to throw BadRequest when checking storage for org without storage - _sendValidationService.StorageRemainingForSendAsync(send) - .Throws(new BadRequestException("This organization cannot use file sends.")); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); - - Assert.Contains("This organization cannot use file sends", exception.Message); - - // Verify storage validation was called - await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); - - // Verify no further methods were called - await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); - await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); - await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); - } - - [Fact] - public async Task SaveFileSendAsync_ThroughOrg_MaxStorageIsOneGB_TwoGBFile_ThrowsBadRequest() - { - // Arrange - var orgId = Guid.NewGuid(); - var send = new Send - { - Id = Guid.NewGuid(), - Type = SendType.File, - OrganizationId = orgId, - UserId = null - }; - var fileData = new SendFileData(); - var fileLength = 2L * 1024L * 1024L * 1024L; // 2GB - - // Configure validation service to return 1GB storage (org's max storage limit) - _sendValidationService.StorageRemainingForSendAsync(send) - .Returns(1L * 1024L * 1024L * 1024L); // 1GB remaining - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); - - Assert.Contains("Not enough storage available", exception.Message); - - // Verify storage validation was called - await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); - - // Verify no further methods were called - await _sendRepository.DidNotReceive().CreateAsync(Arg.Any()); - await _sendRepository.DidNotReceive().UpsertAsync(Arg.Any()); - await _sendFileStorageService.DidNotReceive().GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendCreateAsync(Arg.Any()); - await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any()); - } - - [Fact] - public async Task SaveFileSendAsync_HasEnoughStorage_Success() - { - // Arrange - var userId = Guid.NewGuid(); - var send = new Send - { - Id = Guid.NewGuid(), - Type = SendType.File, - UserId = userId - }; - var fileData = new SendFileData(); - var fileLength = 500L * 1024L; // 500KB - var expectedFileId = "generatedfileid"; - var expectedUploadUrl = "https://upload.example.com/url"; - - // Configure storage validation to return more storage than needed - _sendValidationService.StorageRemainingForSendAsync(send) - .Returns(1024L * 1024L); // 1MB remaining - - // Configure file storage service to return upload URL - _sendFileStorageService.GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()) - .Returns(expectedUploadUrl); - - // Set up string generator to return predictable file ID - _sendCoreHelperService.SecureRandomString(32, false, false) - .Returns(expectedFileId); - - // Act - var result = await _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength); - - // Assert - Assert.Equal(expectedUploadUrl, result); - - // Verify storage validation was called - await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); - - // Verify upload URL was requested - await _sendFileStorageService.Received(1).GetSendFileUploadUrlAsync(send, expectedFileId); - } - - [Fact] - public async Task SaveFileSendAsync_HasEnoughStorage_SendFileThrows_CleansUp() - { - // Arrange - var userId = Guid.NewGuid(); - var send = new Send - { - Id = Guid.NewGuid(), - Type = SendType.File, - UserId = userId - }; - var fileData = new SendFileData(); - var fileLength = 500L * 1024L; // 500KB - var expectedFileId = "generatedfileid"; - - // Configure storage validation to return more storage than needed - _sendValidationService.StorageRemainingForSendAsync(send) - .Returns(1024L * 1024L); // 1MB remaining - - // Set up string generator to return predictable file ID - _sendCoreHelperService.SecureRandomString(32, false, false) - .Returns(expectedFileId); - - // Configure file storage service to throw exception when getting upload URL - _sendFileStorageService.GetSendFileUploadUrlAsync(Arg.Any(), Arg.Any()) - .Throws(new Exception("Storage service unavailable")); - - // Act & Assert - await Assert.ThrowsAsync(() => - _nonAnonymousSendCommand.SaveFileSendAsync(send, fileData, fileLength)); - - // Verify storage validation was called - await _sendValidationService.Received(1).StorageRemainingForSendAsync(send); - - // Verify file was cleaned up after failure - await _sendFileStorageService.Received(1).DeleteFileAsync(send, expectedFileId); - } - - [Fact] - public async Task UpdateFileToExistingSendAsync_SendNull_ThrowsBadRequest() - { - // Arrange - Stream stream = new MemoryStream(); - Send send = null; - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send)); - - Assert.Equal("Send does not have file data", exception.Message); - - // Verify no interactions with storage service - await _sendFileStorageService.DidNotReceiveWithAnyArgs().UploadNewFileAsync( - Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task UpdateFileToExistingSendAsync_SendDataNull_ThrowsBadRequest() - { - // Arrange - Stream stream = new MemoryStream(); - var send = new Send - { - Id = Guid.NewGuid(), - Type = SendType.File, - UserId = Guid.NewGuid(), - Data = null // Send exists but has null Data property - }; - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send)); - - Assert.Equal("Send does not have file data", exception.Message); - - // Verify no interactions with storage service - await _sendFileStorageService.DidNotReceiveWithAnyArgs().UploadNewFileAsync( - Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task UpdateFileToExistingSendAsync_NotFileType_ThrowsBadRequest() - { - // Arrange - Stream stream = new MemoryStream(); - var send = new Send - { - Id = Guid.NewGuid(), - Type = SendType.Text, // Not a file type - UserId = Guid.NewGuid(), - Data = "{\"someData\":\"value\"}" // Has data, but not file data - }; - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send)); - - Assert.Equal("Not a File Type Send.", exception.Message); - - // Verify no interactions with storage service - await _sendFileStorageService.DidNotReceiveWithAnyArgs().UploadNewFileAsync( - Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task UpdateFileToExistingSendAsync_StreamPositionRestToZero_Success() - { - // Arrange - var stream = new MemoryStream(new byte[] { 1, 2, 3, 4, 5 }); - stream.Position = 2; - var sendId = Guid.NewGuid(); - var userId = Guid.NewGuid(); - var fileId = "existingfileid123"; - - var sendFileData = new SendFileData { Id = fileId, Size = 1000, Validated = false }; - var send = new Send - { - Id = sendId, - UserId = userId, - Type = SendType.File, - Data = JsonSerializer.Serialize(sendFileData) - }; - - // Setup validation to succeed - _sendFileStorageService.ValidateFileAsync(send, sendFileData.Id, sendFileData.Size, SendFileSettingHelper.FILE_SIZE_LEEWAY).Returns((true, sendFileData.Size)); - - // Act - await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send); - - // Assert - // Verify file was uploaded with correct parameters - await _sendFileStorageService.Received(1).UploadNewFileAsync( - Arg.Is(s => s == stream && s.Position == 0), // Ensure stream position is reset - Arg.Is(s => s.Id == sendId && s.UserId == userId), - Arg.Is(id => id == fileId) - ); - } - - - [Fact] - public async Task UploadFileToExistingSendAsync_Success() - { - // Arrange - var stream = new MemoryStream(new byte[] { 1, 2, 3, 4, 5 }); - stream.Position = 2; // Simulate a non-zero position - var sendId = Guid.NewGuid(); - var userId = Guid.NewGuid(); - var fileId = "existingfileid123"; - - var sendFileData = new SendFileData { Id = fileId, Size = 1000, Validated = false }; - var send = new Send - { - Id = sendId, - UserId = userId, - Type = SendType.File, - Data = JsonSerializer.Serialize(sendFileData) - }; - - _sendFileStorageService.ValidateFileAsync(send, sendFileData.Id, sendFileData.Size, SendFileSettingHelper.FILE_SIZE_LEEWAY).Returns((true, sendFileData.Size)); - - // Act - await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send); - - // Assert - // Verify file was uploaded with correct parameters - await _sendFileStorageService.Received(1).UploadNewFileAsync( - Arg.Is(s => s == stream && s.Position == 0), // Ensure stream position is reset - Arg.Is(s => s.Id == sendId && s.UserId == userId), - Arg.Is(id => id == fileId) - ); - } - - [Fact] - public async Task UpdateFileToExistingSendAsync_InvalidSize_ThrowsBadRequest() - { - // Arrange - var stream = new MemoryStream(new byte[] { 1, 2, 3, 4, 5 }); - var sendId = Guid.NewGuid(); - var userId = Guid.NewGuid(); - var fileId = "existingfileid123"; - - var sendFileData = new SendFileData { Id = fileId, Size = 1000, Validated = false }; - var send = new Send - { - Id = sendId, - UserId = userId, - Type = SendType.File, - Data = JsonSerializer.Serialize(sendFileData) - }; - - // Configure storage service to upload successfully - _sendFileStorageService.UploadNewFileAsync( - Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.CompletedTask); - - // Configure validation to fail due to file size mismatch - _nonAnonymousSendCommand.ConfirmFileSize(send) - .Returns(false); - - // Act & Assert - var exception = await Assert.ThrowsAsync(() => - _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send)); - - Assert.Equal("File received does not match expected file length.", exception.Message); - } -} diff --git a/test/Core.Test/Tools/Services/SendAuthorizationServiceTests.cs b/test/Core.Test/Tools/Services/SendAuthorizationServiceTests.cs deleted file mode 100644 index c33dbc0ec6..0000000000 --- a/test/Core.Test/Tools/Services/SendAuthorizationServiceTests.cs +++ /dev/null @@ -1,168 +0,0 @@ -using Bit.Core.Platform.Push; -using Bit.Core.Tools.Entities; -using Bit.Core.Tools.Models.Data; -using Bit.Core.Tools.Repositories; -using Bit.Core.Tools.Services; -using Microsoft.AspNetCore.Identity; -using NSubstitute; -using Xunit; - -namespace Bit.Core.Test.Tools.Services; - -public class SendAuthorizationServiceTests -{ - private readonly ISendRepository _sendRepository; - private readonly IPasswordHasher _passwordHasher; - private readonly IPushNotificationService _pushNotificationService; - private readonly SendAuthorizationService _sendAuthorizationService; - - public SendAuthorizationServiceTests() - { - _sendRepository = Substitute.For(); - _passwordHasher = Substitute.For>(); - _pushNotificationService = Substitute.For(); - - _sendAuthorizationService = new SendAuthorizationService( - _sendRepository, - _passwordHasher, - _pushNotificationService); - } - - - [Fact] - public void SendCanBeAccessed_Success_ReturnsTrue() - { - // Arrange - var send = new Send - { - Id = Guid.NewGuid(), - UserId = Guid.NewGuid(), - MaxAccessCount = 10, - AccessCount = 5, - ExpirationDate = DateTime.UtcNow.AddYears(1), - DeletionDate = DateTime.UtcNow.AddYears(1), - Disabled = false, - Password = "hashedPassword123" - }; - - const string password = "TEST"; - - _passwordHasher - .VerifyHashedPassword(Arg.Any(), send.Password, password) - .Returns(PasswordVerificationResult.Success); - - // Act - var result = - _sendAuthorizationService.SendCanBeAccessed(send, password); - - // Assert - Assert.Equal(SendAccessResult.Granted, result); - } - - [Fact] - public void SendCanBeAccessed_NullMaxAccess_Success() - { - // Arrange - var send = new Send - { - Id = Guid.NewGuid(), - UserId = Guid.NewGuid(), - MaxAccessCount = null, - AccessCount = 5, - ExpirationDate = DateTime.UtcNow.AddYears(1), - DeletionDate = DateTime.UtcNow.AddYears(1), - Disabled = false, - Password = "hashedPassword123" - }; - - const string password = "TEST"; - - _passwordHasher - .VerifyHashedPassword(Arg.Any(), send.Password, password) - .Returns(PasswordVerificationResult.Success); - - // Act - var result = _sendAuthorizationService.SendCanBeAccessed(send, password); - - // Assert - Assert.Equal(SendAccessResult.Granted, result); - } - - [Fact] - public void SendCanBeAccessed_NullSend_DoesNotGrantAccess() - { - // Arrange - _passwordHasher - .VerifyHashedPassword(Arg.Any(), "TEST", "TEST") - .Returns(PasswordVerificationResult.Success); - - // Act - var result = - _sendAuthorizationService.SendCanBeAccessed(null, "TEST"); - - // Assert - Assert.Equal(SendAccessResult.Denied, result); - } - - [Fact] - public void SendCanBeAccessed_RehashNeeded_RehashesPassword() - { - // Arrange - var now = DateTime.UtcNow; - var send = new Send - { - Id = Guid.NewGuid(), - UserId = Guid.NewGuid(), - MaxAccessCount = null, - AccessCount = 5, - ExpirationDate = now.AddYears(1), - DeletionDate = now.AddYears(1), - Disabled = false, - Password = "TEST" - }; - - _passwordHasher - .VerifyHashedPassword(Arg.Any(), "TEST", "TEST") - .Returns(PasswordVerificationResult.SuccessRehashNeeded); - - // Act - var result = - _sendAuthorizationService.SendCanBeAccessed(send, "TEST"); - - // Assert - _passwordHasher - .Received(1) - .HashPassword(Arg.Any(), "TEST"); - - Assert.Equal(SendAccessResult.Granted, result); - } - - [Fact] - public void SendCanBeAccessed_VerifyFailed_PasswordInvalidReturnsTrue() - { - // Arrange - var now = DateTime.UtcNow; - var send = new Send - { - Id = Guid.NewGuid(), - UserId = Guid.NewGuid(), - MaxAccessCount = null, - AccessCount = 5, - ExpirationDate = now.AddYears(1), - DeletionDate = now.AddYears(1), - Disabled = false, - Password = "TEST" - }; - - _passwordHasher - .VerifyHashedPassword(Arg.Any(), "TEST", "TEST") - .Returns(PasswordVerificationResult.Failed); - - // Act - var result = - _sendAuthorizationService.SendCanBeAccessed(send, "TEST"); - - // Assert - Assert.Equal(SendAccessResult.PasswordInvalid, result); - } -} diff --git a/test/Core.Test/Tools/Services/SendServiceTests.cs b/test/Core.Test/Tools/Services/SendServiceTests.cs new file mode 100644 index 0000000000..86d476340d --- /dev/null +++ b/test/Core.Test/Tools/Services/SendServiceTests.cs @@ -0,0 +1,867 @@ +using System.Text; +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Services; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AutoFixture.CurrentContextFixtures; +using Bit.Core.Test.Entities; +using Bit.Core.Test.Tools.AutoFixture.SendFixtures; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.Repositories; +using Bit.Core.Tools.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Identity; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +using GlobalSettings = Bit.Core.Settings.GlobalSettings; + +namespace Bit.Core.Test.Tools.Services; + +[SutProviderCustomize] +[CurrentContextCustomize] +[UserSendCustomize] +public class SendServiceTests +{ + private void SaveSendAsync_Setup(SendType sendType, bool disableSendPolicyAppliesToUser, + SutProvider sutProvider, Send send) + { + send.Id = default; + send.Type = sendType; + + sutProvider.GetDependency().AnyPoliciesApplicableToUserAsync( + Arg.Any(), PolicyType.DisableSend).Returns(disableSendPolicyAppliesToUser); + } + + // Disable Send policy check + + [Theory] + [BitAutoData(SendType.File)] + [BitAutoData(SendType.Text)] + public async Task SaveSendAsync_DisableSend_Applies_throws(SendType sendType, + SutProvider sutProvider, Send send) + { + SaveSendAsync_Setup(sendType, disableSendPolicyAppliesToUser: true, sutProvider, send); + + await Assert.ThrowsAsync(() => sutProvider.Sut.SaveSendAsync(send)); + } + + [Theory] + [BitAutoData(SendType.File)] + [BitAutoData(SendType.Text)] + public async Task SaveSendAsync_DisableSend_DoesntApply_success(SendType sendType, + SutProvider sutProvider, Send send) + { + SaveSendAsync_Setup(sendType, disableSendPolicyAppliesToUser: false, sutProvider, send); + + await sutProvider.Sut.SaveSendAsync(send); + + await sutProvider.GetDependency().Received(1).CreateAsync(send); + } + + // Send Options Policy - Disable Hide Email check + + private void SaveSendAsync_HideEmail_Setup(bool disableHideEmailAppliesToUser, + SutProvider sutProvider, Send send, Policy policy) + { + send.HideEmail = true; + + var sendOptions = new SendOptionsPolicyData + { + DisableHideEmail = disableHideEmailAppliesToUser + }; + policy.Data = JsonSerializer.Serialize(sendOptions, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }); + + sutProvider.GetDependency().GetPoliciesApplicableToUserAsync( + Arg.Any(), PolicyType.SendOptions).Returns(new List() + { + new() { PolicyType = policy.Type, PolicyData = policy.Data, OrganizationId = policy.OrganizationId, PolicyEnabled = policy.Enabled } + }); + } + + [Theory] + [BitAutoData(SendType.File)] + [BitAutoData(SendType.Text)] + public async Task SaveSendAsync_DisableHideEmail_Applies_throws(SendType sendType, + SutProvider sutProvider, Send send, Policy policy) + { + SaveSendAsync_Setup(sendType, false, sutProvider, send); + SaveSendAsync_HideEmail_Setup(true, sutProvider, send, policy); + + await Assert.ThrowsAsync(() => sutProvider.Sut.SaveSendAsync(send)); + } + + [Theory] + [BitAutoData(SendType.File)] + [BitAutoData(SendType.Text)] + public async Task SaveSendAsync_DisableHideEmail_DoesntApply_success(SendType sendType, + SutProvider sutProvider, Send send, Policy policy) + { + SaveSendAsync_Setup(sendType, false, sutProvider, send); + SaveSendAsync_HideEmail_Setup(false, sutProvider, send, policy); + + await sutProvider.Sut.SaveSendAsync(send); + + await sutProvider.GetDependency().Received(1).CreateAsync(send); + } + + // Disable Send policy check - vNext + private void SaveSendAsync_Setup_vNext(SutProvider sutProvider, Send send, + DisableSendPolicyRequirement disableSendPolicyRequirement, SendOptionsPolicyRequirement sendOptionsPolicyRequirement) + { + sutProvider.GetDependency().GetAsync(send.UserId!.Value) + .Returns(disableSendPolicyRequirement); + sutProvider.GetDependency().GetAsync(send.UserId!.Value) + .Returns(sendOptionsPolicyRequirement); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); + + // Should not be called in these tests + sutProvider.GetDependency().AnyPoliciesApplicableToUserAsync( + Arg.Any(), Arg.Any()).ThrowsAsync(); + } + + [Theory] + [BitAutoData(SendType.File)] + [BitAutoData(SendType.Text)] + public async Task SaveSendAsync_DisableSend_Applies_Throws_vNext(SendType sendType, + SutProvider sutProvider, [NewUserSendCustomize] Send send) + { + send.Type = sendType; + SaveSendAsync_Setup_vNext(sutProvider, send, new DisableSendPolicyRequirement { DisableSend = true }, new SendOptionsPolicyRequirement()); + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SaveSendAsync(send)); + Assert.Contains("Due to an Enterprise Policy, you are only able to delete an existing Send.", + exception.Message); + } + + [Theory] + [BitAutoData(SendType.File)] + [BitAutoData(SendType.Text)] + public async Task SaveSendAsync_DisableSend_DoesntApply_Success_vNext(SendType sendType, + SutProvider sutProvider, [NewUserSendCustomize] Send send) + { + send.Type = sendType; + SaveSendAsync_Setup_vNext(sutProvider, send, new DisableSendPolicyRequirement(), new SendOptionsPolicyRequirement()); + + await sutProvider.Sut.SaveSendAsync(send); + + await sutProvider.GetDependency().Received(1).CreateAsync(send); + } + + // Send Options Policy - Disable Hide Email check + + [Theory] + [BitAutoData(SendType.File)] + [BitAutoData(SendType.Text)] + public async Task SaveSendAsync_DisableHideEmail_Applies_Throws_vNext(SendType sendType, + SutProvider sutProvider, [NewUserSendCustomize] Send send) + { + send.Type = sendType; + SaveSendAsync_Setup_vNext(sutProvider, send, new DisableSendPolicyRequirement(), new SendOptionsPolicyRequirement { DisableHideEmail = true }); + send.HideEmail = true; + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SaveSendAsync(send)); + Assert.Contains("Due to an Enterprise Policy, you are not allowed to hide your email address from recipients when creating or editing a Send.", exception.Message); + } + + [Theory] + [BitAutoData(SendType.File)] + [BitAutoData(SendType.Text)] + public async Task SaveSendAsync_DisableHideEmail_Applies_ButEmailNotHidden_Success_vNext(SendType sendType, + SutProvider sutProvider, [NewUserSendCustomize] Send send) + { + send.Type = sendType; + SaveSendAsync_Setup_vNext(sutProvider, send, new DisableSendPolicyRequirement(), new SendOptionsPolicyRequirement { DisableHideEmail = true }); + send.HideEmail = false; + + await sutProvider.Sut.SaveSendAsync(send); + + await sutProvider.GetDependency().Received(1).CreateAsync(send); + } + + [Theory] + [BitAutoData(SendType.File)] + [BitAutoData(SendType.Text)] + public async Task SaveSendAsync_DisableHideEmail_DoesntApply_Success_vNext(SendType sendType, + SutProvider sutProvider, [NewUserSendCustomize] Send send) + { + send.Type = sendType; + SaveSendAsync_Setup_vNext(sutProvider, send, new DisableSendPolicyRequirement(), new SendOptionsPolicyRequirement()); + send.HideEmail = true; + + await sutProvider.Sut.SaveSendAsync(send); + + await sutProvider.GetDependency().Received(1).CreateAsync(send); + } + + [Theory] + [BitAutoData] + public async Task SaveSendAsync_ExistingSend_Updates(SutProvider sutProvider, + Send send) + { + send.Id = Guid.NewGuid(); + + var now = DateTime.UtcNow; + await sutProvider.Sut.SaveSendAsync(send); + + Assert.True(send.RevisionDate - now < TimeSpan.FromSeconds(1)); + + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(send); + + await sutProvider.GetDependency() + .Received(1) + .PushSyncSendUpdateAsync(send); + } + + [Theory] + [BitAutoData] + public async Task SaveFileSendAsync_TextType_ThrowsBadRequest(SutProvider sutProvider, + Send send) + { + send.Type = SendType.Text; + + var badRequest = await Assert.ThrowsAsync(() => + sutProvider.Sut.SaveFileSendAsync(send, null, 0) + ); + + Assert.Contains("not of type \"file\"", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); + } + + [Theory] + [BitAutoData] + public async Task SaveFileSendAsync_EmptyFile_ThrowsBadRequest(SutProvider sutProvider, + Send send) + { + send.Type = SendType.File; + + var badRequest = await Assert.ThrowsAsync(() => + sutProvider.Sut.SaveFileSendAsync(send, null, 0) + ); + + Assert.Contains("no file data", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); + } + + [Theory] + [BitAutoData] + public async Task SaveFileSendAsync_UserCannotAccessPremium_ThrowsBadRequest(SutProvider sutProvider, + Send send) + { + var user = new User + { + Id = Guid.NewGuid(), + }; + + send.UserId = user.Id; + send.Type = SendType.File; + + sutProvider.GetDependency() + .GetByIdAsync(user.Id) + .Returns(user); + + sutProvider.GetDependency() + .CanAccessPremium(user) + .Returns(false); + + var badRequest = await Assert.ThrowsAsync(() => + sutProvider.Sut.SaveFileSendAsync(send, null, 1) + ); + + Assert.Contains("must have premium", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); + } + + [Theory] + [BitAutoData] + public async Task SaveFileSendAsync_UserHasUnconfirmedEmail_ThrowsBadRequest(SutProvider sutProvider, + Send send) + { + var user = new User + { + Id = Guid.NewGuid(), + EmailVerified = false, + }; + + send.UserId = user.Id; + send.Type = SendType.File; + + sutProvider.GetDependency() + .GetByIdAsync(user.Id) + .Returns(user); + + sutProvider.GetDependency() + .CanAccessPremium(user) + .Returns(true); + + var badRequest = await Assert.ThrowsAsync(() => + sutProvider.Sut.SaveFileSendAsync(send, null, 1) + ); + + Assert.Contains("must confirm your email", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); + } + + [Theory] + [BitAutoData] + public async Task SaveFileSendAsync_UserCanAccessPremium_HasNoStorage_ThrowsBadRequest(SutProvider sutProvider, + Send send) + { + var user = new User + { + Id = Guid.NewGuid(), + EmailVerified = true, + Premium = true, + MaxStorageGb = null, + Storage = 0, + }; + + send.UserId = user.Id; + send.Type = SendType.File; + + sutProvider.GetDependency() + .GetByIdAsync(user.Id) + .Returns(user); + + sutProvider.GetDependency() + .CanAccessPremium(user) + .Returns(true); + + var badRequest = await Assert.ThrowsAsync(() => + sutProvider.Sut.SaveFileSendAsync(send, null, 1) + ); + + Assert.Contains("not enough storage", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); + } + + [Theory] + [BitAutoData] + public async Task SaveFileSendAsync_UserCanAccessPremium_StorageFull_ThrowsBadRequest(SutProvider sutProvider, + Send send) + { + var user = new User + { + Id = Guid.NewGuid(), + EmailVerified = true, + Premium = true, + MaxStorageGb = 2, + Storage = 2 * UserTests.Multiplier, + }; + + send.UserId = user.Id; + send.Type = SendType.File; + + sutProvider.GetDependency() + .GetByIdAsync(user.Id) + .Returns(user); + + sutProvider.GetDependency() + .CanAccessPremium(user) + .Returns(true); + + var badRequest = await Assert.ThrowsAsync(() => + sutProvider.Sut.SaveFileSendAsync(send, null, 1) + ); + + Assert.Contains("not enough storage", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); + } + + [Theory] + [BitAutoData] + public async Task SaveFileSendAsync_UserCanAccessPremium_IsNotPremium_IsSelfHosted_GiantFile_ThrowsBadRequest(SutProvider sutProvider, + Send send) + { + var user = new User + { + Id = Guid.NewGuid(), + EmailVerified = true, + Premium = false, + }; + + send.UserId = user.Id; + send.Type = SendType.File; + + sutProvider.GetDependency() + .GetByIdAsync(user.Id) + .Returns(user); + + sutProvider.GetDependency() + .CanAccessPremium(user) + .Returns(true); + + sutProvider.GetDependency() + .SelfHosted = true; + + var badRequest = await Assert.ThrowsAsync(() => + sutProvider.Sut.SaveFileSendAsync(send, null, 11000 * UserTests.Multiplier) + ); + + Assert.Contains("not enough storage", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); + } + + [Theory] + [BitAutoData] + public async Task SaveFileSendAsync_UserCanAccessPremium_IsNotPremium_IsNotSelfHosted_TwoGigabyteFile_ThrowsBadRequest(SutProvider sutProvider, + Send send) + { + var user = new User + { + Id = Guid.NewGuid(), + EmailVerified = true, + Premium = false, + }; + + send.UserId = user.Id; + send.Type = SendType.File; + + sutProvider.GetDependency() + .GetByIdAsync(user.Id) + .Returns(user); + + sutProvider.GetDependency() + .CanAccessPremium(user) + .Returns(true); + + sutProvider.GetDependency() + .SelfHosted = false; + + var badRequest = await Assert.ThrowsAsync(() => + sutProvider.Sut.SaveFileSendAsync(send, null, 2 * UserTests.Multiplier) + ); + + Assert.Contains("not enough storage", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); + } + + [Theory] + [BitAutoData] + public async Task SaveFileSendAsync_ThroughOrg_MaxStorageIsNull_ThrowsBadRequest(SutProvider sutProvider, + Send send) + { + var org = new Organization + { + Id = Guid.NewGuid(), + MaxStorageGb = null, + }; + + send.UserId = null; + send.OrganizationId = org.Id; + send.Type = SendType.File; + + sutProvider.GetDependency() + .GetByIdAsync(org.Id) + .Returns(org); + + var badRequest = await Assert.ThrowsAsync(() => + sutProvider.Sut.SaveFileSendAsync(send, null, 1) + ); + + Assert.Contains("organization cannot use file sends", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); + } + + [Theory] + [BitAutoData] + public async Task SaveFileSendAsync_ThroughOrg_MaxStorageIsNull_TwoGBFile_ThrowsBadRequest(SutProvider sutProvider, + Send send) + { + var org = new Organization + { + Id = Guid.NewGuid(), + MaxStorageGb = null, + }; + + send.UserId = null; + send.OrganizationId = org.Id; + send.Type = SendType.File; + + sutProvider.GetDependency() + .GetByIdAsync(org.Id) + .Returns(org); + + var badRequest = await Assert.ThrowsAsync(() => + sutProvider.Sut.SaveFileSendAsync(send, null, 1) + ); + + Assert.Contains("organization cannot use file sends", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); + } + + [Theory] + [BitAutoData] + public async Task SaveFileSendAsync_ThroughOrg_MaxStorageIsOneGB_TwoGBFile_ThrowsBadRequest(SutProvider sutProvider, + Send send) + { + var org = new Organization + { + Id = Guid.NewGuid(), + MaxStorageGb = 1, + }; + + send.UserId = null; + send.OrganizationId = org.Id; + send.Type = SendType.File; + + sutProvider.GetDependency() + .GetByIdAsync(org.Id) + .Returns(org); + + var badRequest = await Assert.ThrowsAsync(() => + sutProvider.Sut.SaveFileSendAsync(send, null, 2 * UserTests.Multiplier) + ); + + Assert.Contains("not enough storage", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); + } + + [Theory] + [BitAutoData] + public async Task SaveFileSendAsync_HasEnoughStorage_Success(SutProvider sutProvider, + Send send) + { + var user = new User + { + Id = Guid.NewGuid(), + EmailVerified = true, + MaxStorageGb = 10, + }; + + var data = new SendFileData + { + + }; + + send.UserId = user.Id; + send.Type = SendType.File; + + var testUrl = "https://test.com/"; + + sutProvider.GetDependency() + .GetByIdAsync(user.Id) + .Returns(user); + + sutProvider.GetDependency() + .CanAccessPremium(user) + .Returns(true); + + sutProvider.GetDependency() + .GetSendFileUploadUrlAsync(send, Arg.Any()) + .Returns(testUrl); + + var utcNow = DateTime.UtcNow; + + var url = await sutProvider.Sut.SaveFileSendAsync(send, data, 1 * UserTests.Multiplier); + + Assert.Equal(testUrl, url); + Assert.True(send.RevisionDate - utcNow < TimeSpan.FromSeconds(1)); + + await sutProvider.GetDependency() + .Received(1) + .GetSendFileUploadUrlAsync(send, Arg.Any()); + + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(send); + + await sutProvider.GetDependency() + .Received(1) + .PushSyncSendUpdateAsync(send); + } + + [Theory] + [BitAutoData] + public async Task SaveFileSendAsync_HasEnoughStorage_SendFileThrows_CleansUp(SutProvider sutProvider, + Send send) + { + var user = new User + { + Id = Guid.NewGuid(), + EmailVerified = true, + MaxStorageGb = 10, + }; + + var data = new SendFileData + { + + }; + + send.UserId = user.Id; + send.Type = SendType.File; + + sutProvider.GetDependency() + .GetByIdAsync(user.Id) + .Returns(user); + + sutProvider.GetDependency() + .CanAccessPremium(user) + .Returns(true); + + sutProvider.GetDependency() + .GetSendFileUploadUrlAsync(send, Arg.Any()) + .Returns(callInfo => throw new Exception("Problem")); + + var utcNow = DateTime.UtcNow; + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.SaveFileSendAsync(send, data, 1 * UserTests.Multiplier) + ); + + Assert.True(send.RevisionDate - utcNow < TimeSpan.FromSeconds(1)); + Assert.Equal("Problem", exception.Message); + + await sutProvider.GetDependency() + .Received(1) + .GetSendFileUploadUrlAsync(send, Arg.Any()); + + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(send); + + await sutProvider.GetDependency() + .Received(1) + .PushSyncSendUpdateAsync(send); + + await sutProvider.GetDependency() + .Received(1) + .DeleteFileAsync(send, Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateFileToExistingSendAsync_SendNull_ThrowsBadRequest(SutProvider sutProvider) + { + + var badRequest = await Assert.ThrowsAsync(() => + sutProvider.Sut.UploadFileToExistingSendAsync(new MemoryStream(), null) + ); + + Assert.Contains("does not have file data", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); + } + + [Theory] + [BitAutoData] + public async Task UpdateFileToExistingSendAsync_SendDataNull_ThrowsBadRequest(SutProvider sutProvider, + Send send) + { + send.Data = null; + + var badRequest = await Assert.ThrowsAsync(() => + sutProvider.Sut.UploadFileToExistingSendAsync(new MemoryStream(), send) + ); + + Assert.Contains("does not have file data", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); + } + + [Theory] + [BitAutoData] + public async Task UpdateFileToExistingSendAsync_NotFileType_ThrowsBadRequest(SutProvider sutProvider, + Send send) + { + var badRequest = await Assert.ThrowsAsync(() => + sutProvider.Sut.UploadFileToExistingSendAsync(new MemoryStream(), send) + ); + + Assert.Contains("not a file type send", badRequest.Message, StringComparison.InvariantCultureIgnoreCase); + } + + [Theory] + [BitAutoData] + public async Task UpdateFileToExistingSendAsync_Success(SutProvider sutProvider, + Send send) + { + var fileContents = "Test file content"; + + var sendFileData = new SendFileData + { + Id = "TEST", + Size = fileContents.Length, + Validated = false, + }; + + send.Type = SendType.File; + send.Data = JsonSerializer.Serialize(sendFileData); + + sutProvider.GetDependency() + .ValidateFileAsync(send, sendFileData.Id, sendFileData.Size, Arg.Any()) + .Returns((true, sendFileData.Size)); + + await sutProvider.Sut.UploadFileToExistingSendAsync(new MemoryStream(Encoding.UTF8.GetBytes(fileContents)), send); + } + + [Theory] + [BitAutoData] + public async Task UpdateFileToExistingSendAsync_InvalidSize(SutProvider sutProvider, + Send send) + { + var fileContents = "Test file content"; + + var sendFileData = new SendFileData + { + Id = "TEST", + Size = fileContents.Length, + }; + + send.Type = SendType.File; + send.Data = JsonSerializer.Serialize(sendFileData); + + sutProvider.GetDependency() + .ValidateFileAsync(send, sendFileData.Id, sendFileData.Size, Arg.Any()) + .Returns((false, sendFileData.Size)); + + var badRequest = await Assert.ThrowsAsync(() => + sutProvider.Sut.UploadFileToExistingSendAsync(new MemoryStream(Encoding.UTF8.GetBytes(fileContents)), send) + ); + } + + [Theory] + [BitAutoData] + public void SendCanBeAccessed_Success(SutProvider sutProvider, Send send) + { + var now = DateTime.UtcNow; + send.MaxAccessCount = 10; + send.AccessCount = 5; + send.ExpirationDate = now.AddYears(1); + send.DeletionDate = now.AddYears(1); + send.Disabled = false; + + sutProvider.GetDependency>() + .VerifyHashedPassword(Arg.Any(), send.Password, "TEST") + .Returns(PasswordVerificationResult.Success); + + var (grant, passwordRequiredError, passwordInvalidError) + = sutProvider.Sut.SendCanBeAccessed(send, "TEST"); + + Assert.True(grant); + Assert.False(passwordRequiredError); + Assert.False(passwordInvalidError); + } + + [Theory] + [BitAutoData] + public void SendCanBeAccessed_NullMaxAccess_Success(SutProvider sutProvider, + Send send) + { + var now = DateTime.UtcNow; + send.MaxAccessCount = null; + send.AccessCount = 5; + send.ExpirationDate = now.AddYears(1); + send.DeletionDate = now.AddYears(1); + send.Disabled = false; + + sutProvider.GetDependency>() + .VerifyHashedPassword(Arg.Any(), send.Password, "TEST") + .Returns(PasswordVerificationResult.Success); + + var (grant, passwordRequiredError, passwordInvalidError) + = sutProvider.Sut.SendCanBeAccessed(send, "TEST"); + + Assert.True(grant); + Assert.False(passwordRequiredError); + Assert.False(passwordInvalidError); + } + + [Theory] + [BitAutoData] + public void SendCanBeAccessed_NullSend_DoesNotGrantAccess(SutProvider sutProvider) + { + sutProvider.GetDependency>() + .VerifyHashedPassword(Arg.Any(), "TEST", "TEST") + .Returns(PasswordVerificationResult.Success); + + var (grant, passwordRequiredError, passwordInvalidError) + = sutProvider.Sut.SendCanBeAccessed(null, "TEST"); + + Assert.False(grant); + Assert.False(passwordRequiredError); + Assert.False(passwordInvalidError); + } + + [Theory] + [BitAutoData] + public void SendCanBeAccessed_NullPassword_PasswordRequiredErrorReturnsTrue(SutProvider sutProvider, + Send send) + { + var now = DateTime.UtcNow; + send.MaxAccessCount = null; + send.AccessCount = 5; + send.ExpirationDate = now.AddYears(1); + send.DeletionDate = now.AddYears(1); + send.Disabled = false; + send.Password = "HASH"; + + sutProvider.GetDependency>() + .VerifyHashedPassword(Arg.Any(), "TEST", "TEST") + .Returns(PasswordVerificationResult.Success); + + var (grant, passwordRequiredError, passwordInvalidError) + = sutProvider.Sut.SendCanBeAccessed(send, null); + + Assert.False(grant); + Assert.True(passwordRequiredError); + Assert.False(passwordInvalidError); + } + + [Theory] + [BitAutoData] + public void SendCanBeAccessed_RehashNeeded_RehashesPassword(SutProvider sutProvider, + Send send) + { + var now = DateTime.UtcNow; + send.MaxAccessCount = null; + send.AccessCount = 5; + send.ExpirationDate = now.AddYears(1); + send.DeletionDate = now.AddYears(1); + send.Disabled = false; + send.Password = "TEST"; + + sutProvider.GetDependency>() + .VerifyHashedPassword(Arg.Any(), "TEST", "TEST") + .Returns(PasswordVerificationResult.SuccessRehashNeeded); + + var (grant, passwordRequiredError, passwordInvalidError) + = sutProvider.Sut.SendCanBeAccessed(send, "TEST"); + + sutProvider.GetDependency>() + .Received(1) + .HashPassword(Arg.Any(), "TEST"); + + Assert.True(grant); + Assert.False(passwordRequiredError); + Assert.False(passwordInvalidError); + } + + [Theory] + [BitAutoData] + public void SendCanBeAccessed_VerifyFailed_PasswordInvalidReturnsTrue(SutProvider sutProvider, + Send send) + { + var now = DateTime.UtcNow; + send.MaxAccessCount = null; + send.AccessCount = 5; + send.ExpirationDate = now.AddYears(1); + send.DeletionDate = now.AddYears(1); + send.Disabled = false; + send.Password = "TEST"; + + sutProvider.GetDependency>() + .VerifyHashedPassword(Arg.Any(), "TEST", "TEST") + .Returns(PasswordVerificationResult.Failed); + + var (grant, passwordRequiredError, passwordInvalidError) + = sutProvider.Sut.SendCanBeAccessed(send, "TEST"); + + Assert.False(grant); + Assert.False(passwordRequiredError); + Assert.True(passwordInvalidError); + } +}