diff --git a/Directory.Build.props b/Directory.Build.props
index 9438ef3351..e7a8422605 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -3,7 +3,7 @@
net8.0
- 2025.12.2
+ 2026.1.0
Bit.$(MSBuildProjectName)
enable
@@ -30,4 +30,4 @@
4.18.1
-
+
\ No newline at end of file
diff --git a/src/Api/Tools/Controllers/SendsController.cs b/src/Api/Tools/Controllers/SendsController.cs
index 449d9573fd..f9f71d076d 100644
--- a/src/Api/Tools/Controllers/SendsController.cs
+++ b/src/Api/Tools/Controllers/SendsController.cs
@@ -5,9 +5,11 @@ using Bit.Api.Tools.Models.Request;
using Bit.Api.Tools.Models.Response;
using Bit.Api.Utilities;
using Bit.Core;
+using Bit.Core.Auth.Identity;
+using Bit.Core.Auth.UserFeatures.SendAccess;
using Bit.Core.Exceptions;
+using Bit.Core.Platform.Push;
using Bit.Core.Services;
-using Bit.Core.Settings;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Repositories;
@@ -22,7 +24,6 @@ using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Tools.Controllers;
[Route("sends")]
-[Authorize("Application")]
public class SendsController : Controller
{
private readonly ISendRepository _sendRepository;
@@ -31,11 +32,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;
+ private readonly IFeatureService _featureService;
+ private readonly IPushNotificationService _pushNotificationService;
public SendsController(
ISendRepository sendRepository,
@@ -46,7 +46,8 @@ public class SendsController : Controller
ISendOwnerQuery sendOwnerQuery,
ISendFileStorageService sendFileStorageService,
ILogger logger,
- GlobalSettings globalSettings)
+ IFeatureService featureService,
+ IPushNotificationService pushNotificationService)
{
_sendRepository = sendRepository;
_userService = userService;
@@ -56,10 +57,12 @@ public class SendsController : Controller
_sendOwnerQuery = sendOwnerQuery;
_sendFileStorageService = sendFileStorageService;
_logger = logger;
- _globalSettings = globalSettings;
+ _featureService = featureService;
+ _pushNotificationService = pushNotificationService;
}
#region Anonymous endpoints
+
[AllowAnonymous]
[HttpPost("access/{id}")]
public async Task Access(string id, [FromBody] SendAccessRequestModel model)
@@ -73,21 +76,32 @@ public class SendsController : Controller
var guid = new Guid(CoreHelpers.Base64UrlDecode(id));
var send = await _sendRepository.GetByIdAsync(guid);
+
if (send == null)
{
throw new BadRequestException("Could not locate send");
}
+
+ /* This guard can be removed once feature flag is retired*/
+ var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP);
+ if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null)
+ {
+ return new UnauthorizedResult();
+ }
+
var sendAuthResult =
await _sendAuthorizationService.AccessAsync(send, model.Password);
if (sendAuthResult.Equals(SendAccessResult.PasswordRequired))
{
return new UnauthorizedResult();
}
+
if (sendAuthResult.Equals(SendAccessResult.PasswordInvalid))
{
await Task.Delay(2000);
throw new BadRequestException("Invalid password.");
}
+
if (sendAuthResult.Equals(SendAccessResult.Denied))
{
throw new NotFoundException();
@@ -99,6 +113,7 @@ public class SendsController : Controller
var creator = await _userService.GetUserByIdAsync(send.UserId.Value);
sendResponse.CreatorIdentifier = creator.Email;
}
+
return new ObjectResult(sendResponse);
}
@@ -122,6 +137,13 @@ public class SendsController : Controller
throw new BadRequestException("Could not locate send");
}
+ /* This guard can be removed once feature flag is retired*/
+ var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP);
+ if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null)
+ {
+ return new UnauthorizedResult();
+ }
+
var (url, result) = await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId,
model.Password);
@@ -129,21 +151,19 @@ public class SendsController : Controller
{
return new UnauthorizedResult();
}
+
if (result.Equals(SendAccessResult.PasswordInvalid))
{
await Task.Delay(2000);
throw new BadRequestException("Invalid password.");
}
+
if (result.Equals(SendAccessResult.Denied))
{
throw new NotFoundException();
}
- return new ObjectResult(new SendFileDownloadDataResponseModel()
- {
- Id = fileId,
- Url = url,
- });
+ return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url, });
}
[AllowAnonymous]
@@ -157,7 +177,8 @@ public class SendsController : Controller
{
try
{
- var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1];
+ 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)
@@ -166,6 +187,7 @@ public class SendsController : Controller
{
await azureSendFileStorageService.DeleteBlobAsync(blobName);
}
+
return;
}
@@ -173,7 +195,8 @@ public class SendsController : Controller
}
catch (Exception e)
{
- _logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}", JsonSerializer.Serialize(eventGridEvent));
+ _logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}",
+ JsonSerializer.Serialize(eventGridEvent));
return;
}
}
@@ -185,6 +208,7 @@ public class SendsController : Controller
#region Non-anonymous endpoints
+ [Authorize(Policies.Application)]
[HttpGet("{id}")]
public async Task Get(string id)
{
@@ -193,6 +217,7 @@ public class SendsController : Controller
return new SendResponseModel(send);
}
+ [Authorize(Policies.Application)]
[HttpGet("")]
public async Task> GetAll()
{
@@ -203,6 +228,67 @@ public class SendsController : Controller
return result;
}
+ [Authorize(Policy = Policies.Send)]
+ // [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */
+ [HttpPost("access/")]
+ public async Task AccessUsingAuth()
+ {
+ var guid = User.GetSendId();
+ var send = await _sendRepository.GetByIdAsync(guid);
+ if (send == null)
+ {
+ throw new BadRequestException("Could not locate send");
+ }
+ if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
+ send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
+ send.DeletionDate < DateTime.UtcNow)
+ {
+ throw new NotFoundException();
+ }
+
+ var sendResponse = new SendAccessResponseModel(send);
+ if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault())
+ {
+ var creator = await _userService.GetUserByIdAsync(send.UserId.Value);
+ sendResponse.CreatorIdentifier = creator.Email;
+ }
+
+ send.AccessCount++;
+ await _sendRepository.ReplaceAsync(send);
+ await _pushNotificationService.PushSyncSendUpdateAsync(send);
+
+ return new ObjectResult(sendResponse);
+ }
+
+ [Authorize(Policy = Policies.Send)]
+ // [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */
+ [HttpPost("access/file/{fileId}")]
+ public async Task GetSendFileDownloadDataUsingAuth(string fileId)
+ {
+ var sendId = User.GetSendId();
+ var send = await _sendRepository.GetByIdAsync(sendId);
+
+ if (send == null)
+ {
+ throw new BadRequestException("Could not locate send");
+ }
+ if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
+ send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
+ send.DeletionDate < DateTime.UtcNow)
+ {
+ throw new NotFoundException();
+ }
+
+ var url = await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId);
+
+ send.AccessCount++;
+ await _sendRepository.ReplaceAsync(send);
+ await _pushNotificationService.PushSyncSendUpdateAsync(send);
+
+ return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url });
+ }
+
+ [Authorize(Policies.Application)]
[HttpPost("")]
public async Task Post([FromBody] SendRequestModel model)
{
@@ -213,6 +299,7 @@ public class SendsController : Controller
return new SendResponseModel(send);
}
+ [Authorize(Policies.Application)]
[HttpPost("file/v2")]
public async Task PostFile([FromBody] SendRequestModel model)
{
@@ -243,6 +330,7 @@ public class SendsController : Controller
};
}
+ [Authorize(Policies.Application)]
[HttpGet("{id}/file/{fileId}")]
public async Task RenewFileUpload(string id, string fileId)
{
@@ -267,6 +355,7 @@ public class SendsController : Controller
};
}
+ [Authorize(Policies.Application)]
[HttpPost("{id}/file/{fileId}")]
[SelfHosted(SelfHostedOnly = true)]
[RequestSizeLimit(Constants.FileSize501mb)]
@@ -283,12 +372,14 @@ public class SendsController : Controller
{
throw new BadRequestException("Could not locate send");
}
+
await Request.GetFileAsync(async (stream) =>
{
await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send);
});
}
+ [Authorize(Policies.Application)]
[HttpPut("{id}")]
public async Task Put(string id, [FromBody] SendRequestModel model)
{
@@ -304,6 +395,7 @@ public class SendsController : Controller
return new SendResponseModel(send);
}
+ [Authorize(Policies.Application)]
[HttpPut("{id}/remove-password")]
public async Task PutRemovePassword(string id)
{
@@ -322,6 +414,28 @@ public class SendsController : Controller
return new SendResponseModel(send);
}
+ // Removes ALL authentication (email or password) if any is present
+ [Authorize(Policies.Application)]
+ [HttpPut("{id}/remove-auth")]
+ public async Task PutRemoveAuth(string id)
+ {
+ var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
+ var send = await _sendRepository.GetByIdAsync(new Guid(id));
+ if (send == null || send.UserId != userId)
+ {
+ throw new NotFoundException();
+ }
+
+ // This endpoint exists because PUT preserves existing Password/Emails when not provided.
+ // This allows clients to update other fields without re-submitting sensitive auth data.
+ send.Password = null;
+ send.Emails = null;
+ send.AuthType = AuthType.None;
+ await _nonAnonymousSendCommand.SaveSendAsync(send);
+ return new SendResponseModel(send);
+ }
+
+ [Authorize(Policies.Application)]
[HttpDelete("{id}")]
public async Task Delete(string id)
{
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs
index 86c94147f4..213d18c27d 100644
--- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs
+++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs
@@ -74,8 +74,12 @@ public class AutomaticUserConfirmationPolicyEventHandler(
private async Task ValidateUserComplianceWithSingleOrgAsync(Guid organizationId,
ICollection organizationUsers)
{
- var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(
- organizationUsers.Select(ou => ou.UserId!.Value)))
+ var userIds = organizationUsers.Where(
+ u => u.UserId is not null &&
+ u.Status != OrganizationUserStatusType.Invited)
+ .Select(u => u.UserId!.Value);
+
+ var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(userIds))
.Any(uo => uo.OrganizationId != organizationId
&& uo.Status != OrganizationUserStatusType.Invited);
diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs
index d52c79c1ee..764406ee56 100644
--- a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs
+++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs
@@ -361,7 +361,8 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
var invoice = await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions
{
- AutoAdvance = false
+ AutoAdvance = false,
+ Expand = ["customer"]
});
await braintreeService.PayInvoice(new UserId(userId), invoice);
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index 47e7eb40bd..7cf00621c1 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -142,8 +142,7 @@ public static class FeatureFlagKeys
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache";
public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration";
public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud";
- public const string BulkRevokeUsersV2 = "pm-28456-bulk-revoke-users-v2";
- public const string PremiumAccessQuery = "pm-21411-premium-access-query";
+ public const string PremiumAccessQuery = "pm-29495-refactor-premium-interface";
/* Architecture */
public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1";
@@ -230,23 +229,12 @@ public static class FeatureFlagKeys
/// 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";
public const string UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators";
public const string UseChromiumImporter = "pm-23982-chromium-importer";
public const string ChromiumImporterWithABE = "pm-25855-chromium-importer-abe";
public const string SendUIRefresh = "pm-28175-send-ui-refresh";
public const string SendEmailOTP = "pm-19051-send-email-verification";
- ///
- /// 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 CipherKeyEncryption = "cipher-key-encryption";
public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk";
diff --git a/src/Core/Tools/Enums/AuthType.cs b/src/Core/Tools/Enums/AuthType.cs
index 814ebf69b8..4a31275b7a 100644
--- a/src/Core/Tools/Enums/AuthType.cs
+++ b/src/Core/Tools/Enums/AuthType.cs
@@ -1,11 +1,8 @@
-using System.Text.Json.Serialization;
-
-namespace Bit.Core.Tools.Enums;
+namespace Bit.Core.Tools.Enums;
///
/// Specifies the authentication method required to access a Send.
///
-[JsonConverter(typeof(JsonStringEnumConverter))]
public enum AuthType : byte
{
///
diff --git a/src/Core/Tools/SendFeatures/Queries/SendAuthenticationQuery.cs b/src/Core/Tools/SendFeatures/Queries/SendAuthenticationQuery.cs
index fed7c9e8d4..97c2e64dc5 100644
--- a/src/Core/Tools/SendFeatures/Queries/SendAuthenticationQuery.cs
+++ b/src/Core/Tools/SendFeatures/Queries/SendAuthenticationQuery.cs
@@ -1,4 +1,5 @@
-using Bit.Core.Tools.Models.Data;
+using Bit.Core.Tools.Enums;
+using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Repositories;
using Bit.Core.Tools.SendFeatures.Queries.Interfaces;
@@ -37,8 +38,8 @@ public class SendAuthenticationQuery : ISendAuthenticationQuery
{
null => NEVER_AUTHENTICATE,
var s when s.AccessCount >= s.MaxAccessCount => NEVER_AUTHENTICATE,
- var s when s.Emails is not null => emailOtp(s.Emails),
- var s when s.Password is not null => new ResourcePassword(s.Password),
+ var s when s.AuthType == AuthType.Email && s.Emails is not null => emailOtp(s.Emails),
+ var s when s.AuthType == AuthType.Password && s.Password is not null => new ResourcePassword(s.Password),
_ => NOT_AUTHENTICATED
};
diff --git a/src/Core/Tools/SendFeatures/Queries/SendOwnerQuery.cs b/src/Core/Tools/SendFeatures/Queries/SendOwnerQuery.cs
index cb539429a5..29bd8f56f9 100644
--- a/src/Core/Tools/SendFeatures/Queries/SendOwnerQuery.cs
+++ b/src/Core/Tools/SendFeatures/Queries/SendOwnerQuery.cs
@@ -12,7 +12,6 @@ namespace Bit.Core.Tools.SendFeatures.Queries;
public class SendOwnerQuery : ISendOwnerQuery
{
private readonly ISendRepository _repository;
- private readonly IFeatureService _features;
private readonly IUserService _users;
///
@@ -24,10 +23,9 @@ public class SendOwnerQuery : ISendOwnerQuery
///
/// Thrown when is .
///
- public SendOwnerQuery(ISendRepository sendRepository, IFeatureService features, IUserService users)
+ public SendOwnerQuery(ISendRepository sendRepository, IUserService users)
{
_repository = sendRepository;
- _features = features ?? throw new ArgumentNullException(nameof(features));
_users = users ?? throw new ArgumentNullException(nameof(users));
}
@@ -51,16 +49,6 @@ public class SendOwnerQuery : ISendOwnerQuery
var userId = _users.GetProperUserId(user) ?? throw new BadRequestException("invalid user.");
var sends = await _repository.GetManyByUserIdAsync(userId);
- 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 541e8a4903..e3a9ba4435 100644
--- a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs
+++ b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs
@@ -8,8 +8,8 @@ using Bit.Api.Tools.Models.Request;
using Bit.Api.Tools.Models.Response;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
+using Bit.Core.Platform.Push;
using Bit.Core.Services;
-using Bit.Core.Settings;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Data;
@@ -28,7 +28,6 @@ namespace Bit.Api.Test.Tools.Controllers;
public class SendsControllerTests : IDisposable
{
private readonly SendsController _sut;
- private readonly GlobalSettings _globalSettings;
private readonly IUserService _userService;
private readonly ISendRepository _sendRepository;
private readonly INonAnonymousSendCommand _nonAnonymousSendCommand;
@@ -37,6 +36,8 @@ public class SendsControllerTests : IDisposable
private readonly ISendAuthorizationService _sendAuthorizationService;
private readonly ISendFileStorageService _sendFileStorageService;
private readonly ILogger _logger;
+ private readonly IFeatureService _featureService;
+ private readonly IPushNotificationService _pushNotificationService;
public SendsControllerTests()
{
@@ -47,8 +48,9 @@ public class SendsControllerTests : IDisposable
_sendOwnerQuery = Substitute.For();
_sendAuthorizationService = Substitute.For();
_sendFileStorageService = Substitute.For();
- _globalSettings = new GlobalSettings();
_logger = Substitute.For>();
+ _featureService = Substitute.For();
+ _pushNotificationService = Substitute.For();
_sut = new SendsController(
_sendRepository,
@@ -59,7 +61,8 @@ public class SendsControllerTests : IDisposable
_sendOwnerQuery,
_sendFileStorageService,
_logger,
- _globalSettings
+ _featureService,
+ _pushNotificationService
);
}
@@ -96,8 +99,8 @@ public class SendsControllerTests : IDisposable
{
var now = DateTime.UtcNow;
var expected = "You cannot have a Send with a deletion date that far " +
- "into the future. Adjust the Deletion Date to a value less than 31 days from now " +
- "and try again.";
+ "into the future. Adjust the Deletion Date to a value less than 31 days from now " +
+ "and try again.";
var request = new SendRequestModel() { DeletionDate = now.AddDays(32) };
var exception = await Assert.ThrowsAsync(() => _sut.Post(request));
@@ -109,9 +112,10 @@ public class SendsControllerTests : IDisposable
{
var now = DateTime.UtcNow;
var expected = "You cannot have a Send with a deletion date that far " +
- "into the future. Adjust the Deletion Date to a value less than 31 days from now " +
- "and try again.";
- var request = new SendRequestModel() { Type = SendType.File, FileLength = 1024L, DeletionDate = now.AddDays(32) };
+ "into the future. Adjust the Deletion Date to a value less than 31 days from now " +
+ "and try again.";
+ var request =
+ new SendRequestModel() { Type = SendType.File, FileLength = 1024L, DeletionDate = now.AddDays(32) };
var exception = await Assert.ThrowsAsync(() => _sut.PostFile(request));
Assert.Equal(expected, exception.Message);
@@ -409,7 +413,8 @@ public class SendsControllerTests : IDisposable
}
[Theory, AutoData]
- public async Task PutRemovePassword_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId, Guid sendId)
+ public async Task PutRemovePassword_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId,
+ Guid sendId)
{
_userService.GetProperUserId(Arg.Any()).Returns(userId);
var existingSend = new Send
@@ -753,4 +758,683 @@ public class SendsControllerTests : IDisposable
s.Password == null &&
s.Emails == null));
}
+
+ #region Authenticated Access Endpoints
+
+ [Theory, AutoData]
+ public async Task AccessUsingAuth_WithValidSend_ReturnsSendAccessResponse(Guid sendId, User creator)
+ {
+ var send = new Send
+ {
+ Id = sendId,
+ UserId = creator.Id,
+ Type = SendType.Text,
+ Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
+ HideEmail = false,
+ 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);
+ _userService.GetUserByIdAsync(creator.Id).Returns(creator);
+
+ var result = await _sut.AccessUsingAuth();
+
+ Assert.NotNull(result);
+ var objectResult = Assert.IsType(result);
+ var response = Assert.IsType(objectResult.Value);
+ Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id);
+ Assert.Equal(creator.Email, response.CreatorIdentifier);
+ await _sendRepository.Received(1).GetByIdAsync(sendId);
+ await _userService.Received(1).GetUserByIdAsync(creator.Id);
+ }
+
+ [Theory, AutoData]
+ public async Task AccessUsingAuth_WithHideEmail_DoesNotIncludeCreatorIdentifier(Guid sendId, User creator)
+ {
+ var send = new Send
+ {
+ Id = sendId,
+ UserId = creator.Id,
+ Type = SendType.Text,
+ Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
+ HideEmail = true,
+ 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);
+
+ var result = await _sut.AccessUsingAuth();
+
+ Assert.NotNull(result);
+ var objectResult = Assert.IsType(result);
+ var response = Assert.IsType(objectResult.Value);
+ Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id);
+ Assert.Null(response.CreatorIdentifier);
+ await _sendRepository.Received(1).GetByIdAsync(sendId);
+ await _userService.DidNotReceive().GetUserByIdAsync(Arg.Any());
+ }
+
+ [Theory, AutoData]
+ public async Task AccessUsingAuth_WithNoUserId_DoesNotIncludeCreatorIdentifier(Guid sendId)
+ {
+ var send = new Send
+ {
+ Id = sendId,
+ UserId = null,
+ Type = SendType.Text,
+ Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
+ HideEmail = false,
+ 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);
+
+ var result = await _sut.AccessUsingAuth();
+
+ Assert.NotNull(result);
+ var objectResult = Assert.IsType(result);
+ var response = Assert.IsType(objectResult.Value);
+ Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id);
+ Assert.Null(response.CreatorIdentifier);
+ await _sendRepository.Received(1).GetByIdAsync(sendId);
+ await _userService.DidNotReceive().GetUserByIdAsync(Arg.Any());
+ }
+
+ [Theory, AutoData]
+ public async Task AccessUsingAuth_WithNonExistentSend_ThrowsBadRequestException(Guid sendId)
+ {
+ var user = CreateUserWithSendIdClaim(sendId);
+ _sut.ControllerContext = CreateControllerContextWithUser(user);
+ _sendRepository.GetByIdAsync(sendId).Returns((Send)null);
+
+ var exception =
+ await Assert.ThrowsAsync(() => _sut.AccessUsingAuth());
+
+ Assert.Equal("Could not locate send", exception.Message);
+ await _sendRepository.Received(1).GetByIdAsync(sendId);
+ }
+
+ [Theory, AutoData]
+ public async Task AccessUsingAuth_WithFileSend_ReturnsCorrectResponse(Guid sendId, User creator)
+ {
+ var fileData = new SendFileData("Test File", "Notes", "document.pdf") { Id = "file-123", Size = 2048 };
+ var send = new Send
+ {
+ Id = sendId,
+ UserId = creator.Id,
+ Type = SendType.File,
+ Data = JsonSerializer.Serialize(fileData),
+ HideEmail = false,
+ 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);
+ _userService.GetUserByIdAsync(creator.Id).Returns(creator);
+
+ var result = await _sut.AccessUsingAuth();
+
+ Assert.NotNull(result);
+ var objectResult = Assert.IsType(result);
+ var response = Assert.IsType(objectResult.Value);
+ Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id);
+ Assert.Equal(SendType.File, response.Type);
+ Assert.NotNull(response.File);
+ Assert.Equal("file-123", response.File.Id);
+ Assert.Equal(creator.Email, response.CreatorIdentifier);
+ }
+
+ [Theory, AutoData]
+ public async Task GetSendFileDownloadDataUsingAuth_WithValidFileId_ReturnsDownloadUrl(
+ Guid sendId, string fileId, string expectedUrl)
+ {
+ 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);
+ _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId).Returns(expectedUrl);
+
+ var result = await _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);
+ await _sendRepository.Received(1).GetByIdAsync(sendId);
+ await _sendFileStorageService.Received(1).GetSendFileDownloadUrlAsync(send, fileId);
+ }
+
+ [Theory, AutoData]
+ public async Task GetSendFileDownloadDataUsingAuth_WithNonExistentSend_ThrowsBadRequestException(
+ Guid sendId, string fileId)
+ {
+ var user = CreateUserWithSendIdClaim(sendId);
+ _sut.ControllerContext = CreateControllerContextWithUser(user);
+ _sendRepository.GetByIdAsync(sendId).Returns((Send)null);
+
+ var exception =
+ await Assert.ThrowsAsync(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
+
+ Assert.Equal("Could not locate send", exception.Message);
+ await _sendRepository.Received(1).GetByIdAsync(sendId);
+ await _sendFileStorageService.DidNotReceive()
+ .GetSendFileDownloadUrlAsync(Arg.Any(), Arg.Any());
+ }
+
+ [Theory, AutoData]
+ public async Task GetSendFileDownloadDataUsingAuth_WithTextSend_StillReturnsResponse(
+ Guid sendId, string fileId, string expectedUrl)
+ {
+ 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 = 0,
+ MaxAccessCount = null
+ };
+ var user = CreateUserWithSendIdClaim(sendId);
+ _sut.ControllerContext = CreateControllerContextWithUser(user);
+ _sendRepository.GetByIdAsync(sendId).Returns(send);
+ _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId).Returns(expectedUrl);
+
+ var result = await _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);
+ }
+
+ #region AccessUsingAuth Validation Tests
+
+ [Theory, AutoData]
+ public async Task AccessUsingAuth_WithExpiredSend_ThrowsNotFoundException(Guid sendId)
+ {
+ var send = new Send
+ {
+ Id = sendId,
+ UserId = Guid.NewGuid(),
+ 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);
+ }
+
+ [Theory, AutoData]
+ public async Task AccessUsingAuth_WithDeletedSend_ThrowsNotFoundException(Guid sendId)
+ {
+ var send = new Send
+ {
+ Id = sendId,
+ UserId = Guid.NewGuid(),
+ Type = SendType.Text,
+ Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
+ DeletionDate = DateTime.UtcNow.AddDays(-1), // Should have been deleted yesterday
+ 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);
+ }
+
+ [Theory, AutoData]
+ public async Task AccessUsingAuth_WithDisabledSend_ThrowsNotFoundException(Guid sendId)
+ {
+ var send = new Send
+ {
+ Id = sendId,
+ UserId = Guid.NewGuid(),
+ Type = SendType.Text,
+ Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
+ DeletionDate = DateTime.UtcNow.AddDays(7),
+ ExpirationDate = null,
+ Disabled = true, // Disabled
+ 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);
+ }
+
+ [Theory, AutoData]
+ public async Task AccessUsingAuth_WithAccessCountExceeded_ThrowsNotFoundException(Guid sendId)
+ {
+ var send = new Send
+ {
+ Id = sendId,
+ UserId = Guid.NewGuid(),
+ Type = SendType.Text,
+ Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
+ DeletionDate = DateTime.UtcNow.AddDays(7),
+ ExpirationDate = null,
+ Disabled = false,
+ AccessCount = 5,
+ MaxAccessCount = 5 // Limit reached
+ };
+ var user = CreateUserWithSendIdClaim(sendId);
+ _sut.ControllerContext = CreateControllerContextWithUser(user);
+ _sendRepository.GetByIdAsync(sendId).Returns(send);
+
+ await Assert.ThrowsAsync(() => _sut.AccessUsingAuth());
+
+ await _sendRepository.Received(1).GetByIdAsync(sendId);
+ }
+
+ #endregion
+
+ #region GetSendFileDownloadDataUsingAuth Validation Tests
+
+ [Theory, AutoData]
+ public async Task GetSendFileDownloadDataUsingAuth_WithExpiredSend_ThrowsNotFoundException(
+ Guid sendId, string fileId)
+ {
+ var send = new Send
+ {
+ Id = sendId,
+ Type = SendType.File,
+ Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
+ DeletionDate = DateTime.UtcNow.AddDays(7),
+ ExpirationDate = DateTime.UtcNow.AddDays(-1), // Expired
+ Disabled = false,
+ AccessCount = 0,
+ MaxAccessCount = null
+ };
+ var user = CreateUserWithSendIdClaim(sendId);
+ _sut.ControllerContext = CreateControllerContextWithUser(user);
+ _sendRepository.GetByIdAsync(sendId).Returns(send);
+
+ await Assert.ThrowsAsync(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
+
+ await _sendRepository.Received(1).GetByIdAsync(sendId);
+ }
+
+ [Theory, AutoData]
+ public async Task GetSendFileDownloadDataUsingAuth_WithDeletedSend_ThrowsNotFoundException(
+ Guid sendId, string fileId)
+ {
+ var send = new Send
+ {
+ Id = sendId,
+ Type = SendType.File,
+ Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
+ DeletionDate = DateTime.UtcNow.AddDays(-1), // Deleted
+ 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.GetSendFileDownloadDataUsingAuth(fileId));
+
+ await _sendRepository.Received(1).GetByIdAsync(sendId);
+ }
+
+ [Theory, AutoData]
+ public async Task GetSendFileDownloadDataUsingAuth_WithDisabledSend_ThrowsNotFoundException(
+ Guid sendId, string fileId)
+ {
+ var send = new Send
+ {
+ Id = sendId,
+ Type = SendType.File,
+ Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
+ DeletionDate = DateTime.UtcNow.AddDays(7),
+ ExpirationDate = null,
+ Disabled = true, // Disabled
+ AccessCount = 0,
+ MaxAccessCount = null
+ };
+ var user = CreateUserWithSendIdClaim(sendId);
+ _sut.ControllerContext = CreateControllerContextWithUser(user);
+ _sendRepository.GetByIdAsync(sendId).Returns(send);
+
+ await Assert.ThrowsAsync(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
+
+ await _sendRepository.Received(1).GetByIdAsync(sendId);
+ }
+
+ [Theory, AutoData]
+ public async Task GetSendFileDownloadDataUsingAuth_WithAccessCountExceeded_ThrowsNotFoundException(
+ Guid sendId, string fileId)
+ {
+ var send = new Send
+ {
+ Id = sendId,
+ Type = SendType.File,
+ Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
+ DeletionDate = DateTime.UtcNow.AddDays(7),
+ ExpirationDate = null,
+ Disabled = false,
+ AccessCount = 10,
+ MaxAccessCount = 10 // Limit reached
+ };
+ var user = CreateUserWithSendIdClaim(sendId);
+ _sut.ControllerContext = CreateControllerContextWithUser(user);
+ _sendRepository.GetByIdAsync(sendId).Returns(send);
+
+ await Assert.ThrowsAsync(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
+
+ await _sendRepository.Received(1).GetByIdAsync(sendId);
+ }
+
+ #endregion
+
+ #endregion
+
+ #region PutRemoveAuth Tests
+
+ [Theory, AutoData]
+ public async Task PutRemoveAuth_WithPasswordProtectedSend_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",
+ Emails = null,
+ AuthType = AuthType.Password
+ };
+ _sendRepository.GetByIdAsync(sendId).Returns(existingSend);
+
+ var result = await _sut.PutRemoveAuth(sendId.ToString());
+
+ Assert.NotNull(result);
+ Assert.Equal(sendId, result.Id);
+ Assert.Equal(AuthType.None, result.AuthType);
+ Assert.Null(result.Password);
+ Assert.Null(result.Emails);
+ await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s =>
+ s.Id == sendId &&
+ s.Password == null &&
+ s.Emails == null &&
+ s.AuthType == AuthType.None));
+ }
+
+ [Theory, AutoData]
+ public async Task PutRemoveAuth_WithEmailProtectedSend_RemovesEmailsAndSetsAuthTypeNone(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 = null,
+ Emails = "test@example.com,user@example.com",
+ AuthType = AuthType.Email
+ };
+ _sendRepository.GetByIdAsync(sendId).Returns(existingSend);
+
+ var result = await _sut.PutRemoveAuth(sendId.ToString());
+
+ Assert.NotNull(result);
+ Assert.Equal(sendId, result.Id);
+ Assert.Equal(AuthType.None, result.AuthType);
+ Assert.Null(result.Password);
+ Assert.Null(result.Emails);
+ await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s =>
+ s.Id == sendId &&
+ s.Password == null &&
+ s.Emails == null &&
+ s.AuthType == AuthType.None));
+ }
+
+ [Theory, AutoData]
+ public async Task PutRemoveAuth_WithSendAlreadyHavingNoAuth_StillSucceeds(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 = null,
+ Emails = null,
+ AuthType = AuthType.None
+ };
+ _sendRepository.GetByIdAsync(sendId).Returns(existingSend);
+
+ var result = await _sut.PutRemoveAuth(sendId.ToString());
+
+ Assert.NotNull(result);
+ Assert.Equal(sendId, result.Id);
+ Assert.Equal(AuthType.None, result.AuthType);
+ Assert.Null(result.Password);
+ Assert.Null(result.Emails);
+ await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s =>
+ s.Id == sendId &&
+ s.Password == null &&
+ s.Emails == null &&
+ s.AuthType == AuthType.None));
+ }
+
+ [Theory, AutoData]
+ public async Task PutRemoveAuth_WithFileSend_RemovesAuthAndPreservesFileData(Guid userId, Guid sendId)
+ {
+ _userService.GetProperUserId(Arg.Any()).Returns(userId);
+ var fileData = new SendFileData("Test File", "Notes", "document.pdf") { Id = "file-123", Size = 2048 };
+ var existingSend = new Send
+ {
+ Id = sendId,
+ UserId = userId,
+ Type = SendType.File,
+ Data = JsonSerializer.Serialize(fileData),
+ Password = "hashed-password",
+ Emails = null,
+ AuthType = AuthType.Password
+ };
+ _sendRepository.GetByIdAsync(sendId).Returns(existingSend);
+
+ var result = await _sut.PutRemoveAuth(sendId.ToString());
+
+ Assert.NotNull(result);
+ Assert.Equal(sendId, result.Id);
+ Assert.Equal(AuthType.None, result.AuthType);
+ Assert.Equal(SendType.File, result.Type);
+ Assert.NotNull(result.File);
+ Assert.Equal("file-123", result.File.Id);
+ Assert.Null(result.Password);
+ Assert.Null(result.Emails);
+ }
+
+ [Theory, AutoData]
+ public async Task PutRemoveAuth_WithNonExistentSend_ThrowsNotFoundException(Guid userId, Guid sendId)
+ {
+ _userService.GetProperUserId(Arg.Any()).Returns(userId);
+ _sendRepository.GetByIdAsync(sendId).Returns((Send)null);
+
+ await Assert.ThrowsAsync(() => _sut.PutRemoveAuth(sendId.ToString()));
+
+ await _sendRepository.Received(1).GetByIdAsync(sendId);
+ await _nonAnonymousSendCommand.DidNotReceive().SaveSendAsync(Arg.Any());
+ }
+
+ [Theory, AutoData]
+ public async Task PutRemoveAuth_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",
+ AuthType = AuthType.Password
+ };
+ _sendRepository.GetByIdAsync(sendId).Returns(existingSend);
+
+ await Assert.ThrowsAsync(() => _sut.PutRemoveAuth(sendId.ToString()));
+
+ await _sendRepository.Received(1).GetByIdAsync(sendId);
+ await _nonAnonymousSendCommand.DidNotReceive().SaveSendAsync(Arg.Any());
+ }
+
+ [Theory, AutoData]
+ public async Task PutRemoveAuth_WithNullUserId_ThrowsInvalidOperationException(Guid sendId)
+ {
+ _userService.GetProperUserId(Arg.Any()).Returns((Guid?)null);
+
+ var exception =
+ await Assert.ThrowsAsync(() => _sut.PutRemoveAuth(sendId.ToString()));
+
+ Assert.Equal("User ID not found", exception.Message);
+ await _sendRepository.DidNotReceive().GetByIdAsync(Arg.Any());
+ await _nonAnonymousSendCommand.DidNotReceive().SaveSendAsync(Arg.Any());
+ }
+
+ [Theory, AutoData]
+ public async Task PutRemoveAuth_WithSendHavingBothPasswordAndEmails_RemovesBoth(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",
+ Emails = "test@example.com",
+ AuthType = AuthType.Password
+ };
+ _sendRepository.GetByIdAsync(sendId).Returns(existingSend);
+
+ var result = await _sut.PutRemoveAuth(sendId.ToString());
+
+ Assert.NotNull(result);
+ Assert.Equal(sendId, result.Id);
+ Assert.Equal(AuthType.None, result.AuthType);
+ Assert.Null(result.Password);
+ Assert.Null(result.Emails);
+ await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is(s =>
+ s.Id == sendId &&
+ s.Password == null &&
+ s.Emails == null &&
+ s.AuthType == AuthType.None));
+ }
+
+ [Theory, AutoData]
+ public async Task PutRemoveAuth_PreservesOtherSendProperties(Guid userId, Guid sendId)
+ {
+ _userService.GetProperUserId(Arg.Any()).Returns(userId);
+ var deletionDate = DateTime.UtcNow.AddDays(7);
+ var expirationDate = DateTime.UtcNow.AddDays(3);
+ 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,
+ Key = "encryption-key",
+ MaxAccessCount = 10,
+ AccessCount = 3,
+ DeletionDate = deletionDate,
+ ExpirationDate = expirationDate,
+ Disabled = false,
+ HideEmail = true
+ };
+ _sendRepository.GetByIdAsync(sendId).Returns(existingSend);
+
+ var result = await _sut.PutRemoveAuth(sendId.ToString());
+
+ Assert.NotNull(result);
+ Assert.Equal(sendId, result.Id);
+ Assert.Equal(AuthType.None, result.AuthType);
+ // Verify other properties are preserved
+ Assert.Equal("encryption-key", result.Key);
+ Assert.Equal(10, result.MaxAccessCount);
+ Assert.Equal(3, result.AccessCount);
+ Assert.Equal(deletionDate, result.DeletionDate);
+ Assert.Equal(expirationDate, result.ExpirationDate);
+ Assert.False(result.Disabled);
+ Assert.True(result.HideEmail);
+ }
+
+ #endregion
+
+ #region Test Helpers
+
+ private static ClaimsPrincipal CreateUserWithSendIdClaim(Guid sendId)
+ {
+ var claims = new List { new Claim("send_id", sendId.ToString()) };
+ var identity = new ClaimsIdentity(claims, "TestAuth");
+ return new ClaimsPrincipal(identity);
+ }
+
+ private static ControllerContext CreateControllerContextWithUser(ClaimsPrincipal user)
+ {
+ return new ControllerContext { HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext { User = user } };
+ }
+
+ #endregion
}
diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs
index 3c9fd9a9e9..e2c9de4d6f 100644
--- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs
+++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs
@@ -283,7 +283,7 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Invited,
- UserId = Guid.NewGuid(),
+ UserId = null,
Email = "invited@example.com"
};
@@ -302,6 +302,56 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
Assert.True(string.IsNullOrEmpty(result));
}
+ [Theory, BitAutoData]
+ public async Task ValidateAsync_EnablingPolicy_MixedUsersWithNullUserId_HandlesCorrectly(
+ [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
+ Guid confirmedUserId,
+ SutProvider sutProvider)
+ {
+ // Arrange
+ var invitedUser = new OrganizationUserUserDetails
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = policyUpdate.OrganizationId,
+ Type = OrganizationUserType.User,
+ Status = OrganizationUserStatusType.Invited,
+ UserId = null,
+ Email = "invited@example.com"
+ };
+
+ var confirmedUser = new OrganizationUserUserDetails
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = policyUpdate.OrganizationId,
+ Type = OrganizationUserType.User,
+ Status = OrganizationUserStatusType.Confirmed,
+ UserId = confirmedUserId,
+ Email = "confirmed@example.com"
+ };
+
+ sutProvider.GetDependency()
+ .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
+ .Returns([invitedUser, confirmedUser]);
+
+ sutProvider.GetDependency()
+ .GetManyByManyUsersAsync(Arg.Any>())
+ .Returns([]);
+
+ sutProvider.GetDependency()
+ .GetManyByManyUsersAsync(Arg.Any>())
+ .Returns([]);
+
+ // Act
+ var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
+
+ // Assert
+ Assert.True(string.IsNullOrEmpty(result));
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .GetManyByManyUsersAsync(Arg.Is>(ids => ids.Count() == 1 && ids.First() == confirmedUserId));
+ }
+
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_RevokedUsersIncluded_InComplianceCheck(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs
index 55eb69cc64..da287dc02b 100644
--- a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs
+++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs
@@ -266,7 +266,10 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any());
await _subscriberService.Received(1).CreateBraintreeCustomer(user, paymentMethod.Token);
await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId,
- Arg.Is(opts => opts.AutoAdvance == false));
+ Arg.Is(opts =>
+ opts.AutoAdvance == false &&
+ opts.Expand != null &&
+ opts.Expand.Contains("customer")));
await _braintreeService.Received(1).PayInvoice(Arg.Any(), mockInvoice);
await _userService.Received(1).SaveUserAsync(user);
await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id);
@@ -502,7 +505,10 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
Assert.True(user.Premium);
Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate);
await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId,
- Arg.Is(opts => opts.AutoAdvance == false));
+ Arg.Is(opts =>
+ opts.AutoAdvance == false &&
+ opts.Expand != null &&
+ opts.Expand.Contains("customer")));
await _braintreeService.Received(1).PayInvoice(Arg.Any(), mockInvoice);
}
@@ -612,7 +618,10 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
Assert.False(user.Premium);
Assert.Null(user.PremiumExpirationDate);
await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId,
- Arg.Is(opts => opts.AutoAdvance == false));
+ Arg.Is(opts =>
+ opts.AutoAdvance == false &&
+ opts.Expand != null &&
+ opts.Expand.Contains("customer")));
await _braintreeService.Received(1).PayInvoice(Arg.Any(), mockInvoice);
}
diff --git a/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs b/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs
index c34afc42bd..7901b3c5c0 100644
--- a/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs
+++ b/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs
@@ -1,4 +1,5 @@
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.Queries;
@@ -47,7 +48,7 @@ public class SendAuthenticationQueryTests
{
// Arrange
var sendId = Guid.NewGuid();
- var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: emailString, password: null);
+ var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: emailString, password: null, AuthType.Email);
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
@@ -63,7 +64,7 @@ public class SendAuthenticationQueryTests
{
// Arrange
var sendId = Guid.NewGuid();
- var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: "hashedpassword");
+ var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: "hashedpassword", AuthType.Email);
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
@@ -78,7 +79,7 @@ public class SendAuthenticationQueryTests
{
// Arrange
var sendId = Guid.NewGuid();
- var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null);
+ var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null, AuthType.None);
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
@@ -105,11 +106,11 @@ public class SendAuthenticationQueryTests
public static IEnumerable