From bc9493480818885cf24486753c64f9dca1605377 Mon Sep 17 00:00:00 2001
From: John Harrington <84741727+harr1424@users.noreply.github.com>
Date: Tue, 10 Feb 2026 07:57:43 -0700
Subject: [PATCH] [PM-31787] Users can access the sends after the limit was
reached (#6958)
* fix file type send increment behavior
* fix text send access increment behavior
* fix & update tests
* cleanup unused service
* fix broken test constructor expecting unused service
---
src/Api/Tools/Controllers/SendsController.cs | 30 +-
.../Interfaces/INonAnonymousSendCommand.cs | 40 ++-
.../Commands/NonAnonymousSendCommand.cs | 18 +-
.../Tools/Controllers/SendsControllerTests.cs | 154 +++++++-
.../Services/NonAnonymousSendCommandTests.cs | 329 +++++++++++++++++-
5 files changed, 547 insertions(+), 24 deletions(-)
diff --git a/src/Api/Tools/Controllers/SendsController.cs b/src/Api/Tools/Controllers/SendsController.cs
index c04eae0904..af7fe8f12b 100644
--- a/src/Api/Tools/Controllers/SendsController.cs
+++ b/src/Api/Tools/Controllers/SendsController.cs
@@ -240,6 +240,11 @@ public class SendsController : Controller
throw new BadRequestException("Could not locate send");
}
+ if (!INonAnonymousSendCommand.SendCanBeAccessed(send))
+ {
+ throw new NotFoundException();
+ }
+
var sendResponse = new SendAccessResponseModel(send);
if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault())
{
@@ -247,9 +252,19 @@ public class SendsController : Controller
sendResponse.CreatorIdentifier = creator.Email;
}
- send.AccessCount++;
- await _sendRepository.ReplaceAsync(send);
- await _pushNotificationService.PushSyncSendUpdateAsync(send);
+ /*
+ * AccessCount is incremented differently for File and Text Send types:
+ * - Text Sends are incremented at every access
+ * - File Sends are incremented only when the file is downloaded
+ *
+ * Note that this endpoint is initially called for all Send types
+ */
+ if (send.Type == SendType.Text)
+ {
+ send.AccessCount++;
+ await _sendRepository.ReplaceAsync(send);
+ await _pushNotificationService.PushSyncSendUpdateAsync(send);
+ }
return new ObjectResult(sendResponse);
}
@@ -267,11 +282,12 @@ public class SendsController : Controller
throw new BadRequestException("Could not locate send");
}
- var url = await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId);
+ var (url, result) = await _nonAnonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId);
- send.AccessCount++;
- await _sendRepository.ReplaceAsync(send);
- await _pushNotificationService.PushSyncSendUpdateAsync(send);
+ if (result.Equals(SendAccessResult.Denied))
+ {
+ throw new NotFoundException();
+ }
return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url });
}
diff --git a/src/Core/Tools/SendFeatures/Commands/Interfaces/INonAnonymousSendCommand.cs b/src/Core/Tools/SendFeatures/Commands/Interfaces/INonAnonymousSendCommand.cs
index 58693e619c..5ecf056268 100644
--- a/src/Core/Tools/SendFeatures/Commands/Interfaces/INonAnonymousSendCommand.cs
+++ b/src/Core/Tools/SendFeatures/Commands/Interfaces/INonAnonymousSendCommand.cs
@@ -47,7 +47,45 @@ public interface INonAnonymousSendCommand
/// 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.
+ /// an abundance of caution.
///
Task ConfirmFileSize(Send send);
+
+ ///
+ /// If a File type Send can be downloaded, retrieves the download URL.
+ ///
+ /// The this command acts upon
+ /// The fileId to be downloaded
+ ///
+ /// A tuple wrapping the download URL string and indicating whether access was granted
+ ///
+ ///
+ /// This method is intended for authenticated endpoints where authentication has already been validated.
+ /// Returns when the Send is disabled, MaxAccessCount has been reached,
+ /// expiration date has passed, or deletion date has been reached.
+ ///
+ Task<(string, SendAccessResult)> GetSendFileDownloadUrlAsync(Send send, string fileId);
+
+ ///
+ /// Determines whether a can be accessed based on its current state.
+ ///
+ /// The to evaluate for access
+ /// if the Send can be accessed, otherwise
+ ///
+ /// This method checks if the Send is disabled, if MaxAccessCount has been reached,
+ /// if the expiration date has passed, or if the deletion date has been reached.
+ ///
+ static bool SendCanBeAccessed(Send send)
+ {
+ var now = DateTime.UtcNow;
+ if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
+ send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < now ||
+ send.Disabled ||
+ send.DeletionDate < now)
+ {
+ return false;
+ }
+
+ return true;
+ }
}
diff --git a/src/Core/Tools/SendFeatures/Commands/NonAnonymousSendCommand.cs b/src/Core/Tools/SendFeatures/Commands/NonAnonymousSendCommand.cs
index 9655d155e6..21ca1ca3fb 100644
--- a/src/Core/Tools/SendFeatures/Commands/NonAnonymousSendCommand.cs
+++ b/src/Core/Tools/SendFeatures/Commands/NonAnonymousSendCommand.cs
@@ -27,7 +27,6 @@ public class NonAnonymousSendCommand : INonAnonymousSendCommand
public NonAnonymousSendCommand(ISendRepository sendRepository,
ISendFileStorageService sendFileStorageService,
IPushNotificationService pushNotificationService,
- ISendAuthorizationService sendAuthorizationService,
ISendValidationService sendValidationService,
ISendCoreHelperService sendCoreHelperService,
ILogger logger)
@@ -181,4 +180,21 @@ public class NonAnonymousSendCommand : INonAnonymousSendCommand
return valid;
}
+ public async Task<(string, SendAccessResult)> GetSendFileDownloadUrlAsync(Send send, string fileId)
+ {
+ if (send.Type != SendType.File)
+ {
+ throw new BadRequestException("Can only get a download URL for a file type of Send");
+ }
+
+ if (!INonAnonymousSendCommand.SendCanBeAccessed(send))
+ {
+ return (null, SendAccessResult.Denied);
+ }
+
+ send.AccessCount++;
+ await _sendRepository.ReplaceAsync(send);
+ await _pushNotificationService.PushSyncSendUpdateAsync(send);
+ return (await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId), SendAccessResult.Granted);
+ }
}
diff --git a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs
index 9322948037..3d77ac2343 100644
--- a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs
+++ b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs
@@ -903,6 +903,106 @@ public class SendsControllerTests : IDisposable
Assert.Equal(creator.Email, response.CreatorIdentifier);
}
+ [Theory, AutoData]
+ public async Task AccessUsingAuth_WithDisabledSend_ThrowsNotFoundException(Guid sendId)
+ {
+ var send = new Send
+ {
+ Id = sendId,
+ Type = SendType.Text,
+ Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
+ DeletionDate = DateTime.UtcNow.AddDays(7),
+ ExpirationDate = null,
+ Disabled = true,
+ AccessCount = 0,
+ MaxAccessCount = null
+ };
+ var user = CreateUserWithSendIdClaim(sendId);
+ _sut.ControllerContext = CreateControllerContextWithUser(user);
+ _sendRepository.GetByIdAsync(sendId).Returns(send);
+
+ await Assert.ThrowsAsync(() => _sut.AccessUsingAuth());
+
+ await _sendRepository.Received(1).GetByIdAsync(sendId);
+ await _userService.DidNotReceive().GetUserByIdAsync(Arg.Any());
+ await _sendRepository.DidNotReceive().ReplaceAsync(Arg.Any());
+ }
+
+ [Theory, AutoData]
+ public async Task AccessUsingAuth_WithMaxAccessCountReached_ThrowsNotFoundException(Guid sendId)
+ {
+ var send = new Send
+ {
+ Id = sendId,
+ Type = SendType.Text,
+ Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
+ DeletionDate = DateTime.UtcNow.AddDays(7),
+ ExpirationDate = null,
+ Disabled = false,
+ AccessCount = 10,
+ MaxAccessCount = 10
+ };
+ var user = CreateUserWithSendIdClaim(sendId);
+ _sut.ControllerContext = CreateControllerContextWithUser(user);
+ _sendRepository.GetByIdAsync(sendId).Returns(send);
+
+ await Assert.ThrowsAsync(() => _sut.AccessUsingAuth());
+
+ await _sendRepository.Received(1).GetByIdAsync(sendId);
+ await _userService.DidNotReceive().GetUserByIdAsync(Arg.Any());
+ await _sendRepository.DidNotReceive().ReplaceAsync(Arg.Any());
+ }
+
+ [Theory, AutoData]
+ public async Task AccessUsingAuth_WithExpiredSend_ThrowsNotFoundException(Guid sendId)
+ {
+ var send = new Send
+ {
+ Id = sendId,
+ Type = SendType.Text,
+ Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
+ DeletionDate = DateTime.UtcNow.AddDays(7),
+ ExpirationDate = DateTime.UtcNow.AddDays(-1), // Expired yesterday
+ Disabled = false,
+ AccessCount = 0,
+ MaxAccessCount = null
+ };
+ var user = CreateUserWithSendIdClaim(sendId);
+ _sut.ControllerContext = CreateControllerContextWithUser(user);
+ _sendRepository.GetByIdAsync(sendId).Returns(send);
+
+ await Assert.ThrowsAsync(() => _sut.AccessUsingAuth());
+
+ await _sendRepository.Received(1).GetByIdAsync(sendId);
+ await _userService.DidNotReceive().GetUserByIdAsync(Arg.Any());
+ await _sendRepository.DidNotReceive().ReplaceAsync(Arg.Any());
+ }
+
+ [Theory, AutoData]
+ public async Task AccessUsingAuth_WithDeletionDatePassed_ThrowsNotFoundException(Guid sendId)
+ {
+ var send = new Send
+ {
+ Id = sendId,
+ Type = SendType.Text,
+ Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
+ DeletionDate = DateTime.UtcNow.AddDays(-1), // Deletion date has passed
+ ExpirationDate = null,
+ Disabled = false,
+ AccessCount = 0,
+ MaxAccessCount = null
+ };
+ var user = CreateUserWithSendIdClaim(sendId);
+ _sut.ControllerContext = CreateControllerContextWithUser(user);
+ _sendRepository.GetByIdAsync(sendId).Returns(send);
+
+ await Assert.ThrowsAsync(() => _sut.AccessUsingAuth());
+
+ await _sendRepository.Received(1).GetByIdAsync(sendId);
+ await _userService.DidNotReceive().GetUserByIdAsync(Arg.Any());
+ await _sendRepository.DidNotReceive().ReplaceAsync(Arg.Any());
+ }
+
[Theory, AutoData]
public async Task GetSendFileDownloadDataUsingAuth_WithValidFileId_ReturnsDownloadUrl(
Guid sendId, string fileId, string expectedUrl)
@@ -922,7 +1022,8 @@ public class SendsControllerTests : IDisposable
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
- _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId).Returns(expectedUrl);
+ _nonAnonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId)
+ .Returns((expectedUrl, SendAccessResult.Granted));
var result = await _sut.GetSendFileDownloadDataUsingAuth(fileId);
@@ -932,7 +1033,7 @@ public class SendsControllerTests : IDisposable
Assert.Equal(fileId, response.Id);
Assert.Equal(expectedUrl, response.Url);
await _sendRepository.Received(1).GetByIdAsync(sendId);
- await _sendFileStorageService.Received(1).GetSendFileDownloadUrlAsync(send, fileId);
+ await _nonAnonymousSendCommand.Received(1).GetSendFileDownloadUrlAsync(send, fileId);
}
[Theory, AutoData]
@@ -948,13 +1049,13 @@ public class SendsControllerTests : IDisposable
Assert.Equal("Could not locate send", exception.Message);
await _sendRepository.Received(1).GetByIdAsync(sendId);
- await _sendFileStorageService.DidNotReceive()
+ await _nonAnonymousSendCommand.DidNotReceive()
.GetSendFileDownloadUrlAsync(Arg.Any(), Arg.Any());
}
[Theory, AutoData]
- public async Task GetSendFileDownloadDataUsingAuth_WithTextSend_StillReturnsResponse(
- Guid sendId, string fileId, string expectedUrl)
+ public async Task GetSendFileDownloadDataUsingAuth_WithTextSend_ThrowsBadRequestException(
+ Guid sendId, string fileId)
{
var send = new Send
{
@@ -970,15 +1071,44 @@ public class SendsControllerTests : IDisposable
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
- _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId).Returns(expectedUrl);
+ _nonAnonymousSendCommand
+ .When(x => x.GetSendFileDownloadUrlAsync(send, fileId))
+ .Do(x => throw new BadRequestException("Can only get a download URL for a file type of Send"));
- var result = await _sut.GetSendFileDownloadDataUsingAuth(fileId);
+ var exception = await Assert.ThrowsAsync(
+ () => _sut.GetSendFileDownloadDataUsingAuth(fileId));
- Assert.NotNull(result);
- var objectResult = Assert.IsType(result);
- var response = Assert.IsType(objectResult.Value);
- Assert.Equal(fileId, response.Id);
- Assert.Equal(expectedUrl, response.Url);
+ Assert.Equal("Can only get a download URL for a file type of Send", exception.Message);
+ await _sendRepository.Received(1).GetByIdAsync(sendId);
+ await _nonAnonymousSendCommand.Received(1).GetSendFileDownloadUrlAsync(send, fileId);
+ }
+
+ [Theory, AutoData]
+ public async Task GetSendFileDownloadDataUsingAuth_WithAccessDenied_ThrowsNotFoundException(
+ Guid sendId, string fileId)
+ {
+ var fileData = new SendFileData("Test File", "Notes", "document.pdf") { Id = fileId, Size = 2048 };
+ var send = new Send
+ {
+ Id = sendId,
+ Type = SendType.File,
+ Data = JsonSerializer.Serialize(fileData),
+ DeletionDate = DateTime.UtcNow.AddDays(7),
+ ExpirationDate = null,
+ Disabled = false,
+ AccessCount = 0,
+ MaxAccessCount = null
+ };
+ var user = CreateUserWithSendIdClaim(sendId);
+ _sut.ControllerContext = CreateControllerContextWithUser(user);
+ _sendRepository.GetByIdAsync(sendId).Returns(send);
+ _nonAnonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId)
+ .Returns((null, SendAccessResult.Denied));
+
+ await Assert.ThrowsAsync(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
+
+ await _sendRepository.Received(1).GetByIdAsync(sendId);
+ await _nonAnonymousSendCommand.Received(1).GetSendFileDownloadUrlAsync(send, fileId);
}
diff --git a/test/Core.Test/Tools/Services/NonAnonymousSendCommandTests.cs b/test/Core.Test/Tools/Services/NonAnonymousSendCommandTests.cs
index 1ad6a08516..9bebe5560c 100644
--- a/test/Core.Test/Tools/Services/NonAnonymousSendCommandTests.cs
+++ b/test/Core.Test/Tools/Services/NonAnonymousSendCommandTests.cs
@@ -11,6 +11,7 @@ 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.SendFeatures.Commands.Interfaces;
using Bit.Core.Tools.Services;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Logging;
@@ -28,7 +29,6 @@ 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;
@@ -42,7 +42,6 @@ public class NonAnonymousSendCommandTests
_sendRepository = Substitute.For();
_sendFileStorageService = Substitute.For();
_pushNotificationService = Substitute.For();
- _sendAuthorizationService = Substitute.For();
_featureService = Substitute.For();
_sendValidationService = Substitute.For();
_currentContext = Substitute.For();
@@ -53,7 +52,6 @@ public class NonAnonymousSendCommandTests
_sendRepository,
_sendFileStorageService,
_pushNotificationService,
- _sendAuthorizationService,
_sendValidationService,
_sendCoreHelperService,
_logger
@@ -1093,4 +1091,329 @@ public class NonAnonymousSendCommandTests
Assert.Equal("File received does not match expected file length.", exception.Message);
}
+
+ [Fact]
+ public async Task GetSendFileDownloadUrlAsync_WithTextSend_ThrowsBadRequest()
+ {
+ // Arrange
+ var send = new Send
+ {
+ Id = Guid.NewGuid(),
+ Type = SendType.Text,
+ UserId = Guid.NewGuid()
+ };
+ var fileId = "somefile123";
+
+ // Act & Assert
+ var exception = await Assert.ThrowsAsync(() =>
+ _nonAnonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId));
+
+ Assert.Equal("Can only get a download URL for a file type of Send", exception.Message);
+
+ // Verify no storage service methods were called
+ await _sendFileStorageService.DidNotReceive()
+ .GetSendFileDownloadUrlAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task GetSendFileDownloadUrlAsync_WithDisabledSend_ReturnsDenied()
+ {
+ // Arrange
+ var fileId = "file123";
+ var send = new Send
+ {
+ Id = Guid.NewGuid(),
+ Type = SendType.File,
+ UserId = Guid.NewGuid(),
+ Disabled = true,
+ DeletionDate = DateTime.UtcNow.AddDays(7),
+ ExpirationDate = null,
+ AccessCount = 0,
+ MaxAccessCount = null
+ };
+
+ // Act
+ var (url, result) = await _nonAnonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId);
+
+ // Assert
+ Assert.Null(url);
+ Assert.Equal(SendAccessResult.Denied, result);
+
+ // Verify no repository updates occurred
+ await _sendRepository.DidNotReceive().ReplaceAsync(Arg.Any());
+ await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any());
+ await _sendFileStorageService.DidNotReceive()
+ .GetSendFileDownloadUrlAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task GetSendFileDownloadUrlAsync_WithMaxAccessCountReached_ReturnsDenied()
+ {
+ // Arrange
+ var fileId = "file123";
+ var send = new Send
+ {
+ Id = Guid.NewGuid(),
+ Type = SendType.File,
+ UserId = Guid.NewGuid(),
+ Disabled = false,
+ DeletionDate = DateTime.UtcNow.AddDays(7),
+ ExpirationDate = null,
+ AccessCount = 5,
+ MaxAccessCount = 5
+ };
+
+ // Act
+ var (url, result) = await _nonAnonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId);
+
+ // Assert
+ Assert.Null(url);
+ Assert.Equal(SendAccessResult.Denied, result);
+
+ // Verify no repository updates occurred
+ await _sendRepository.DidNotReceive().ReplaceAsync(Arg.Any());
+ await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any());
+ await _sendFileStorageService.DidNotReceive()
+ .GetSendFileDownloadUrlAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task GetSendFileDownloadUrlAsync_WithExpiredSend_ReturnsDenied()
+ {
+ // Arrange
+ var fileId = "file123";
+ var send = new Send
+ {
+ Id = Guid.NewGuid(),
+ Type = SendType.File,
+ UserId = Guid.NewGuid(),
+ Disabled = false,
+ DeletionDate = DateTime.UtcNow.AddDays(7),
+ ExpirationDate = DateTime.UtcNow.AddDays(-1), // Expired yesterday
+ AccessCount = 0,
+ MaxAccessCount = null
+ };
+
+ // Act
+ var (url, result) = await _nonAnonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId);
+
+ // Assert
+ Assert.Null(url);
+ Assert.Equal(SendAccessResult.Denied, result);
+
+ // Verify no repository updates occurred
+ await _sendRepository.DidNotReceive().ReplaceAsync(Arg.Any());
+ await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any());
+ await _sendFileStorageService.DidNotReceive()
+ .GetSendFileDownloadUrlAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task GetSendFileDownloadUrlAsync_WithDeletionDatePassed_ReturnsDenied()
+ {
+ // Arrange
+ var fileId = "file123";
+ var send = new Send
+ {
+ Id = Guid.NewGuid(),
+ Type = SendType.File,
+ UserId = Guid.NewGuid(),
+ Disabled = false,
+ DeletionDate = DateTime.UtcNow.AddDays(-1), // Deletion date has passed
+ ExpirationDate = null,
+ AccessCount = 0,
+ MaxAccessCount = null
+ };
+
+ // Act
+ var (url, result) = await _nonAnonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId);
+
+ // Assert
+ Assert.Null(url);
+ Assert.Equal(SendAccessResult.Denied, result);
+
+ // Verify no repository updates occurred
+ await _sendRepository.DidNotReceive().ReplaceAsync(Arg.Any());
+ await _pushNotificationService.DidNotReceive().PushSyncSendUpdateAsync(Arg.Any());
+ await _sendFileStorageService.DidNotReceive()
+ .GetSendFileDownloadUrlAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Fact]
+ public async Task GetSendFileDownloadUrlAsync_WithValidSend_ReturnsUrlAndIncrementsAccessCount()
+ {
+ // Arrange
+ var fileId = "file123";
+ var expectedUrl = "https://download.example.com/file123";
+ var send = new Send
+ {
+ Id = Guid.NewGuid(),
+ Type = SendType.File,
+ UserId = Guid.NewGuid(),
+ Disabled = false,
+ DeletionDate = DateTime.UtcNow.AddDays(7),
+ ExpirationDate = null,
+ AccessCount = 3,
+ MaxAccessCount = 10
+ };
+
+ _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId).Returns(expectedUrl);
+
+ // Act
+ var (url, result) = await _nonAnonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId);
+
+ // Assert
+ Assert.Equal(expectedUrl, url);
+ Assert.Equal(SendAccessResult.Granted, result);
+
+ // Verify access count was incremented
+ Assert.Equal(4, send.AccessCount);
+
+ // Verify repository was updated
+ await _sendRepository.Received(1).ReplaceAsync(send);
+ await _pushNotificationService.Received(1).PushSyncSendUpdateAsync(send);
+
+ // Verify file storage service was called
+ await _sendFileStorageService.Received(1).GetSendFileDownloadUrlAsync(send, fileId);
+ }
+
+ [Fact]
+ public void SendCanBeAccessed_WithDisabledSend_ReturnsFalse()
+ {
+ // Arrange
+ var send = new Send
+ {
+ Disabled = true,
+ DeletionDate = DateTime.UtcNow.AddDays(7),
+ ExpirationDate = null,
+ AccessCount = 0,
+ MaxAccessCount = null
+ };
+
+ // Act
+ var result = INonAnonymousSendCommand.SendCanBeAccessed(send);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void SendCanBeAccessed_WithMaxAccessCountReached_ReturnsFalse()
+ {
+ // Arrange
+ var send = new Send
+ {
+ Disabled = false,
+ DeletionDate = DateTime.UtcNow.AddDays(7),
+ ExpirationDate = null,
+ AccessCount = 10,
+ MaxAccessCount = 10
+ };
+
+ // Act
+ var result = INonAnonymousSendCommand.SendCanBeAccessed(send);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void SendCanBeAccessed_WithExpiredSend_ReturnsFalse()
+ {
+ // Arrange
+ var send = new Send
+ {
+ Disabled = false,
+ DeletionDate = DateTime.UtcNow.AddDays(7),
+ ExpirationDate = DateTime.UtcNow.AddDays(-1),
+ AccessCount = 0,
+ MaxAccessCount = null
+ };
+
+ // Act
+ var result = INonAnonymousSendCommand.SendCanBeAccessed(send);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void SendCanBeAccessed_WithDeletionDatePassed_ReturnsFalse()
+ {
+ // Arrange
+ var send = new Send
+ {
+ Disabled = false,
+ DeletionDate = DateTime.UtcNow.AddDays(-1),
+ ExpirationDate = null,
+ AccessCount = 0,
+ MaxAccessCount = null
+ };
+
+ // Act
+ var result = INonAnonymousSendCommand.SendCanBeAccessed(send);
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Fact]
+ public void SendCanBeAccessed_WithValidSend_ReturnsTrue()
+ {
+ // Arrange
+ var send = new Send
+ {
+ Disabled = false,
+ DeletionDate = DateTime.UtcNow.AddDays(7),
+ ExpirationDate = DateTime.UtcNow.AddDays(7),
+ AccessCount = 5,
+ MaxAccessCount = 10
+ };
+
+ // Act
+ var result = INonAnonymousSendCommand.SendCanBeAccessed(send);
+
+ // Assert
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void SendCanBeAccessed_WithNullMaxAccessCount_ReturnsTrue()
+ {
+ // Arrange
+ var send = new Send
+ {
+ Disabled = false,
+ DeletionDate = DateTime.UtcNow.AddDays(7),
+ ExpirationDate = null,
+ AccessCount = 100,
+ MaxAccessCount = null
+ };
+
+ // Act
+ var result = INonAnonymousSendCommand.SendCanBeAccessed(send);
+
+ // Assert
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void SendCanBeAccessed_WithNullExpirationDate_ReturnsTrue()
+ {
+ // Arrange
+ var send = new Send
+ {
+ Disabled = false,
+ DeletionDate = DateTime.UtcNow.AddDays(7),
+ ExpirationDate = null,
+ AccessCount = 0,
+ MaxAccessCount = 10
+ };
+
+ // Act
+ var result = INonAnonymousSendCommand.SendCanBeAccessed(send);
+
+ // Assert
+ Assert.True(result);
+ }
}