diff --git a/src/Api/KeyManagement/Validators/SendRotationValidator.cs b/src/Api/KeyManagement/Validators/SendRotationValidator.cs index 10a5d996b7..a781ab99ee 100644 --- a/src/Api/KeyManagement/Validators/SendRotationValidator.cs +++ b/src/Api/KeyManagement/Validators/SendRotationValidator.cs @@ -44,7 +44,7 @@ public class SendRotationValidator : IRotationValidator public SendType Type { get; set; } + /// + /// Specifies the authentication method required to access this Send. + /// + public AuthType? AuthType { get; set; } + /// /// Estimated length of the file accompanying the send. when /// is . @@ -125,7 +130,7 @@ public class SendRequestModel Type = Type, UserId = (Guid?)userId }; - ToSend(send, sendAuthorizationService); + send = UpdateSend(send, sendAuthorizationService); return send; } @@ -155,8 +160,7 @@ public class SendRequestModel /// The send to update /// Hashes the send password. /// The send object - // FIXME: rename to `UpdateSend` - public Send ToSend(Send existingSend, ISendAuthorizationService sendAuthorizationService) + public Send UpdateSend(Send existingSend, ISendAuthorizationService sendAuthorizationService) { existingSend = ToSendBase(existingSend, sendAuthorizationService); switch (existingSend.Type) @@ -249,11 +253,29 @@ public class SendRequestModel var emails = Emails.Split(',', RemoveEmptyEntries | TrimEntries); existingSend.Emails = string.Join(", ", emails); existingSend.Password = null; + existingSend.AuthType = Core.Tools.Enums.AuthType.Email; } else if (!string.IsNullOrWhiteSpace(Password)) { existingSend.Password = authorizationService.HashPassword(Password); existingSend.Emails = null; + existingSend.AuthType = Core.Tools.Enums.AuthType.Password; + } + else + { + // Neither Password nor Emails provided - preserve existing values and infer AuthType + if (!string.IsNullOrWhiteSpace(existingSend.Password)) + { + existingSend.AuthType = Core.Tools.Enums.AuthType.Password; + } + else if (!string.IsNullOrWhiteSpace(existingSend.Emails)) + { + existingSend.AuthType = Core.Tools.Enums.AuthType.Email; + } + else + { + existingSend.AuthType = Core.Tools.Enums.AuthType.None; + } } existingSend.Disabled = Disabled.GetValueOrDefault(); diff --git a/src/Api/Tools/Models/Response/SendAccessResponseModel.cs b/src/Api/Tools/Models/Response/SendAccessResponseModel.cs index a466740d55..b722dd5fff 100644 --- a/src/Api/Tools/Models/Response/SendAccessResponseModel.cs +++ b/src/Api/Tools/Models/Response/SendAccessResponseModel.cs @@ -3,7 +3,6 @@ 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; @@ -20,17 +19,13 @@ public class SendAccessResponseModel : ResponseModel /// Instantiates a send access response model /// /// Content to transmit to the client. - /// - /// Settings that control response generation. - /// /// /// Thrown when is /// /// /// Thrown when has an invalid . /// - // FIXME: remove `globalSettings` variable - public SendAccessResponseModel(Send send, GlobalSettings globalSettings) + public SendAccessResponseModel(Send send) : base("send-access") { if (send == null) @@ -40,6 +35,7 @@ public class SendAccessResponseModel : ResponseModel Id = CoreHelpers.Base64UrlEncode(send.Id.ToByteArray()); Type = send.Type; + AuthType = send.AuthType; SendData sendData; switch (send.Type) @@ -72,6 +68,11 @@ public class SendAccessResponseModel : ResponseModel /// public SendType Type { get; set; } + /// + /// Specifies the authentication method required to access this Send. + /// + public AuthType? AuthType { get; set; } + /// /// Label for the send. This is only visible to the owner of the send. /// diff --git a/src/Api/Tools/Models/Response/SendResponseModel.cs b/src/Api/Tools/Models/Response/SendResponseModel.cs index c8169252e5..569b8c13ce 100644 --- a/src/Api/Tools/Models/Response/SendResponseModel.cs +++ b/src/Api/Tools/Models/Response/SendResponseModel.cs @@ -37,6 +37,9 @@ public class SendResponseModel : ResponseModel Id = send.Id; AccessId = CoreHelpers.Base64UrlEncode(send.Id.ToByteArray()); Type = send.Type; + AuthType = send.AuthType ?? (!string.IsNullOrWhiteSpace(send.Password) + ? AuthType = Core.Tools.Enums.AuthType.Password + : (!string.IsNullOrWhiteSpace(send.Emails)? Core.Tools.Enums.AuthType.Email : Core.Tools.Enums.AuthType.None)); Key = send.Key; MaxAccessCount = send.MaxAccessCount; AccessCount = send.AccessCount; @@ -84,6 +87,11 @@ public class SendResponseModel : ResponseModel /// public SendType Type { get; set; } + /// + /// Specifies the authentication method required to access this Send. + /// + public AuthType? AuthType { get; set; } + /// /// Label for the send. /// diff --git a/src/Core/Tools/Entities/Send.cs b/src/Core/Tools/Entities/Send.cs index 34b68fd6c5..2daa2a6b30 100644 --- a/src/Core/Tools/Entities/Send.cs +++ b/src/Core/Tools/Entities/Send.cs @@ -37,6 +37,12 @@ public class Send : ITableObject /// public SendType Type { get; set; } + /// + /// Specifies the authentication method required to access this Send. + /// + /// + public AuthType? AuthType { get; set; } + /// /// Stores data containing or pointing to the transmitted secret. JSON. /// diff --git a/src/Core/Tools/Enums/AuthType.cs b/src/Core/Tools/Enums/AuthType.cs new file mode 100644 index 0000000000..814ebf69b8 --- /dev/null +++ b/src/Core/Tools/Enums/AuthType.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace Bit.Core.Tools.Enums; + +/// +/// Specifies the authentication method required to access a Send. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AuthType : byte +{ + /// + /// Email-based OTP authentication + /// + Email = 0, + + /// + /// Password-based authentication + /// + Password = 1, + + /// + /// No authentication required + /// + None = 2 +} diff --git a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs index 885f7c1c7b..541e8a4903 100644 --- a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs +++ b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs @@ -3,6 +3,7 @@ using System.Text.Json; using AutoFixture.Xunit2; using Bit.Api.Models.Response; using Bit.Api.Tools.Controllers; +using Bit.Api.Tools.Models; using Bit.Api.Tools.Models.Request; using Bit.Api.Tools.Models.Response; using Bit.Core.Entities; @@ -173,4 +174,583 @@ public class SendsControllerTests : IDisposable Assert.Empty(result.Data); await _sendOwnerQuery.Received(1).GetOwned(Arg.Any()); } + + [Theory, AutoData] + public async Task Post_WithPassword_InfersAuthTypePassword(Guid userId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var request = new SendRequestModel + { + Type = SendType.Text, + Key = "key", + Text = new SendTextModel { Text = "text" }, + Password = "password", + DeletionDate = DateTime.UtcNow.AddDays(7) + }; + + var result = await _sut.Post(request); + + Assert.NotNull(result); + Assert.Equal(AuthType.Password, result.AuthType); + await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => + s.AuthType == AuthType.Password && + s.Password != null && + s.Emails == null && + s.UserId == userId && + s.Type == SendType.Text)); + _userService.Received(1).GetProperUserId(Arg.Any()); + } + + [Theory, AutoData] + public async Task Post_WithEmails_InfersAuthTypeEmail(Guid userId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var request = new SendRequestModel + { + Type = SendType.Text, + Key = "key", + Text = new SendTextModel { Text = "text" }, + Emails = "test@example.com", + DeletionDate = DateTime.UtcNow.AddDays(7) + }; + + var result = await _sut.Post(request); + + Assert.NotNull(result); + Assert.Equal(AuthType.Email, result.AuthType); + await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => + s.AuthType == AuthType.Email && + s.Emails != null && + s.Password == null && + s.UserId == userId && + s.Type == SendType.Text)); + _userService.Received(1).GetProperUserId(Arg.Any()); + } + + [Theory, AutoData] + public async Task Post_WithoutPasswordOrEmails_InfersAuthTypeNone(Guid userId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var request = new SendRequestModel + { + Type = SendType.Text, + Key = "key", + Text = new SendTextModel { Text = "text" }, + DeletionDate = DateTime.UtcNow.AddDays(7) + }; + + var result = await _sut.Post(request); + + Assert.NotNull(result); + Assert.Equal(AuthType.None, result.AuthType); + await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => + s.AuthType == AuthType.None && + s.Password == null && + s.Emails == null && + s.UserId == userId && + s.Type == SendType.Text)); + _userService.Received(1).GetProperUserId(Arg.Any()); + } + + [Theory] + [InlineData(AuthType.Password)] + [InlineData(AuthType.Email)] + [InlineData(AuthType.None)] + public async Task Access_ReturnsCorrectAuthType(AuthType authType) + { + var sendId = Guid.NewGuid(); + var accessId = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); + var send = new Send + { + Id = sendId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new Dictionary()), + AuthType = authType + }; + + _sendRepository.GetByIdAsync(sendId).Returns(send); + _sendAuthorizationService.AccessAsync(send, "pwd123").Returns(SendAccessResult.Granted); + + var request = new SendAccessRequestModel(); + var actionResult = await _sut.Access(accessId, request); + var response = (actionResult as ObjectResult)?.Value as SendAccessResponseModel; + + Assert.NotNull(response); + Assert.Equal(authType, response.AuthType); + } + + [Theory] + [InlineData(AuthType.Password)] + [InlineData(AuthType.Email)] + [InlineData(AuthType.None)] + public async Task Get_ReturnsCorrectAuthType(AuthType authType) + { + var sendId = Guid.NewGuid(); + var send = new Send + { + Id = sendId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("a", "b", "c", false)), + AuthType = authType + }; + + _sendOwnerQuery.Get(sendId, Arg.Any()).Returns(send); + + var result = await _sut.Get(sendId.ToString()); + + Assert.NotNull(result); + Assert.Equal(authType, result.AuthType); + } + + [Theory, AutoData] + public async Task Put_WithValidSend_UpdatesSuccessfully(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var existingSend = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false)), + AuthType = AuthType.None + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + var request = new SendRequestModel + { + Type = SendType.Text, + Key = "updated-key", + Text = new SendTextModel { Text = "updated text" }, + Password = "new-password", + DeletionDate = DateTime.UtcNow.AddDays(7) + }; + + var result = await _sut.Put(sendId.ToString(), request); + + Assert.NotNull(result); + Assert.Equal(sendId, result.Id); + await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => s.Id == sendId)); + } + + [Theory, AutoData] + public async Task Put_WithNonExistentSend_ThrowsNotFoundException(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + _sendRepository.GetByIdAsync(sendId).Returns((Send)null); + + var request = new SendRequestModel + { + Type = SendType.Text, + Key = "key", + Text = new SendTextModel { Text = "text" }, + DeletionDate = DateTime.UtcNow.AddDays(7) + }; + + await Assert.ThrowsAsync(() => _sut.Put(sendId.ToString(), request)); + } + + [Theory, AutoData] + public async Task Put_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var existingSend = new Send + { + Id = sendId, + UserId = otherUserId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false)) + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + var request = new SendRequestModel + { + Type = SendType.Text, + Key = "key", + Text = new SendTextModel { Text = "text" }, + DeletionDate = DateTime.UtcNow.AddDays(7) + }; + + await Assert.ThrowsAsync(() => _sut.Put(sendId.ToString(), request)); + } + + [Theory, AutoData] + public async Task PutRemovePassword_WithValidSend_RemovesPasswordAndSetsAuthTypeNone(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var existingSend = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + Password = "hashed-password", + AuthType = AuthType.Password + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + var result = await _sut.PutRemovePassword(sendId.ToString()); + + Assert.NotNull(result); + Assert.Equal(sendId, result.Id); + Assert.Equal(AuthType.None, result.AuthType); + await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => + s.Id == sendId && + s.Password == null && + s.AuthType == AuthType.None)); + } + + [Theory, AutoData] + public async Task PutRemovePassword_WithNonExistentSend_ThrowsNotFoundException(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + _sendRepository.GetByIdAsync(sendId).Returns((Send)null); + + await Assert.ThrowsAsync(() => _sut.PutRemovePassword(sendId.ToString())); + } + + [Theory, AutoData] + public async Task PutRemovePassword_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var existingSend = new Send + { + Id = sendId, + UserId = otherUserId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)), + Password = "hashed-password" + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + await Assert.ThrowsAsync(() => _sut.PutRemovePassword(sendId.ToString())); + } + + [Theory, AutoData] + public async Task Delete_WithValidSend_DeletesSuccessfully(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var existingSend = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)) + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + await _sut.Delete(sendId.ToString()); + + await _nonAnonymousSendCommand.Received(1).DeleteSendAsync(Arg.Is(s => s.Id == sendId)); + } + + [Theory, AutoData] + public async Task Delete_WithNonExistentSend_ThrowsNotFoundException(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + _sendRepository.GetByIdAsync(sendId).Returns((Send)null); + + await Assert.ThrowsAsync(() => _sut.Delete(sendId.ToString())); + } + + [Theory, AutoData] + public async Task Delete_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var existingSend = new Send + { + Id = sendId, + UserId = otherUserId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)) + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + await Assert.ThrowsAsync(() => _sut.Delete(sendId.ToString())); + } + + [Theory, AutoData] + public async Task PostFile_WithPassword_InfersAuthTypePassword(Guid userId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + _nonAnonymousSendCommand.SaveFileSendAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://example.com/upload") + .AndDoes(callInfo => + { + var send = callInfo.ArgAt(0); + var data = callInfo.ArgAt(1); + send.Data = JsonSerializer.Serialize(data); + }); + + var request = new SendRequestModel + { + Type = SendType.File, + Key = "key", + File = new SendFileModel { FileName = "test.txt" }, + FileLength = 1024L, + Password = "password", + DeletionDate = DateTime.UtcNow.AddDays(7) + }; + + var result = await _sut.PostFile(request); + + Assert.NotNull(result); + Assert.NotNull(result.SendResponse); + Assert.Equal(AuthType.Password, result.SendResponse.AuthType); + await _nonAnonymousSendCommand.Received(1).SaveFileSendAsync( + Arg.Is(s => + s.AuthType == AuthType.Password && + s.Password != null && + s.Emails == null && + s.UserId == userId), + Arg.Any(), + 1024L); + } + + [Theory, AutoData] + public async Task PostFile_WithEmails_InfersAuthTypeEmail(Guid userId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + _nonAnonymousSendCommand.SaveFileSendAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://example.com/upload") + .AndDoes(callInfo => + { + var send = callInfo.ArgAt(0); + var data = callInfo.ArgAt(1); + send.Data = JsonSerializer.Serialize(data); + }); + + var request = new SendRequestModel + { + Type = SendType.File, + Key = "key", + File = new SendFileModel { FileName = "test.txt" }, + FileLength = 1024L, + Emails = "test@example.com", + DeletionDate = DateTime.UtcNow.AddDays(7) + }; + + var result = await _sut.PostFile(request); + + Assert.NotNull(result); + Assert.NotNull(result.SendResponse); + Assert.Equal(AuthType.Email, result.SendResponse.AuthType); + await _nonAnonymousSendCommand.Received(1).SaveFileSendAsync( + Arg.Is(s => + s.AuthType == AuthType.Email && + s.Emails != null && + s.Password == null && + s.UserId == userId), + Arg.Any(), + 1024L); + } + + [Theory, AutoData] + public async Task PostFile_WithoutPasswordOrEmails_InfersAuthTypeNone(Guid userId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + _nonAnonymousSendCommand.SaveFileSendAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://example.com/upload") + .AndDoes(callInfo => + { + var send = callInfo.ArgAt(0); + var data = callInfo.ArgAt(1); + send.Data = JsonSerializer.Serialize(data); + }); + + var request = new SendRequestModel + { + Type = SendType.File, + Key = "key", + File = new SendFileModel { FileName = "test.txt" }, + FileLength = 1024L, + DeletionDate = DateTime.UtcNow.AddDays(7) + }; + + var result = await _sut.PostFile(request); + + Assert.NotNull(result); + Assert.NotNull(result.SendResponse); + Assert.Equal(AuthType.None, result.SendResponse.AuthType); + await _nonAnonymousSendCommand.Received(1).SaveFileSendAsync( + Arg.Is(s => + s.AuthType == AuthType.None && + s.Password == null && + s.Emails == null && + s.UserId == userId), + Arg.Any(), + 1024L); + } + + [Theory, AutoData] + public async Task Put_ChangingFromPasswordToEmails_UpdatesAuthTypeToEmail(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var existingSend = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false)), + Password = "hashed-password", + AuthType = AuthType.Password + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + var request = new SendRequestModel + { + Type = SendType.Text, + Key = "updated-key", + Text = new SendTextModel { Text = "updated text" }, + Emails = "new@example.com", + DeletionDate = DateTime.UtcNow.AddDays(7) + }; + + var result = await _sut.Put(sendId.ToString(), request); + + Assert.NotNull(result); + Assert.Equal(sendId, result.Id); + await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => + s.Id == sendId && + s.AuthType == AuthType.Email && + s.Emails != null && + s.Password == null)); + } + + [Theory, AutoData] + public async Task Put_ChangingFromEmailToPassword_UpdatesAuthTypeToPassword(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var existingSend = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false)), + Emails = "old@example.com", + AuthType = AuthType.Email + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + var request = new SendRequestModel + { + Type = SendType.Text, + Key = "updated-key", + Text = new SendTextModel { Text = "updated text" }, + Password = "new-password", + DeletionDate = DateTime.UtcNow.AddDays(7) + }; + + var result = await _sut.Put(sendId.ToString(), request); + + Assert.NotNull(result); + Assert.Equal(sendId, result.Id); + await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => + s.Id == sendId && + s.AuthType == AuthType.Password && + s.Password != null && + s.Emails == null)); + } + + [Theory, AutoData] + public async Task Put_WithoutPasswordOrEmails_PreservesExistingPassword(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var existingSend = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false)), + Password = "hashed-password", + AuthType = AuthType.Password + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + var request = new SendRequestModel + { + Type = SendType.Text, + Key = "updated-key", + Text = new SendTextModel { Text = "updated text" }, + DeletionDate = DateTime.UtcNow.AddDays(7) + }; + + var result = await _sut.Put(sendId.ToString(), request); + + Assert.NotNull(result); + Assert.Equal(sendId, result.Id); + await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => + s.Id == sendId && + s.AuthType == AuthType.Password && + s.Password == "hashed-password" && + s.Emails == null)); + } + + [Theory, AutoData] + public async Task Put_WithoutPasswordOrEmails_PreservesExistingEmails(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var existingSend = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false)), + Emails = "test@example.com", + AuthType = AuthType.Email + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + var request = new SendRequestModel + { + Type = SendType.Text, + Key = "updated-key", + Text = new SendTextModel { Text = "updated text" }, + DeletionDate = DateTime.UtcNow.AddDays(7) + }; + + var result = await _sut.Put(sendId.ToString(), request); + + Assert.NotNull(result); + Assert.Equal(sendId, result.Id); + await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => + s.Id == sendId && + s.AuthType == AuthType.Email && + s.Emails == "test@example.com" && + s.Password == null)); + } + + [Theory, AutoData] + public async Task Put_WithoutPasswordOrEmails_PreservesNoneAuthType(Guid userId, Guid sendId) + { + _userService.GetProperUserId(Arg.Any()).Returns(userId); + var existingSend = new Send + { + Id = sendId, + UserId = userId, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData("Old", "Old notes", "Old text", false)), + Password = null, + Emails = null, + AuthType = AuthType.None + }; + _sendRepository.GetByIdAsync(sendId).Returns(existingSend); + + var request = new SendRequestModel + { + Type = SendType.Text, + Key = "updated-key", + Text = new SendTextModel { Text = "updated text" }, + DeletionDate = DateTime.UtcNow.AddDays(7) + }; + + var result = await _sut.Put(sendId.ToString(), request); + + Assert.NotNull(result); + Assert.Equal(sendId, result.Id); + await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s => + s.Id == sendId && + s.AuthType == AuthType.None && + s.Password == null && + s.Emails == null)); + } }