diff --git a/.checkmarx/config.yml b/.checkmarx/config.yml
index 641da0eacb..e40c43b662 100644
--- a/.checkmarx/config.yml
+++ b/.checkmarx/config.yml
@@ -11,3 +11,7 @@ checkmarx:
filter: "!test"
kics:
filter: "!dev,!.devcontainer"
+ sca:
+ filter: "!dev,!.devcontainer"
+ containers:
+ filter: "!dev,!.devcontainer"
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index f0c85d98c1..5b1d2a85dc 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -11,6 +11,9 @@
**/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre
**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre
+# Scanning tools
+.checkmarx/ @bitwarden/team-appsec
+
## BRE team owns these workflows ##
.github/workflows/publish.yml @bitwarden/dept-bre
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index f3cc279a58..1adb6a8a1a 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -263,14 +263,14 @@ jobs:
- name: Scan Docker image
id: container-scan
- uses: anchore/scan-action@3c9a191a0fbab285ca6b8530b5de5a642cba332f # v7.2.2
+ uses: anchore/scan-action@62b74fb7bb810d2c45b1865f47a77655621862a5 # v7.2.3
with:
image: ${{ steps.image-tags.outputs.primary_tag }}
fail-build: false
output-format: sarif
- name: Upload Grype results to GitHub
- uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
+ uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
with:
sarif_file: ${{ steps.container-scan.outputs.sarif }}
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
diff --git a/src/Api/AdminConsole/Public/Controllers/MembersController.cs b/src/Api/AdminConsole/Public/Controllers/MembersController.cs
index 58e5db18c2..220c812cae 100644
--- a/src/Api/AdminConsole/Public/Controllers/MembersController.cs
+++ b/src/Api/AdminConsole/Public/Controllers/MembersController.cs
@@ -2,12 +2,16 @@
using Bit.Api.AdminConsole.Public.Models.Request;
using Bit.Api.AdminConsole.Public.Models.Response;
using Bit.Api.Models.Public.Response;
+using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
+using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
+using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
+using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
@@ -30,6 +34,8 @@ public class MembersController : Controller
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
+ private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommandV2;
+ private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
public MembersController(
IOrganizationUserRepository organizationUserRepository,
@@ -42,7 +48,9 @@ public class MembersController : Controller
IOrganizationRepository organizationRepository,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
- IResendOrganizationInviteCommand resendOrganizationInviteCommand)
+ IResendOrganizationInviteCommand resendOrganizationInviteCommand,
+ IRevokeOrganizationUserCommand revokeOrganizationUserCommandV2,
+ IRestoreOrganizationUserCommand restoreOrganizationUserCommand)
{
_organizationUserRepository = organizationUserRepository;
_groupRepository = groupRepository;
@@ -55,6 +63,8 @@ public class MembersController : Controller
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
+ _revokeOrganizationUserCommandV2 = revokeOrganizationUserCommandV2;
+ _restoreOrganizationUserCommand = restoreOrganizationUserCommand;
}
///
@@ -258,4 +268,59 @@ public class MembersController : Controller
await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId!.Value, null, id);
return new OkResult();
}
+
+ ///
+ /// Revoke a member's access to an organization.
+ ///
+ /// The ID of the member to be revoked.
+ [HttpPost("{id}/revoke")]
+ [ProducesResponseType((int)HttpStatusCode.OK)]
+ [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
+ [ProducesResponseType((int)HttpStatusCode.NotFound)]
+ public async Task Revoke(Guid id)
+ {
+ var organizationUser = await _organizationUserRepository.GetByIdAsync(id);
+ if (organizationUser == null || organizationUser.OrganizationId != _currentContext.OrganizationId)
+ {
+ return new NotFoundResult();
+ }
+
+ var request = new RevokeOrganizationUsersRequest(
+ _currentContext.OrganizationId!.Value,
+ [id],
+ new SystemUser(EventSystemUser.PublicApi)
+ );
+
+ var results = await _revokeOrganizationUserCommandV2.RevokeUsersAsync(request);
+ var result = results.Single();
+
+ return result.Result.Match(
+ error => new BadRequestObjectResult(new ErrorResponseModel(error.Message)),
+ _ => new OkResult()
+ );
+ }
+
+ ///
+ /// Restore a member.
+ ///
+ ///
+ /// Restores a previously revoked member of the organization.
+ ///
+ /// The identifier of the member to be restored.
+ [HttpPost("{id}/restore")]
+ [ProducesResponseType((int)HttpStatusCode.OK)]
+ [ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
+ [ProducesResponseType((int)HttpStatusCode.NotFound)]
+ public async Task Restore(Guid id)
+ {
+ var organizationUser = await _organizationUserRepository.GetByIdAsync(id);
+ if (organizationUser == null || organizationUser.OrganizationId != _currentContext.OrganizationId)
+ {
+ return new NotFoundResult();
+ }
+
+ await _restoreOrganizationUserCommand.RestoreUserAsync(organizationUser, EventSystemUser.PublicApi);
+
+ return new OkResult();
+ }
}
diff --git a/src/Api/KeyManagement/Validators/OrganizationUserRotationValidator.cs b/src/Api/KeyManagement/Validators/OrganizationUserRotationValidator.cs
index 5023521fe3..835965e2d6 100644
--- a/src/Api/KeyManagement/Validators/OrganizationUserRotationValidator.cs
+++ b/src/Api/KeyManagement/Validators/OrganizationUserRotationValidator.cs
@@ -34,8 +34,7 @@ public class OrganizationUserRotationValidator : IRotationValidator o.ResetPasswordKey != null).ToList();
-
+ existing = existing.Where(o => !string.IsNullOrEmpty(o.ResetPasswordKey)).ToList();
foreach (var ou in existing)
{
diff --git a/src/Api/Models/Public/Response/ErrorResponseModel.cs b/src/Api/Models/Public/Response/ErrorResponseModel.cs
index c5bb06d02e..a40b0c9569 100644
--- a/src/Api/Models/Public/Response/ErrorResponseModel.cs
+++ b/src/Api/Models/Public/Response/ErrorResponseModel.cs
@@ -1,7 +1,5 @@
-// FIXME: Update this file to be null safe and then delete the line below
-#nullable disable
-
-using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Bit.Api.Models.Public.Response;
@@ -46,13 +44,14 @@ public class ErrorResponseModel : IResponseModel
{ }
public ErrorResponseModel(string errorKey, string errorValue)
- : this(errorKey, new string[] { errorValue })
+ : this(errorKey, [errorValue])
{ }
public ErrorResponseModel(string errorKey, IEnumerable errorValues)
: this(new Dictionary> { { errorKey, errorValues } })
{ }
+ [JsonConstructor]
public ErrorResponseModel(string message, Dictionary> errors)
{
Message = message;
@@ -70,10 +69,10 @@ public class ErrorResponseModel : IResponseModel
///
/// The request model is invalid.
[Required]
- public string Message { get; set; }
+ public string Message { get; init; }
///
/// If multiple errors occurred, they are listed in dictionary. Errors related to a specific
/// request parameter will include a dictionary key describing that parameter.
///
- public Dictionary> Errors { get; set; }
+ public Dictionary>? Errors { get; }
}
diff --git a/src/Api/Tools/Controllers/SendsController.cs b/src/Api/Tools/Controllers/SendsController.cs
index f9f71d076d..61002a0168 100644
--- a/src/Api/Tools/Controllers/SendsController.cs
+++ b/src/Api/Tools/Controllers/SendsController.cs
@@ -239,12 +239,6 @@ public class SendsController : Controller
{
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())
@@ -272,12 +266,6 @@ public class SendsController : Controller
{
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);
diff --git a/src/Api/Tools/Models/Request/SendRequestModel.cs b/src/Api/Tools/Models/Request/SendRequestModel.cs
index f3308dbd5a..00dcb6273f 100644
--- a/src/Api/Tools/Models/Request/SendRequestModel.cs
+++ b/src/Api/Tools/Models/Request/SendRequestModel.cs
@@ -102,9 +102,17 @@ public class SendRequestModel
/// Comma-separated list of emails that may access the send using OTP
/// authentication. Mutually exclusive with .
///
- [StringLength(4000)]
+ [EncryptedString]
+ [EncryptedStringLength(4000)]
public string Emails { get; set; }
+ ///
+ /// Comma-separated list of email **hashes** that may access the send using OTP
+ /// authentication. Mutually exclusive with .
+ ///
+ [StringLength(4000)]
+ public string EmailHashes { get; set; }
+
///
/// When , send access is disabled.
/// Defaults to .
@@ -253,6 +261,7 @@ public class SendRequestModel
// normalize encoding
var emails = Emails.Split(',', RemoveEmptyEntries | TrimEntries);
existingSend.Emails = string.Join(",", emails);
+ existingSend.EmailHashes = EmailHashes;
existingSend.Password = null;
existingSend.AuthType = Core.Tools.Enums.AuthType.Email;
}
diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs
index 3430f33a77..9b6cf86257 100644
--- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs
+++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/AutomaticUserConfirmationPolicyRequirement.cs
@@ -19,7 +19,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements
/// Collection of policy details that apply to this user id
public class AutomaticUserConfirmationPolicyRequirement(IEnumerable policyDetails) : IPolicyRequirement
{
- public bool CannotBeGrantedEmergencyAccess() => policyDetails.Any();
+ public bool CannotHaveEmergencyAccess() => policyDetails.Any();
public bool CannotJoinProvider() => policyDetails.Any();
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index 73eeb1384c..a2e678230a 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -165,6 +165,7 @@ public static class FeatureFlagKeys
public const string OrganizationConfirmationEmail = "pm-28402-update-confirmed-to-org-email-template";
public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow";
public const string PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin";
+ public const string SafariAccountSwitching = "pm-5594-safari-account-switching";
public const string PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password";
/* Autofill Team */
@@ -223,9 +224,10 @@ public static class FeatureFlagKeys
/* Platform Team */
public const string WebPush = "web-push";
- public const string IpcChannelFramework = "ipc-channel-framework";
+ public const string ContentScriptIpcFramework = "content-script-ipc-channel-framework";
public const string PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked";
public const string PushNotificationsWhenInactive = "pm-25130-receive-push-notifications-for-inactive-users";
+ public const string WebAuthnRelatedOrigins = "pm-30529-webauthn-related-origins";
/* Tools Team */
///
diff --git a/src/Core/Tools/Entities/Send.cs b/src/Core/Tools/Entities/Send.cs
index 52b439c41e..c4398e212c 100644
--- a/src/Core/Tools/Entities/Send.cs
+++ b/src/Core/Tools/Entities/Send.cs
@@ -81,6 +81,15 @@ public class Send : ITableObject
[MaxLength(4000)]
public string? Emails { get; set; }
+ ///
+ /// Comma-separated list of email **hashes** for OTP authentication.
+ ///
+ ///
+ /// This field is mutually exclusive with
+ ///
+ [MaxLength(4000)]
+ public string? EmailHashes { get; set; }
+
///
/// The send becomes unavailable to API callers when
/// >= .
diff --git a/src/Core/Tools/Models/Data/SendAuthenticationTypes.cs b/src/Core/Tools/Models/Data/SendAuthenticationTypes.cs
index 9ce477ed0c..c90dba43a8 100644
--- a/src/Core/Tools/Models/Data/SendAuthenticationTypes.cs
+++ b/src/Core/Tools/Models/Data/SendAuthenticationTypes.cs
@@ -45,6 +45,6 @@ public record ResourcePassword(string Hash) : SendAuthenticationMethod;
/// Create a send claim by requesting a one time password (OTP) confirmation code.
///
///
-/// The list of email addresses permitted access to the send.
+/// The list of email address **hashes** permitted access to the send.
///
public record EmailOtp(string[] Emails) : SendAuthenticationMethod;
diff --git a/src/Core/Tools/SendFeatures/Queries/SendAuthenticationQuery.cs b/src/Core/Tools/SendFeatures/Queries/SendAuthenticationQuery.cs
index 97c2e64dc5..a82c27d0c3 100644
--- a/src/Core/Tools/SendFeatures/Queries/SendAuthenticationQuery.cs
+++ b/src/Core/Tools/SendFeatures/Queries/SendAuthenticationQuery.cs
@@ -37,8 +37,11 @@ public class SendAuthenticationQuery : ISendAuthenticationQuery
SendAuthenticationMethod method = send switch
{
null => NEVER_AUTHENTICATE,
- var s when s.AccessCount >= s.MaxAccessCount => NEVER_AUTHENTICATE,
- var s when s.AuthType == AuthType.Email && s.Emails is not null => emailOtp(s.Emails),
+ var s when s.Disabled => NEVER_AUTHENTICATE,
+ var s when s.AccessCount >= s.MaxAccessCount.GetValueOrDefault(int.MaxValue) => NEVER_AUTHENTICATE,
+ var s when s.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow => NEVER_AUTHENTICATE,
+ var s when s.DeletionDate <= DateTime.UtcNow => NEVER_AUTHENTICATE,
+ var s when s.AuthType == AuthType.Email && s.EmailHashes is not null => EmailOtp(s.EmailHashes),
var s when s.AuthType == AuthType.Password && s.Password is not null => new ResourcePassword(s.Password),
_ => NOT_AUTHENTICATED
};
@@ -46,9 +49,13 @@ public class SendAuthenticationQuery : ISendAuthenticationQuery
return method;
}
- private EmailOtp emailOtp(string emails)
+ private static EmailOtp EmailOtp(string? emailHashes)
{
- var list = emails.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (string.IsNullOrWhiteSpace(emailHashes))
+ {
+ return new EmailOtp([]);
+ }
+ var list = emailHashes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return new EmailOtp(list);
}
}
diff --git a/src/Icons/Icons.csproj b/src/Icons/Icons.csproj
index 97e9562183..9dc39eab1e 100644
--- a/src/Icons/Icons.csproj
+++ b/src/Icons/Icons.csproj
@@ -9,7 +9,7 @@
-
+
diff --git a/src/Infrastructure.Dapper/Tools/Repositories/SendRepository.cs b/src/Infrastructure.Dapper/Tools/Repositories/SendRepository.cs
index 81a94f0f7c..4c5d70340f 100644
--- a/src/Infrastructure.Dapper/Tools/Repositories/SendRepository.cs
+++ b/src/Infrastructure.Dapper/Tools/Repositories/SendRepository.cs
@@ -1,6 +1,7 @@
#nullable enable
using System.Data;
+using Bit.Core;
using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Settings;
using Bit.Core.Tools.Entities;
@@ -8,6 +9,7 @@ using Bit.Core.Tools.Repositories;
using Bit.Infrastructure.Dapper.Repositories;
using Bit.Infrastructure.Dapper.Tools.Helpers;
using Dapper;
+using Microsoft.AspNetCore.DataProtection;
using Microsoft.Data.SqlClient;
namespace Bit.Infrastructure.Dapper.Tools.Repositories;
@@ -15,13 +17,24 @@ namespace Bit.Infrastructure.Dapper.Tools.Repositories;
///
public class SendRepository : Repository, ISendRepository
{
- public SendRepository(GlobalSettings globalSettings)
- : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
+ private readonly IDataProtector _dataProtector;
+
+ public SendRepository(GlobalSettings globalSettings, IDataProtectionProvider dataProtectionProvider)
+ : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString, dataProtectionProvider)
{ }
- public SendRepository(string connectionString, string readOnlyConnectionString)
+ public SendRepository(string connectionString, string readOnlyConnectionString, IDataProtectionProvider dataProtectionProvider)
: base(connectionString, readOnlyConnectionString)
- { }
+ {
+ _dataProtector = dataProtectionProvider.CreateProtector(Constants.DatabaseFieldProtectorPurpose);
+ }
+
+ public override async Task GetByIdAsync(Guid id)
+ {
+ var send = await base.GetByIdAsync(id);
+ UnprotectData(send);
+ return send;
+ }
///
public async Task> GetManyByUserIdAsync(Guid userId)
@@ -33,7 +46,9 @@ public class SendRepository : Repository, ISendRepository
new { UserId = userId },
commandType: CommandType.StoredProcedure);
- return results.ToList();
+ var sends = results.ToList();
+ UnprotectData(sends);
+ return sends;
}
}
@@ -47,15 +62,35 @@ public class SendRepository : Repository, ISendRepository
new { DeletionDate = deletionDateBefore },
commandType: CommandType.StoredProcedure);
- return results.ToList();
+ var sends = results.ToList();
+ UnprotectData(sends);
+ return sends;
}
}
+ public override async Task CreateAsync(Send send)
+ {
+ await ProtectDataAndSaveAsync(send, async () => await base.CreateAsync(send));
+ return send;
+ }
+
+ public override async Task ReplaceAsync(Send send)
+ {
+ await ProtectDataAndSaveAsync(send, async () => await base.ReplaceAsync(send));
+ }
+
///
public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid userId, IEnumerable sends)
{
return async (connection, transaction) =>
{
+ // Protect all sends before bulk update
+ var sendsList = sends.ToList();
+ foreach (var send in sendsList)
+ {
+ ProtectData(send);
+ }
+
// Create temp table
var sqlCreateTemp = @"
SELECT TOP 0 *
@@ -71,7 +106,7 @@ public class SendRepository : Repository, ISendRepository
using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction))
{
bulkCopy.DestinationTableName = "#TempSend";
- var sendsTable = sends.ToDataTable();
+ var sendsTable = sendsList.ToDataTable();
foreach (DataColumn col in sendsTable.Columns)
{
bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName);
@@ -101,6 +136,69 @@ public class SendRepository : Repository, ISendRepository
cmd.Parameters.Add("@UserId", SqlDbType.UniqueIdentifier).Value = userId;
cmd.ExecuteNonQuery();
}
+
+ // Unprotect after save
+ foreach (var send in sendsList)
+ {
+ UnprotectData(send);
+ }
};
}
+
+ private async Task ProtectDataAndSaveAsync(Send send, Func saveTask)
+ {
+ if (send == null)
+ {
+ await saveTask();
+ return;
+ }
+
+ // Capture original value
+ var originalEmailHashes = send.EmailHashes;
+
+ // Protect value
+ ProtectData(send);
+
+ // Save
+ await saveTask();
+
+ // Restore original value
+ send.EmailHashes = originalEmailHashes;
+ }
+
+ private void ProtectData(Send send)
+ {
+ if (!send.EmailHashes?.StartsWith(Constants.DatabaseFieldProtectedPrefix) ?? false)
+ {
+ send.EmailHashes = string.Concat(Constants.DatabaseFieldProtectedPrefix,
+ _dataProtector.Protect(send.EmailHashes!));
+ }
+ }
+
+ private void UnprotectData(Send? send)
+ {
+ if (send == null)
+ {
+ return;
+ }
+
+ if (send.EmailHashes?.StartsWith(Constants.DatabaseFieldProtectedPrefix) ?? false)
+ {
+ send.EmailHashes = _dataProtector.Unprotect(
+ send.EmailHashes.Substring(Constants.DatabaseFieldProtectedPrefix.Length));
+ }
+ }
+
+ private void UnprotectData(IEnumerable sends)
+ {
+ if (sends == null)
+ {
+ return;
+ }
+
+ foreach (var send in sends)
+ {
+ UnprotectData(send);
+ }
+ }
}
diff --git a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs
index a0ee0376c0..3f638f88e5 100644
--- a/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs
+++ b/src/Infrastructure.EntityFramework/Repositories/DatabaseContext.cs
@@ -119,6 +119,7 @@ public class DatabaseContext : DbContext
var eOrganizationDomain = builder.Entity();
var aWebAuthnCredential = builder.Entity();
var eOrganizationMemberBaseDetail = builder.Entity();
+ var eSend = builder.Entity();
// Shadow property configurations go here
@@ -148,6 +149,7 @@ public class DatabaseContext : DbContext
var dataProtectionConverter = new DataProtectionConverter(dataProtector);
eUser.Property(c => c.Key).HasConversion(dataProtectionConverter);
eUser.Property(c => c.MasterPassword).HasConversion(dataProtectionConverter);
+ eSend.Property(c => c.EmailHashes).HasConversion(dataProtectionConverter);
if (Database.IsNpgsql())
{
diff --git a/src/Sql/dbo/Tools/Stored Procedures/Send_Create.sql b/src/Sql/dbo/Tools/Stored Procedures/Send_Create.sql
index 752f8fb496..e277174717 100644
--- a/src/Sql/dbo/Tools/Stored Procedures/Send_Create.sql
+++ b/src/Sql/dbo/Tools/Stored Procedures/Send_Create.sql
@@ -18,7 +18,8 @@
-- FIXME: remove null default value once this argument has been
-- in 2 server releases
@Emails NVARCHAR(4000) = NULL,
- @AuthType TINYINT = NULL
+ @AuthType TINYINT = NULL,
+ @EmailHashes NVARCHAR(4000) = NULL
AS
BEGIN
SET NOCOUNT ON
@@ -42,7 +43,8 @@ BEGIN
[HideEmail],
[CipherId],
[Emails],
- [AuthType]
+ [AuthType],
+ [EmailHashes]
)
VALUES
(
@@ -63,7 +65,8 @@ BEGIN
@HideEmail,
@CipherId,
@Emails,
- @AuthType
+ @AuthType,
+ @EmailHashes
)
IF @UserId IS NOT NULL
diff --git a/src/Sql/dbo/Tools/Stored Procedures/Send_Update.sql b/src/Sql/dbo/Tools/Stored Procedures/Send_Update.sql
index fba842d8d6..a2bcb0a24b 100644
--- a/src/Sql/dbo/Tools/Stored Procedures/Send_Update.sql
+++ b/src/Sql/dbo/Tools/Stored Procedures/Send_Update.sql
@@ -16,7 +16,8 @@
@HideEmail BIT,
@CipherId UNIQUEIDENTIFIER = NULL,
@Emails NVARCHAR(4000) = NULL,
- @AuthType TINYINT = NULL
+ @AuthType TINYINT = NULL,
+ @EmailHashes NVARCHAR(4000) = NULL
AS
BEGIN
SET NOCOUNT ON
@@ -40,7 +41,8 @@ BEGIN
[HideEmail] = @HideEmail,
[CipherId] = @CipherId,
[Emails] = @Emails,
- [AuthType] = @AuthType
+ [AuthType] = @AuthType,
+ [EmailHashes] = @EmailHashes
WHERE
[Id] = @Id
diff --git a/src/Sql/dbo/Tools/Tables/Send.sql b/src/Sql/dbo/Tools/Tables/Send.sql
index 94311d6328..59a42a2aa5 100644
--- a/src/Sql/dbo/Tools/Tables/Send.sql
+++ b/src/Sql/dbo/Tools/Tables/Send.sql
@@ -1,22 +1,24 @@
-CREATE TABLE [dbo].[Send] (
+CREATE TABLE [dbo].[Send]
+(
[Id] UNIQUEIDENTIFIER NOT NULL,
[UserId] UNIQUEIDENTIFIER NULL,
[OrganizationId] UNIQUEIDENTIFIER NULL,
[Type] TINYINT NOT NULL,
[Data] VARCHAR(MAX) NOT NULL,
- [Key] VARCHAR (MAX) NOT NULL,
- [Password] NVARCHAR (300) NULL,
- [Emails] NVARCHAR (4000) NULL,
+ [Key] VARCHAR(MAX) NOT NULL,
+ [Password] NVARCHAR(300) NULL,
+ [Emails] NVARCHAR(4000) NULL,
[MaxAccessCount] INT NULL,
[AccessCount] INT NOT NULL,
- [CreationDate] DATETIME2 (7) NOT NULL,
- [RevisionDate] DATETIME2 (7) NOT NULL,
- [ExpirationDate] DATETIME2 (7) NULL,
- [DeletionDate] DATETIME2 (7) NOT NULL,
+ [CreationDate] DATETIME2(7) NOT NULL,
+ [RevisionDate] DATETIME2(7) NOT NULL,
+ [ExpirationDate] DATETIME2(7) NULL,
+ [DeletionDate] DATETIME2(7) NOT NULL,
[Disabled] BIT NOT NULL,
[HideEmail] BIT NULL,
[CipherId] UNIQUEIDENTIFIER NULL,
[AuthType] TINYINT NULL,
+ [EmailHashes] NVARCHAR(4000) NULL,
CONSTRAINT [PK_Send] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_Send_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]),
CONSTRAINT [FK_Send_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]),
@@ -26,9 +28,9 @@
GO
CREATE NONCLUSTERED INDEX [IX_Send_UserId_OrganizationId]
- ON [dbo].[Send]([UserId] ASC, [OrganizationId] ASC);
+ ON [dbo].[Send] ([UserId] ASC, [OrganizationId] ASC);
GO
CREATE NONCLUSTERED INDEX [IX_Send_DeletionDate]
- ON [dbo].[Send]([DeletionDate] ASC);
+ ON [dbo].[Send] ([DeletionDate] ASC);
diff --git a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs
index 9f2512038e..e4bdbdb174 100644
--- a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs
+++ b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs
@@ -264,4 +264,138 @@ public class MembersControllerTests : IClassFixture, IAsy
new Permissions { CreateNewCollections = true, ManageScim = true, ManageGroups = true, ManageUsers = true },
orgUser.GetPermissions());
}
+
+ [Fact]
+ public async Task Revoke_Member_Success()
+ {
+ var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
+ _factory, _organization.Id, OrganizationUserType.User);
+
+ var response = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var updatedUser = await _factory.GetService()
+ .GetByIdAsync(orgUser.Id);
+ Assert.NotNull(updatedUser);
+ Assert.Equal(OrganizationUserStatusType.Revoked, updatedUser.Status);
+ }
+
+ [Fact]
+ public async Task Revoke_AlreadyRevoked_ReturnsBadRequest()
+ {
+ var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
+ _factory, _organization.Id, OrganizationUserType.User);
+
+ var revokeResponse = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null);
+ Assert.Equal(HttpStatusCode.OK, revokeResponse.StatusCode);
+
+ var response = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null);
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ var error = await response.Content.ReadFromJsonAsync();
+ Assert.Equal("Already revoked.", error?.Message);
+ }
+
+ [Fact]
+ public async Task Revoke_NotFound_ReturnsNotFound()
+ {
+ var response = await _client.PostAsync($"/public/members/{Guid.NewGuid()}/revoke", null);
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Revoke_DifferentOrganization_ReturnsNotFound()
+ {
+ // Create a different organization
+ var ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
+ await _factory.LoginWithNewAccount(ownerEmail);
+ var (otherOrganization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually,
+ ownerEmail: ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card);
+
+ // Create a user in the other organization
+ var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
+ _factory, otherOrganization.Id, OrganizationUserType.User);
+
+ // Re-authenticate with the original organization
+ await _loginHelper.LoginWithOrganizationApiKeyAsync(_organization.Id);
+
+ // Try to revoke the user from the other organization
+ var response = await _client.PostAsync($"/public/members/{orgUser.Id}/revoke", null);
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Restore_Member_Success()
+ {
+ // Invite a user to revoke
+ var email = $"integration-test{Guid.NewGuid()}@example.com";
+ var inviteRequest = new MemberCreateRequestModel
+ {
+ Email = email,
+ Type = OrganizationUserType.User,
+ };
+
+ var inviteResponse = await _client.PostAsync("/public/members", JsonContent.Create(inviteRequest));
+ Assert.Equal(HttpStatusCode.OK, inviteResponse.StatusCode);
+ var invitedMember = await inviteResponse.Content.ReadFromJsonAsync();
+ Assert.NotNull(invitedMember);
+
+ // Revoke the invited user
+ var revokeResponse = await _client.PostAsync($"/public/members/{invitedMember.Id}/revoke", null);
+ Assert.Equal(HttpStatusCode.OK, revokeResponse.StatusCode);
+
+ // Restore the user
+ var response = await _client.PostAsync($"/public/members/{invitedMember.Id}/restore", null);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ // Verify user is restored to Invited state
+ var updatedUser = await _factory.GetService()
+ .GetByIdAsync(invitedMember.Id);
+ Assert.NotNull(updatedUser);
+ Assert.Equal(OrganizationUserStatusType.Invited, updatedUser.Status);
+ }
+
+ [Fact]
+ public async Task Restore_AlreadyActive_ReturnsBadRequest()
+ {
+ var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
+ _factory, _organization.Id, OrganizationUserType.User);
+
+ var response = await _client.PostAsync($"/public/members/{orgUser.Id}/restore", null);
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ var error = await response.Content.ReadFromJsonAsync();
+ Assert.Equal("Already active.", error?.Message);
+ }
+
+ [Fact]
+ public async Task Restore_NotFound_ReturnsNotFound()
+ {
+ var response = await _client.PostAsync($"/public/members/{Guid.NewGuid()}/restore", null);
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Restore_DifferentOrganization_ReturnsNotFound()
+ {
+ // Create a different organization
+ var ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
+ await _factory.LoginWithNewAccount(ownerEmail);
+ var (otherOrganization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually,
+ ownerEmail: ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card);
+
+ // Create a user in the other organization
+ var (_, orgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(
+ _factory, otherOrganization.Id, OrganizationUserType.User);
+
+ // Re-authenticate with the original organization
+ await _loginHelper.LoginWithOrganizationApiKeyAsync(_organization.Id);
+
+ // Try to restore the user from the other organization
+ var response = await _client.PostAsync($"/public/members/{orgUser.Id}/restore", null);
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
}
diff --git a/test/Api.Test/KeyManagement/Validators/OrganizationUserRotationValidatorTests.cs b/test/Api.Test/KeyManagement/Validators/OrganizationUserRotationValidatorTests.cs
index 964c801903..a939636fc2 100644
--- a/test/Api.Test/KeyManagement/Validators/OrganizationUserRotationValidatorTests.cs
+++ b/test/Api.Test/KeyManagement/Validators/OrganizationUserRotationValidatorTests.cs
@@ -69,6 +69,44 @@ public class OrganizationUserRotationValidatorTests
Assert.Empty(result);
}
+ [Theory]
+ [BitAutoData([null])]
+ [BitAutoData("")]
+ public async Task ValidateAsync_OrgUsersWithNullOrEmptyResetPasswordKey_FiltersOutInvalidKeys(
+ string? invalidResetPasswordKey,
+ SutProvider sutProvider, User user,
+ ResetPasswordWithOrgIdRequestModel validResetPasswordKey)
+ {
+ // Arrange
+ var existingUserResetPassword = new List
+ {
+ // Valid org user with reset password key
+ new OrganizationUser
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = validResetPasswordKey.OrganizationId,
+ ResetPasswordKey = validResetPasswordKey.ResetPasswordKey
+ },
+ // Invalid org user with null or empty reset password key - should be filtered out
+ new OrganizationUser
+ {
+ Id = Guid.NewGuid(),
+ OrganizationId = Guid.NewGuid(),
+ ResetPasswordKey = invalidResetPasswordKey
+ }
+ };
+ sutProvider.GetDependency().GetManyByUserAsync(user.Id)
+ .Returns(existingUserResetPassword);
+
+ // Act
+ var result = await sutProvider.Sut.ValidateAsync(user, new[] { validResetPasswordKey });
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Single(result);
+ Assert.Equal(validResetPasswordKey.OrganizationId, result[0].OrganizationId);
+ }
+
[Theory]
[BitAutoData]
public async Task ValidateAsync_MissingResetPassword_Throws(
diff --git a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs
index e3a9ba4435..9322948037 100644
--- a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs
+++ b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs
@@ -981,205 +981,6 @@ public class SendsControllerTests : IDisposable
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
diff --git a/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs b/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs
index 7901b3c5c0..56b0f306cb 100644
--- a/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs
+++ b/test/Core.Test/Tools/Services/SendAuthenticationQueryTests.cs
@@ -43,12 +43,12 @@ public class SendAuthenticationQueryTests
}
[Theory]
- [MemberData(nameof(EmailParsingTestCases))]
- public async Task GetAuthenticationMethod_WithEmails_ParsesEmailsCorrectly(string emailString, string[] expectedEmails)
+ [MemberData(nameof(EmailHashesParsingTestCases))]
+ public async Task GetAuthenticationMethod_WithEmailHashes_ParsesEmailHashesCorrectly(string emailHashString, string[] expectedEmailHashes)
{
// Arrange
var sendId = Guid.NewGuid();
- var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: emailString, password: null, AuthType.Email);
+ var send = CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: emailHashString, password: null, AuthType.Email);
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
@@ -56,15 +56,15 @@ public class SendAuthenticationQueryTests
// Assert
var emailOtp = Assert.IsType(result);
- Assert.Equal(expectedEmails, emailOtp.Emails);
+ Assert.Equal(expectedEmailHashes, emailOtp.Emails);
}
[Fact]
- public async Task GetAuthenticationMethod_WithBothEmailsAndPassword_ReturnsEmailOtp()
+ public async Task GetAuthenticationMethod_WithBothEmailHashesAndPassword_ReturnsEmailOtp()
{
// Arrange
var sendId = Guid.NewGuid();
- var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: "hashedpassword", AuthType.Email);
+ var send = CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: "hashedemail", password: "hashedpassword", AuthType.Email);
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
@@ -79,7 +79,7 @@ public class SendAuthenticationQueryTests
{
// Arrange
var sendId = Guid.NewGuid();
- var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null, AuthType.None);
+ var send = CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: null, password: null, AuthType.None);
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
@@ -106,32 +106,218 @@ public class SendAuthenticationQueryTests
public static IEnumerable