diff --git a/src/Api/Tools/Controllers/SendsController.cs b/src/Api/Tools/Controllers/SendsController.cs index a51ec942cfd2..89c835ff7ca9 100644 --- a/src/Api/Tools/Controllers/SendsController.cs +++ b/src/Api/Tools/Controllers/SendsController.cs @@ -7,12 +7,12 @@ using Bit.Core; using Bit.Core.Exceptions; using Bit.Core.Services; -using Bit.Core.Settings; 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.Interfaces; +using Bit.Core.Tools.SendFeatures.Queries.Interfaces; using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; @@ -30,8 +30,10 @@ public class SendsController : Controller private readonly ISendFileStorageService _sendFileStorageService; private readonly IAnonymousSendCommand _anonymousSendCommand; private readonly INonAnonymousSendCommand _nonAnonymousSendCommand; + + private readonly ISendOwnerQuery _sendOwnerQuery; + private readonly ILogger _logger; - private readonly GlobalSettings _globalSettings; public SendsController( ISendRepository sendRepository, @@ -39,18 +41,18 @@ public SendsController( ISendAuthorizationService sendAuthorizationService, IAnonymousSendCommand anonymousSendCommand, INonAnonymousSendCommand nonAnonymousSendCommand, + ISendOwnerQuery sendOwnerQuery, ISendFileStorageService sendFileStorageService, - ILogger logger, - GlobalSettings globalSettings) + ILogger logger) { _sendRepository = sendRepository; _userService = userService; _sendAuthorizationService = sendAuthorizationService; _anonymousSendCommand = anonymousSendCommand; _nonAnonymousSendCommand = nonAnonymousSendCommand; + _sendOwnerQuery = sendOwnerQuery; _sendFileStorageService = sendFileStorageService; _logger = logger; - _globalSettings = globalSettings; } #region Anonymous endpoints @@ -83,7 +85,7 @@ public async Task Access(string id, [FromBody] SendAccessRequestM throw new NotFoundException(); } - var sendResponse = new SendAccessResponseModel(send, _globalSettings); + var sendResponse = new SendAccessResponseModel(send); if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault()) { var creator = await _userService.GetUserByIdAsync(send.UserId.Value); @@ -178,23 +180,19 @@ public async Task AzureValidateFile() [HttpGet("{id}")] public async Task Get(string id) { - var userId = _userService.GetProperUserId(User).Value; - var send = await _sendRepository.GetByIdAsync(new Guid(id)); - if (send == null || send.UserId != userId) - { - throw new NotFoundException(); - } - - return new SendResponseModel(send, _globalSettings); + var sendId = new Guid(id); + var send = await _sendOwnerQuery.Get(sendId); + return new SendResponseModel(send); } [HttpGet("")] public async Task> Get() { - var userId = _userService.GetProperUserId(User).Value; - var sends = await _sendRepository.GetManyByUserIdAsync(userId); - var responses = sends.Select(s => new SendResponseModel(s, _globalSettings)); - return new ListResponseModel(responses); + var sends = await _sendOwnerQuery.GetOwned(); + var responses = sends.Select(s => new SendResponseModel(s)); + var result = new ListResponseModel(responses); + + return result; } [HttpPost("")] @@ -204,7 +202,7 @@ public async Task Post([FromBody] SendRequestModel model) var userId = _userService.GetProperUserId(User).Value; var send = model.ToSend(userId, _sendAuthorizationService); await _nonAnonymousSendCommand.SaveSendAsync(send); - return new SendResponseModel(send, _globalSettings); + return new SendResponseModel(send); } [HttpPost("file/v2")] @@ -233,7 +231,7 @@ public async Task PostFile([FromBody] SendReque { Url = uploadUrl, FileUploadType = _sendFileStorageService.FileUploadType, - SendResponse = new SendResponseModel(send, _globalSettings) + SendResponse = new SendResponseModel(send) }; } @@ -257,7 +255,7 @@ public async Task RenewFileUpload(string id, st { Url = await _sendFileStorageService.GetSendFileUploadUrlAsync(send, fileId), FileUploadType = _sendFileStorageService.FileUploadType, - SendResponse = new SendResponseModel(send, _globalSettings), + SendResponse = new SendResponseModel(send), }; } @@ -291,7 +289,7 @@ public async Task Put(string id, [FromBody] SendRequestModel } await _nonAnonymousSendCommand.SaveSendAsync(model.ToSend(send, _sendAuthorizationService)); - return new SendResponseModel(send, _globalSettings); + return new SendResponseModel(send); } [HttpPut("{id}/remove-password")] @@ -306,7 +304,7 @@ public async Task PutRemovePassword(string id) send.Password = null; await _nonAnonymousSendCommand.SaveSendAsync(send); - return new SendResponseModel(send, _globalSettings); + return new SendResponseModel(send); } [HttpDelete("{id}")] diff --git a/src/Api/Tools/Models/Response/SendAccessResponseModel.cs b/src/Api/Tools/Models/Response/SendAccessResponseModel.cs index a3bb0f8bc08d..e24f368d39bb 100644 --- a/src/Api/Tools/Models/Response/SendAccessResponseModel.cs +++ b/src/Api/Tools/Models/Response/SendAccessResponseModel.cs @@ -1,6 +1,5 @@ using System.Text.Json; using Bit.Core.Models.Api; -using Bit.Core.Settings; using Bit.Core.Tools.Entities; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Data; @@ -10,7 +9,7 @@ namespace Bit.Api.Tools.Models.Response; public class SendAccessResponseModel : ResponseModel { - public SendAccessResponseModel(Send send, GlobalSettings globalSettings) + public SendAccessResponseModel(Send send) : base("send-access") { if (send == null) diff --git a/src/Api/Tools/Models/Response/SendResponseModel.cs b/src/Api/Tools/Models/Response/SendResponseModel.cs index 2ea217fd67a4..9fa916d31578 100644 --- a/src/Api/Tools/Models/Response/SendResponseModel.cs +++ b/src/Api/Tools/Models/Response/SendResponseModel.cs @@ -1,6 +1,5 @@ using System.Text.Json; using Bit.Core.Models.Api; -using Bit.Core.Settings; using Bit.Core.Tools.Entities; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Data; @@ -10,7 +9,7 @@ namespace Bit.Api.Tools.Models.Response; public class SendResponseModel : ResponseModel { - public SendResponseModel(Send send, GlobalSettings globalSettings) + public SendResponseModel(Send send) : base("send") { if (send == null) diff --git a/src/Api/Vault/Models/Response/SyncResponseModel.cs b/src/Api/Vault/Models/Response/SyncResponseModel.cs index b9da786567b8..ffa0540021ae 100644 --- a/src/Api/Vault/Models/Response/SyncResponseModel.cs +++ b/src/Api/Vault/Models/Response/SyncResponseModel.cs @@ -50,7 +50,7 @@ public SyncResponseModel( c => new CollectionDetailsResponseModel(c)) ?? new List(); Domains = excludeDomains ? null : new DomainsResponseModel(user, false); Policies = policies?.Select(p => new PolicyResponseModel(p)) ?? new List(); - Sends = sends.Select(s => new SendResponseModel(s, globalSettings)); + Sends = sends.Select(s => new SendResponseModel(s)); } public ProfileResponseModel Profile { get; set; } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index b2e28dab47ad..6c27fb35a969 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -193,8 +193,22 @@ public static class FeatureFlagKeys public const string IpcChannelFramework = "ipc-channel-framework"; /* Tools Team */ + /// + /// Enable this flag to share the send view used by the web and browser clients + /// on the desktop client. + /// public const string DesktopSendUIRefresh = "desktop-send-ui-refresh"; + /// + /// Enable this flag to output email/OTP authenticated sends from the `GET sends` endpoint. When + /// this flag is disabled, the `GET sends` endpoint omits email/OTP authenticated sends. + /// + /// + /// This flag is server-side only, and only inhibits the endpoint returning all sends. + /// Email/OTP sends can still be created and downloaded through other endpoints. + /// + public const string PM19051_ListEmailOtpSends = "tools-send-email-otp-listing"; + /* Vault Team */ public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge"; public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form"; diff --git a/src/Core/Tools/SendFeatures/Queries/Interfaces/ISendOwnerQuery.cs b/src/Core/Tools/SendFeatures/Queries/Interfaces/ISendOwnerQuery.cs new file mode 100644 index 000000000000..d7c2ca4c5bb6 --- /dev/null +++ b/src/Core/Tools/SendFeatures/Queries/Interfaces/ISendOwnerQuery.cs @@ -0,0 +1,37 @@ +using Bit.Core.Exceptions; +using Bit.Core.Tools.Entities; + +#nullable enable + +namespace Bit.Core.Tools.SendFeatures.Queries.Interfaces; + +/// +/// Queries sends owned by the current user. +/// +public interface ISendOwnerQuery +{ + /// + /// Gets a send. + /// + /// Identifies the send + /// The send + /// + /// Thrown when fails to identify a send + /// owned by the user. + /// + /// + /// Thrown when the query cannot identify the current user. + /// + Task Get(Guid id); + + /// + /// Gets all sends owned by the current user. + /// + /// + /// A sequence of all owned sends. + /// + /// + /// Thrown when the query cannot identify the current user. + /// + Task> GetOwned(); +} diff --git a/src/Core/Tools/SendFeatures/Queries/SendOwnerQuery.cs b/src/Core/Tools/SendFeatures/Queries/SendOwnerQuery.cs new file mode 100644 index 000000000000..3470d9411470 --- /dev/null +++ b/src/Core/Tools/SendFeatures/Queries/SendOwnerQuery.cs @@ -0,0 +1,69 @@ + +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Repositories; +using Bit.Core.Tools.SendFeatures.Queries.Interfaces; + +#nullable enable + +namespace Bit.Core.Tools.SendFeatures.Queries; + +/// +public class SendOwnerQuery : ISendOwnerQuery +{ + private readonly ISendRepository _repository; + private readonly IFeatureService _features; + private readonly ICurrentContext _context; + private Guid CurrentUserId + { + get => _context.UserId ?? throw new BadRequestException("Invalid user."); + } + + /// + /// Instantiates the command + /// + /// + /// Retrieves send records + /// + /// + /// Thrown when is . + /// + public SendOwnerQuery(ISendRepository sendRepository, IFeatureService features, ICurrentContext context) + { + _repository = sendRepository; + _features = features ?? throw new ArgumentNullException(nameof(features)); + _context = context ?? throw new ArgumentNullException(nameof(context)); + } + + /// + public async Task Get(Guid id) + { + var send = await _repository.GetByIdAsync(id); + if (send == null || send.UserId != CurrentUserId) + { + throw new NotFoundException(); + } + + return send; + } + + /// + public async Task> GetOwned() + { + var sends = await _repository.GetManyByUserIdAsync(CurrentUserId); + + var removeEmailOtp = !_features.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends); + if (removeEmailOtp) + { + // reify list to avoid invalidating the enumerator + foreach (var s in sends.Where(s => s.Emails != null).ToList()) + { + sends.Remove(s); + } + } + + return sends; + } +} diff --git a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs index 7210bddebb82..b51b3e743fe4 100644 --- a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs +++ b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using AutoFixture.Xunit2; +using Bit.Api.Models.Response; using Bit.Api.Tools.Controllers; using Bit.Api.Tools.Models.Request; using Bit.Api.Tools.Models.Response; @@ -12,6 +13,7 @@ using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.Repositories; using Bit.Core.Tools.SendFeatures.Commands.Interfaces; +using Bit.Core.Tools.SendFeatures.Queries.Interfaces; using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Mvc; @@ -29,6 +31,7 @@ public class SendsControllerTests : IDisposable private readonly ISendRepository _sendRepository; private readonly INonAnonymousSendCommand _nonAnonymousSendCommand; private readonly IAnonymousSendCommand _anonymousSendCommand; + private readonly ISendOwnerQuery _sendOwnerQuery; private readonly ISendAuthorizationService _sendAuthorizationService; private readonly ISendFileStorageService _sendFileStorageService; private readonly ILogger _logger; @@ -39,6 +42,7 @@ public SendsControllerTests() _sendRepository = Substitute.For(); _nonAnonymousSendCommand = Substitute.For(); _anonymousSendCommand = Substitute.For(); + _sendOwnerQuery = Substitute.For(); _sendAuthorizationService = Substitute.For(); _sendFileStorageService = Substitute.For(); _globalSettings = new GlobalSettings(); @@ -50,9 +54,9 @@ public SendsControllerTests() _sendAuthorizationService, _anonymousSendCommand, _nonAnonymousSendCommand, + _sendOwnerQuery, _sendFileStorageService, - _logger, - _globalSettings + _logger ); } @@ -109,4 +113,62 @@ public async Task PostFile_DeletionDateIsMoreThan31DaysFromNow_ThrowsBadRequest( var exception = await Assert.ThrowsAsync(() => _sut.PostFile(request)); Assert.Equal(expected, exception.Message); } + + [Theory, AutoData] + public async Task Get_WithValidId_ReturnsSendResponseModel(Guid sendId, Send send) + { + send.Type = SendType.Text; + var textData = new SendTextData("Test Send", "Notes", "Sample text", false); + send.Data = JsonSerializer.Serialize(textData); + _sendOwnerQuery.Get(sendId).Returns(send); + + var result = await _sut.Get(sendId.ToString()); + + Assert.NotNull(result); + Assert.IsType(result); + Assert.Equal(send.Id, result.Id); + await _sendOwnerQuery.Received(1).Get(sendId); + } + + [Theory, AutoData] + public async Task Get_WithInvalidGuid_ThrowsException(string invalidId) + { + await Assert.ThrowsAsync(() => _sut.Get(invalidId)); + } + + [Fact] + public async Task GetAllOwned_ReturnsListResponseModelWithSendResponseModels() + { + var textSendData = new SendTextData("Test Send 1", "Notes 1", "Sample text", false); + var fileSendData = new SendFileData("Test Send 2", "Notes 2", "test.txt") { Id = "file-123", Size = 1024 }; + var sends = new List + { + new Send { Id = Guid.NewGuid(), Type = SendType.Text, Data = JsonSerializer.Serialize(textSendData) }, + new Send { Id = Guid.NewGuid(), Type = SendType.File, Data = JsonSerializer.Serialize(fileSendData) } + }; + _sendOwnerQuery.GetOwned().Returns(sends); + + var result = await _sut.Get(); + + Assert.NotNull(result); + Assert.IsType>(result); + Assert.Equal(2, result.Data.Count()); + var sendResponseModels = result.Data.ToList(); + Assert.Equal(sends[0].Id, sendResponseModels[0].Id); + Assert.Equal(sends[1].Id, sendResponseModels[1].Id); + await _sendOwnerQuery.Received(1).GetOwned(); + } + + [Fact] + public async Task GetAllOwned_WhenNoSends_ReturnsEmptyListResponseModel() + { + _sendOwnerQuery.GetOwned().Returns(new List()); + + var result = await _sut.Get(); + + Assert.NotNull(result); + Assert.IsType>(result); + Assert.Empty(result.Data); + await _sendOwnerQuery.Received(1).GetOwned(); + } } diff --git a/test/Core.Test/Tools/Services/SendOwnerQueryTests.cs b/test/Core.Test/Tools/Services/SendOwnerQueryTests.cs new file mode 100644 index 000000000000..2d02562b22e7 --- /dev/null +++ b/test/Core.Test/Tools/Services/SendOwnerQueryTests.cs @@ -0,0 +1,165 @@ +using Bit.Core.Context; +using Bit.Core.Exceptions; +using Bit.Core.Services; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Repositories; +using Bit.Core.Tools.SendFeatures.Queries; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Tools.Services; + +public class SendOwnerQueryTests +{ + private readonly ISendRepository _sendRepository; + private readonly IFeatureService _featureService; + private readonly ICurrentContext _currentContext; + private readonly SendOwnerQuery _sendOwnerQuery; + private readonly Guid _currentUserId = Guid.NewGuid(); + + public SendOwnerQueryTests() + { + _sendRepository = Substitute.For(); + _featureService = Substitute.For(); + _currentContext = Substitute.For(); + _currentContext.UserId.Returns(_currentUserId); + _sendOwnerQuery = new SendOwnerQuery(_sendRepository, _featureService, _currentContext); + } + + [Fact] + public async Task Get_WithValidSendOwnedByUser_ReturnsExpectedSend() + { + // Arrange + var sendId = Guid.NewGuid(); + var expectedSend = CreateSend(sendId, _currentUserId); + _sendRepository.GetByIdAsync(sendId).Returns(expectedSend); + + // Act + var result = await _sendOwnerQuery.Get(sendId); + + // Assert + Assert.Same(expectedSend, result); + await _sendRepository.Received(1).GetByIdAsync(sendId); + } + + [Fact] + public async Task Get_WithNonExistentSend_ThrowsNotFoundException() + { + // Arrange + var sendId = Guid.NewGuid(); + _sendRepository.GetByIdAsync(sendId).Returns((Send?)null); + + // Act & Assert + await Assert.ThrowsAsync(() => _sendOwnerQuery.Get(sendId)); + } + + [Fact] + public async Task Get_WithSendOwnedByDifferentUser_ThrowsNotFoundException() + { + // Arrange + var sendId = Guid.NewGuid(); + var differentUserId = Guid.NewGuid(); + var send = CreateSend(sendId, differentUserId); + _sendRepository.GetByIdAsync(sendId).Returns(send); + + // Act & Assert + await Assert.ThrowsAsync(() => _sendOwnerQuery.Get(sendId)); + } + + [Fact] + public async Task Get_WithNullCurrentUserId_ThrowsBadRequestException() + { + // Arrange + var sendId = Guid.NewGuid(); + var send = CreateSend(sendId, _currentUserId); + _sendRepository.GetByIdAsync(sendId).Returns(send); + _currentContext.UserId.Returns((Guid?)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _sendOwnerQuery.Get(sendId)); + Assert.Equal("Invalid user.", exception.Message); + } + + [Fact] + public async Task GetOwned_WithFeatureFlagEnabled_ReturnsAllSends() + { + // Arrange + var sends = new List + { + CreateSend(Guid.NewGuid(), _currentUserId, emails: null), + CreateSend(Guid.NewGuid(), _currentUserId, emails: "test@example.com"), + CreateSend(Guid.NewGuid(), _currentUserId, emails: "other@example.com") + }; + _sendRepository.GetManyByUserIdAsync(_currentUserId).Returns(sends); + _featureService.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends).Returns(true); + + // Act + var result = await _sendOwnerQuery.GetOwned(); + + // Assert + Assert.Equal(3, result.Count); + Assert.Contains(sends[0], result); + Assert.Contains(sends[1], result); + Assert.Contains(sends[2], result); + await _sendRepository.Received(1).GetManyByUserIdAsync(_currentUserId); + _featureService.Received(1).IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends); + } + + [Fact] + public async Task GetOwned_WithFeatureFlagDisabled_FiltersOutEmailOtpSends() + { + // Arrange + var sendWithoutEmails = CreateSend(Guid.NewGuid(), _currentUserId, emails: null); + var sendWithEmails = CreateSend(Guid.NewGuid(), _currentUserId, emails: "test@example.com"); + var sends = new List { sendWithoutEmails, sendWithEmails }; + _sendRepository.GetManyByUserIdAsync(_currentUserId).Returns(sends); + _featureService.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends).Returns(false); + + // Act + var result = await _sendOwnerQuery.GetOwned(); + + // Assert + Assert.Single(result); + Assert.Contains(sendWithoutEmails, result); + Assert.DoesNotContain(sendWithEmails, result); + await _sendRepository.Received(1).GetManyByUserIdAsync(_currentUserId); + _featureService.Received(1).IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends); + } + + [Fact] + public async Task GetOwned_WithNullCurrentUserId_ThrowsBadRequestException() + { + // Arrange + _currentContext.UserId.Returns((Guid?)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _sendOwnerQuery.GetOwned()); + Assert.Equal("Invalid user.", exception.Message); + } + + [Fact] + public async Task GetOwned_WithEmptyCollection_ReturnsEmptyCollection() + { + // Arrange + var emptySends = new List(); + _sendRepository.GetManyByUserIdAsync(_currentUserId).Returns(emptySends); + _featureService.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends).Returns(true); + + // Act + var result = await _sendOwnerQuery.GetOwned(); + + // Assert + Assert.Empty(result); + await _sendRepository.Received(1).GetManyByUserIdAsync(_currentUserId); + } + + private static Send CreateSend(Guid id, Guid userId, string? emails = null) + { + return new Send + { + Id = id, + UserId = userId, + Emails = emails + }; + } +}