1
0
mirror of https://github.com/bitwarden/server synced 2026-02-27 09:53:42 +00:00

Auth/PM-32035 - Emergency Access - DeleteEmergencyAccessCommand refactor (#7054)

* PM-32035 - EmergencyAccessService - fix interface docs, method docs, and tests to cover grantee / grantor deletion which is supported today.

* PM-32035 - EmergencyAccessService - mark existing delete as deprecated

* PM-32035 - EmergencyAccess readme docs - fix deletion docs

* PM-32035 - Add new EmergencyAccessDetails_ReadByUserIds stored proc

* PM-32035 - Add migration script for EmergencyAccessDetails_ReadByUserIds

* PM-32035 - Build out GetManyDetailsByUserIdsAsync in repository layer plus add tests

* PM-32035 - EmergencyAccessRepo - DeleteManyAsync - remove grantee revision bump as not necessary since no EA sync data exists + update tests

* PM-32035 - Fix incorrect nullability annotation on EmergencyAccessDetails.GrantorEmail. Both the SQL view and EF projection use a LEFT JOIN to the User table, meaning the value can be null if the grantor's account no longer exists. Changed to string? and removed the required modifier since the class is only ever materialized from database queries, never directly instantiated.

* PM-32035 - Refactor DeleteEmergencyAccess command to offer new DeleteAllByUserIdAsync and DeleteAllByUserIdsAsync methods. Need to build out DeleteByIdAndUserIdAsync with a new stored proc.

* PM-32035 - Build out IEmergencyAccessRepository.GetDetailsByIdAsync because we need such a method in order to meet the product requirements to send grantor email notifications for normal deletions in the future.

* PM-32035 - Wire up DeleteEmergencyAccessCommand.DeleteByIdAndUserIdAsync to use new repository method emergencyAccessRepository.GetDetailsByIdAsync so we can send notifications. Now, it is full replacement for the existing emergency access service deletion method + has the new notification functionaliy requested.

* PM-32035 - Add more test coverage for DeleteByIdAndUserIdAsync

* PM-32035 - Fix missing GranteeAvatarColor and GrantorAvatarColor projections in EmergencyAccessDetailsViewQuery. The EF view query omitted both avatar color fields from its Select projection, causing the integration tests to fail on all non-SqlServer databases (MySql, Postgres, Sqlite) where EF is used instead of Dapper.

* PM-32035 - Rename migration after main merge revealed collision

* PM-32035 - Rename migration script

* PM-32035 - PR feedback - add ticket + todos to deprecated delete async method.

* PM-32035 - DeleteEmergencyAccessCommand - add logs if we don't have user data required to send email notifications.

* PM-32035 - PR Feedback - rename EmergencyAccessDetails_ReadByUserIds to EmergencyAccessDetails_ReadManyByUserIds
This commit is contained in:
Jared Snider
2026-02-26 12:49:26 -05:00
committed by GitHub
parent fd5ea2f7b3
commit 18973a4f63
17 changed files with 1400 additions and 272 deletions

View File

@@ -8,9 +8,6 @@ public class EmergencyAccessDetails : EmergencyAccess
public string? GranteeEmail { get; set; }
public string? GranteeAvatarColor { get; set; }
public string? GrantorName { get; set; }
/// <summary>
/// Grantor email is assumed not null because in order to create an emergency access the grantor must be an existing user.
/// </summary>
public required string GrantorEmail { get; set; }
public string? GrantorEmail { get; set; }
public string? GrantorAvatarColor { get; set; }
}

View File

@@ -10,6 +10,12 @@ public interface IEmergencyAccessRepository : IRepository<EmergencyAccess, Guid>
Task<ICollection<EmergencyAccessDetails>> GetManyDetailsByGrantorIdAsync(Guid grantorId);
Task<ICollection<EmergencyAccessDetails>> GetManyDetailsByGranteeIdAsync(Guid granteeId);
/// <summary>
/// Gets all emergency access details where the user IDs are either grantors or grantees
/// </summary>
/// <param name="userIds">Collection of user IDs to query</param>
/// <returns>All emergency access details matching the user IDs</returns>
Task<ICollection<EmergencyAccessDetails>> GetManyDetailsByUserIdsAsync(ICollection<Guid> userIds);
/// <summary>
/// Fetches emergency access details by EmergencyAccess id and grantor id
/// </summary>
/// <param name="id">Emergency Access Id</param>
@@ -17,6 +23,12 @@ public interface IEmergencyAccessRepository : IRepository<EmergencyAccess, Guid>
/// <returns>EmergencyAccessDetails or null</returns>
Task<EmergencyAccessDetails?> GetDetailsByIdGrantorIdAsync(Guid id, Guid grantorId);
/// <summary>
/// Fetches emergency access details by EmergencyAccess id
/// </summary>
/// <param name="id">Emergency Access Id</param>
/// <returns>EmergencyAccessDetails or null</returns>
Task<EmergencyAccessDetails?> GetDetailsByIdAsync(Guid id);
/// <summary>
/// Database call to fetch emergency accesses that need notification emails sent through a Job
/// </summary>
/// <returns>collection of EmergencyAccessNotify objects that require notification</returns>

View File

@@ -1,107 +1,148 @@
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces;
using Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces;
using Bit.Core.Auth.UserFeatures.EmergencyAccess.Mail;
using Bit.Core.Exceptions;
using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Repositories;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Auth.UserFeatures.EmergencyAccess.Commands;
public class DeleteEmergencyAccessCommand(
IEmergencyAccessRepository _emergencyAccessRepository,
IMailer mailer) : IDeleteEmergencyAccessCommand
IMailer mailer,
ILogger<DeleteEmergencyAccessCommand> _logger) : IDeleteEmergencyAccessCommand
{
/// <inheritdoc />
public async Task<EmergencyAccessDetails> DeleteByIdGrantorIdAsync(Guid emergencyAccessId, Guid grantorId)
public async Task DeleteByIdAndUserIdAsync(Guid emergencyAccessId, Guid userId)
{
var emergencyAccessDetails = await _emergencyAccessRepository.GetDetailsByIdGrantorIdAsync(emergencyAccessId, grantorId);
var emergencyAccessDetails = await _emergencyAccessRepository.GetDetailsByIdAsync(emergencyAccessId);
if (emergencyAccessDetails == null || emergencyAccessDetails.GrantorId != grantorId)
// Error if the emergency access doesn't exist or the user trying to delete is neither the grantor nor the grantee
if (emergencyAccessDetails == null || (emergencyAccessDetails.GrantorId != userId && emergencyAccessDetails.GranteeId != userId))
{
throw new BadRequestException("Emergency Access not valid.");
}
var (grantorEmails, granteeEmails) = await DeleteEmergencyAccessAsync([emergencyAccessDetails]);
await _emergencyAccessRepository.DeleteAsync(emergencyAccessDetails);
// Send notification email to grantor
await SendEmergencyAccessRemoveGranteesEmailAsync(grantorEmails, granteeEmails);
return emergencyAccessDetails;
// Emails may be null if the grantor or grantee user account has since been deleted
// so ensure we have both emails we need.
if (!string.IsNullOrEmpty(emergencyAccessDetails.GrantorEmail) &&
!string.IsNullOrEmpty(emergencyAccessDetails.GranteeEmail))
{
await SendGranteesRemovalNotificationToGrantorAsync(
emergencyAccessDetails.GrantorEmail,
[emergencyAccessDetails.GranteeEmail]);
}
else
{
// If we are missing the emails needed to send a notification, log this occurrence.
_logger.LogWarning(
"Emergency access deletion notification skipped for grantor {GrantorId} and grantee {GranteeId}: GrantorEmail missing: {GrantorEmailMissing}, GranteeEmail missing: {GranteeEmailMissing}.",
emergencyAccessDetails.GrantorId,
emergencyAccessDetails.GranteeId,
string.IsNullOrEmpty(emergencyAccessDetails.GrantorEmail),
string.IsNullOrEmpty(emergencyAccessDetails.GranteeEmail));
}
}
/// <inheritdoc />
public async Task<ICollection<EmergencyAccessDetails>?> DeleteAllByGrantorIdAsync(Guid grantorId)
public async Task DeleteAllByUserIdAsync(Guid userId)
{
var emergencyAccessDetails = await _emergencyAccessRepository.GetManyDetailsByGrantorIdAsync(grantorId);
await DeleteAllByUserIdsAsync([userId]);
}
/// <inheritdoc />
public async Task DeleteAllByUserIdsAsync(ICollection<Guid> userIds)
{
var emergencyAccessDetails = await _emergencyAccessRepository.GetManyDetailsByUserIdsAsync(userIds);
// if there is nothing return an empty array and do not send an email
if (emergencyAccessDetails.Count == 0)
{
return emergencyAccessDetails;
// No records found, so nothing to delete or notify
// However, don't throw an error since the end state of "no records for these user IDs"
// is already achieved
return;
}
var (grantorEmails, granteeEmails) = await DeleteEmergencyAccessAsync(emergencyAccessDetails);
// Delete all records using existing DeleteManyAsync (batching already implemented)
var emergencyAccessIds = emergencyAccessDetails.Select(ea => ea.Id).ToList();
await _emergencyAccessRepository.DeleteManyAsync(emergencyAccessIds);
// Send notification email to grantor
await SendEmergencyAccessRemoveGranteesEmailAsync(grantorEmails, granteeEmails);
// After deletion, send notifications to grantors about their removed grantees.
// GrantorEmail may be null when a grantor's account has been deleted, since it is sourced
// entirely from a LEFT JOIN on the User table with no fallback column. Log any affected
// GrantorIds up front for traceability — the grantor's account is already gone so the ID
// cannot be used to look up the user, but it can be correlated with audit logs generated
// at the time of that account's deletion to understand why the notification was skipped.
var grantorIdsWithNullEmail = emergencyAccessDetails
.Where(ea => string.IsNullOrEmpty(ea.GrantorEmail))
.Select(ea => ea.GrantorId)
.Distinct()
.ToList();
return emergencyAccessDetails;
}
/// <inheritdoc />
public async Task<ICollection<EmergencyAccessDetails>?> DeleteAllByGranteeIdAsync(Guid granteeId)
{
var emergencyAccessDetails = await _emergencyAccessRepository.GetManyDetailsByGranteeIdAsync(granteeId);
// if there is nothing return an empty array
if (emergencyAccessDetails == null || emergencyAccessDetails.Count == 0)
if (grantorIdsWithNullEmail.Count > 0)
{
return emergencyAccessDetails;
_logger.LogWarning(
"Emergency access deletion notification skipped for {Count} grantor(s) with missing GrantorEmail. GrantorIds: {GrantorIds}.",
grantorIdsWithNullEmail.Count,
grantorIdsWithNullEmail);
}
var (grantorEmails, granteeEmails) = await DeleteEmergencyAccessAsync(emergencyAccessDetails);
// Group by grantor email to send each grantor a single email listing all their removed grantees.
// Records with null GrantorEmail are excluded above and will not receive a notification.
var grantorEmergencyAccessDetailGroups = emergencyAccessDetails
.Where(ea => !string.IsNullOrEmpty(ea.GrantorEmail))
.GroupBy(ea => ea.GrantorEmail!); // .GrantorEmail is safe here due to the Where above
// Send notification email to grantor(s)
await SendEmergencyAccessRemoveGranteesEmailAsync(grantorEmails, granteeEmails);
return emergencyAccessDetails;
}
private async Task<(HashSet<string> grantorEmails, HashSet<string> granteeEmails)> DeleteEmergencyAccessAsync(IEnumerable<EmergencyAccessDetails> emergencyAccessDetails)
{
var grantorEmails = new HashSet<string>();
var granteeEmails = new HashSet<string>();
await _emergencyAccessRepository.DeleteManyAsync([.. emergencyAccessDetails.Select(ea => ea.Id)]);
foreach (var details in emergencyAccessDetails)
foreach (var grantorGroup in grantorEmergencyAccessDetailGroups)
{
granteeEmails.Add(details.GranteeEmail ?? string.Empty);
grantorEmails.Add(details.GrantorEmail);
}
var grantorEmail = grantorGroup.Key;
var granteeEmails = grantorGroup
.Select(ea => ea.GranteeEmail)
// Filter out null grantee emails, which may occur if a grantee's account has been deleted
.Where(e => !string.IsNullOrEmpty(e))
.Cast<string>() // Cast is safe here due to the Where above
.Distinct();
return (grantorEmails, granteeEmails);
var granteeIdsWithNullEmail = grantorGroup
.Where(ea => string.IsNullOrEmpty(ea.GranteeEmail))
.Select(ea => ea.GranteeId)
.Distinct()
.ToList();
if (granteeIdsWithNullEmail.Count > 0)
{
_logger.LogWarning(
"Emergency access deletion notification skipped for {Count} grantee(s) with missing GranteeEmail. GranteeIds: {GranteeIds}.",
granteeIdsWithNullEmail.Count,
granteeIdsWithNullEmail);
}
if (granteeEmails.Any())
{
await SendGranteesRemovalNotificationToGrantorAsync(grantorEmail, granteeEmails);
}
}
}
/// <summary>
/// Sends an email notification to the grantor about removed grantees.
/// Sends an email notification to a grantor about their removed grantees.
/// </summary>
/// <param name="grantorEmails">The email addresses of the grantors to notify when deleting by grantee</param>
/// <param name="formattedGranteeIdentifiers">The formatted identifiers of the removed grantees to include in the email</param>
/// <returns></returns>
private async Task SendEmergencyAccessRemoveGranteesEmailAsync(IEnumerable<string> grantorEmails, IEnumerable<string> formattedGranteeIdentifiers)
/// <param name="grantorEmail">The email address of the grantor to notify</param>
/// <param name="granteeEmails">The email addresses of the removed grantees</param>
private async Task SendGranteesRemovalNotificationToGrantorAsync(string grantorEmail, IEnumerable<string> granteeEmails)
{
foreach (var email in grantorEmails)
var emailViewModel = new EmergencyAccessRemoveGranteesMail
{
var emailViewModel = new EmergencyAccessRemoveGranteesMail
ToEmails = [grantorEmail],
View = new EmergencyAccessRemoveGranteesMailView
{
ToEmails = [email],
View = new EmergencyAccessRemoveGranteesMailView
{
RemovedGranteeEmails = formattedGranteeIdentifiers
}
};
RemovedGranteeEmails = granteeEmails
}
};
await mailer.SendEmail(emailViewModel);
}
await mailer.SendEmail(emailViewModel);
}
}

View File

@@ -161,12 +161,13 @@ public class EmergencyAccessService : IEmergencyAccessService
return emergencyAccess;
}
public async Task DeleteAsync(Guid emergencyAccessId, Guid grantorId)
// TODO: remove with PM-31327 when we migrate to the command.
public async Task DeleteAsync(Guid emergencyAccessId, Guid userId)
{
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId);
// TODO PM-19438/PM-21687
// Not sure why the GrantorId and the GranteeId are supposed to be the same?
if (emergencyAccess == null || (emergencyAccess.GrantorId != grantorId && emergencyAccess.GranteeId != grantorId))
// Error if the emergency access doesn't exist or the user trying to delete is neither the grantor nor the grantee
if (emergencyAccess == null || (emergencyAccess.GrantorId != userId && emergencyAccess.GranteeId != userId))
{
throw new BadRequestException("Emergency Access not valid.");
}

View File

@@ -38,12 +38,14 @@ public interface IEmergencyAccessService
/// <returns>void</returns>
Task<Entities.EmergencyAccess> AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService);
/// <summary>
/// The creator of the emergency access request can delete the request.
/// Either the grantor OR the grantee can delete the emergency access.
/// </summary>
/// <param name="emergencyAccessId">Id of the emergency access being acted on</param>
/// <param name="grantorId">Id of the owner trying to delete the emergency access request</param>
/// <param name="userId">Id of the user (needs to be the grantor or grantee) trying to delete the emergency access</param>
/// <returns>void</returns>
Task DeleteAsync(Guid emergencyAccessId, Guid grantorId);
/// TODO: Remove this deprecated method with PM-31327
[Obsolete("Use IDeleteEmergencyAccessCommand.DeleteByIdAndUserIdAsync instead.")]
Task DeleteAsync(Guid emergencyAccessId, Guid userId);
/// <summary>
/// The grantor user confirms the acceptance of the emergency contact request. This stores the encrypted key allowing the grantee
/// access based on the emergency access type.

View File

@@ -1,35 +1,43 @@
using Bit.Core.Auth.Models.Data;
using Bit.Core.Exceptions;
using Bit.Core.Exceptions;
namespace Bit.Core.Auth.UserFeatures.EmergencyAccess.Interfaces;
/// <summary>
/// Command for deleting emergency access records based on the grantor's user ID.
/// Command for deleting emergency access records.
/// </summary>
public interface IDeleteEmergencyAccessCommand
{
/// <summary>
/// Deletes a single emergency access record for the specified grantor.
/// Deletes a single emergency access record if the requesting user is the grantor or grantee.
/// Sends an email notification to the grantor when a grantee is removed.
/// </summary>
/// <param name="emergencyAccessId">The ID of the emergency access record to delete.</param>
/// <param name="grantorId">The ID of the grantor user who owns the emergency access record.</param>
/// <param name="userId">The ID of the requesting user; must be either the grantor or grantee of the record.</param>
/// <returns>A task representing the asynchronous operation.</returns>
/// <exception cref="BadRequestException">
/// Thrown when the emergency access record is not found or does not belong to the specified grantor.
/// Thrown when the emergency access record is not found or does not belong to the specified user.
/// </exception>
Task<EmergencyAccessDetails> DeleteByIdGrantorIdAsync(Guid emergencyAccessId, Guid grantorId);
Task DeleteByIdAndUserIdAsync(Guid emergencyAccessId, Guid userId);
/// <summary>
/// Deletes all emergency access records for the specified grantor.
/// Deletes all emergency access records where the user IDs are either grantors or grantees.
/// Sends email notifications only to grantors when their grantees are removed.
/// </summary>
/// <param name="grantorId">The ID of the grantor user whose emergency access records should be deleted.</param>
/// <returns>A collection of the deleted emergency access records.</returns>
Task<ICollection<EmergencyAccessDetails>?> DeleteAllByGrantorIdAsync(Guid grantorId);
/// <param name="userIds">The IDs of users whose emergency access records — as grantor or grantee — will be deleted.</param>
/// <returns>A task representing the asynchronous operation.</returns>
/// <remarks>
/// If no records are found for the provided user IDs, the method returns.
/// </remarks>
Task DeleteAllByUserIdsAsync(ICollection<Guid> userIds);
/// <summary>
/// Deletes all emergency access records for the specified grantee.
/// Deletes all emergency access records where the user ID is either a grantor or a grantee.
/// Sends email notifications only to grantors when their grantees are removed.
/// </summary>
/// <param name="granteeId">The ID of the grantee user whose emergency access records should be deleted.</param>
/// <returns>A collection of the deleted emergency access records.</returns>
Task<ICollection<EmergencyAccessDetails>?> DeleteAllByGranteeIdAsync(Guid granteeId);
/// <param name="userId">The ID of the user whose emergency access records — as grantor or grantee — will be deleted.</param>
/// <returns>A task representing the asynchronous operation.</returns>
/// <remarks>
/// If no records are found for the provided user ID, the method returns.
/// </remarks>
Task DeleteAllByUserIdAsync(Guid userId);
}

View File

@@ -1,4 +1,5 @@
# Emergency Access System
This system allows users to share their `User.Key` with other users using public key exchange. An emergency contact (a grantee user) can view or takeover (reset the password) of the grantor user.
When an account is taken over all two factor methods are turned off and device verification is disabled.
@@ -6,6 +7,7 @@ When an account is taken over all two factor methods are turned off and device v
This system is affected by the Key Rotation feature. The `EmergencyAccess.KeyEncrypted` is the `Grantor.UserKey` encrypted by the `Grantee.PublicKey`. So if the `User.Key` is rotated then all `EmergencyAccess` entities will need to be updated.
## Special Cases
Users who use `KeyConnector` are not able to allow `Takeover` of their accounts. However, they can allow `View`.
When a grantee user _takes over_ a grantor user's account, the grantor user will be removed from all organizations where the grantor user is not the `OrganizationUserType.Owner`. A grantor user will not be removed from organizations if the `EmergencyAccessType` is `View`. The grantee user will only be able to `View` the grantor user's ciphers, and not any of the organization ciphers, if any exist.
@@ -16,6 +18,7 @@ A grantor user invites another user to be their emergency contact, the grantee.
The `EmergencyAccess.KeyEncrypted` field is empty, and the `GranteeId` is `null` since the user being invited via email may not have an account yet.
### code
```csharp
// creates entity.
Task<EmergencyAccess> InviteAsync(User grantorUser, string emergencyContactEmail, EmergencyAccessType accessType, int waitTime);
@@ -32,6 +35,7 @@ At this point the grantee user can accept the request. This will set the `Emerge
If the grantee user does not have an account then they can create an account and accept the invitation.
### Code
```csharp
// accepts the request to be an emergency contact.
Task<EmergencyAccess> AcceptUserAsync(Guid emergencyAccessId, User granteeUser, string token, IUserService userService);
@@ -44,6 +48,7 @@ Once the grantee user has accepted, the `EmergencyAccess.GranteeId` allows the g
The `EmergencyAccess.Status` is set to `Confirmed`, and the `EmergencyAccess.KeyEncrypted` is set.
### Code
```csharp
Task<EmergencyAccess> ConfirmUserAsync(Guid emergencyAccessId, string key, Guid grantorId);
```
@@ -53,6 +58,7 @@ Task<EmergencyAccess> ConfirmUserAsync(Guid emergencyAccessId, string key, Guid
The grantee user can now exercise the ability to view or takeover the account. This is done by initiating the recovery. Initiating recovery has a time delay specified by `EmergencyAccess.WaitTime`. `WaitTime` is set in the initial invite. The grantor user can approve the request before the `WaitTime`, but they _cannot_ reject the request _after_ the `WaitTime` has completed. If the recovery request is not rejected then once the `WaitTime` has passed the grantee user will be able to access the emergency access entity.
### Code
```csharp
// Initiates the recovery process; Will set EmergencyAccess.Status to RecoveryInitiated.
Task InitiateAsync(Guid id, User granteeUser);
@@ -69,6 +75,7 @@ Task HandleTimedOutRequestsAsync();
Once the `EmergencyAccess.Status` is `RecoveryApproved` the grantee user is able to exercise their ability to view or takeover the grantor account. Viewing allows the grantee user to view the vault data of the grantor user. Takeover allows the grantee to change the password of the grantor user.
### Takeover
`TakeoverAsync(Guid, User)` returns the grantor user object along with the `EmergencyAccess` entity. The grantor user object is required since to update the password the client needs access to the grantor kdf configuration. Once the password has been set in the `PasswordAsync(Guid, User, string, string)` the account has been successfully recovered.
Taking over the account will change the password of the grantor user, empty the two factor array on the grantor user, and disable device verification.
@@ -86,10 +93,11 @@ Task<AttachmentResponseData> GetAttachmentDownloadAsync(Guid emergencyAccessId,
## Optional steps
The grantor user is able to delete an emergency contact at anytime, at any point in the recovery process.
Either the grantor or grantee is able to delete an emergency access record at any time, at any point in the recovery process.
### Code
```csharp
// deletes the associated EmergencyAccess Entity
Task DeleteAsync(Guid emergencyAccessId, Guid grantorId);
// Deletes the associated EmergencyAccess entity. The requesting user must be the grantor or grantee.
Task DeleteByIdAndUserIdAsync(Guid emergencyAccessId, Guid userId);
```

View File

@@ -60,6 +60,19 @@ public class EmergencyAccessRepository : Repository<EmergencyAccess, Guid>, IEme
}
}
public async Task<ICollection<EmergencyAccessDetails>> GetManyDetailsByUserIdsAsync(ICollection<Guid> userIds)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<EmergencyAccessDetails>(
"[dbo].[EmergencyAccessDetails_ReadManyByUserIds]",
new { UserIds = userIds.ToGuidIdArrayTVP() },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}
public async Task<EmergencyAccessDetails?> GetDetailsByIdGrantorIdAsync(Guid id, Guid grantorId)
{
using (var connection = new SqlConnection(ConnectionString))
@@ -73,6 +86,19 @@ public class EmergencyAccessRepository : Repository<EmergencyAccess, Guid>, IEme
}
}
public async Task<EmergencyAccessDetails?> GetDetailsByIdAsync(Guid id)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<EmergencyAccessDetails>(
"[dbo].[EmergencyAccessDetails_ReadById]",
new { Id = id },
commandType: CommandType.StoredProcedure);
return results.FirstOrDefault();
}
}
public async Task<ICollection<EmergencyAccessNotify>> GetManyToNotifyAsync()
{
using (var connection = new SqlConnection(ConnectionString))

View File

@@ -29,6 +29,8 @@ public class EmergencyAccessRepository : Repository<Core.Auth.Entities.Emergency
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
// TODO: in future, this probably is not necessary as we have no synced EA data.
// if we delete from here, also delete from stored proc as well + update repo tests.
await dbContext.UserBumpAccountRevisionDateByEmergencyAccessGranteeIdAsync(emergencyAccess.Id);
await dbContext.SaveChangesAsync();
}
@@ -49,6 +51,17 @@ public class EmergencyAccessRepository : Repository<Core.Auth.Entities.Emergency
}
}
public async Task<EmergencyAccessDetails?> GetDetailsByIdAsync(Guid id)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var view = new EmergencyAccessDetailsViewQuery();
var query = view.Run(dbContext).Where(ea => ea.Id == id);
return await query.FirstOrDefaultAsync();
}
}
public async Task<ICollection<EmergencyAccessDetails>> GetExpiredRecoveriesAsync()
{
using (var scope = ServiceScopeFactory.CreateScope())
@@ -88,6 +101,20 @@ public class EmergencyAccessRepository : Repository<Core.Auth.Entities.Emergency
}
}
public async Task<ICollection<EmergencyAccessDetails>> GetManyDetailsByUserIdsAsync(ICollection<Guid> userIds)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var view = new EmergencyAccessDetailsViewQuery();
var query = view.Run(dbContext).Where(ea =>
userIds.Contains(ea.GrantorId) ||
(ea.GranteeId.HasValue && userIds.Contains(ea.GranteeId.Value))
);
return await query.ToListAsync();
}
}
public async Task<ICollection<EmergencyAccessNotify>> GetManyToNotifyAsync()
{
using (var scope = ServiceScopeFactory.CreateScope())
@@ -153,14 +180,7 @@ public class EmergencyAccessRepository : Repository<Core.Auth.Entities.Emergency
where emergencyAccessIds.Contains(ea.Id)
select ea;
var granteeIds = entitiesToRemove
.Where(ea => ea.Status == EmergencyAccessStatusType.Confirmed)
.Where(ea => ea.GranteeId.HasValue)
.Select(ea => ea.GranteeId!.Value) // .Value is safe here due to the Where above
.Distinct();
dbContext.EmergencyAccesses.RemoveRange(entitiesToRemove);
await dbContext.UserBumpManyAccountRevisionDatesAsync([.. granteeIds]);
await dbContext.SaveChangesAsync();
}
}

View File

@@ -32,8 +32,10 @@ public class EmergencyAccessDetailsViewQuery : IQuery<EmergencyAccessDetails>
RevisionDate = x.ea.RevisionDate,
GranteeName = x.grantee.Name,
GranteeEmail = x.grantee.Email ?? x.ea.Email,
GranteeAvatarColor = x.grantee.AvatarColor,
GrantorName = x.grantor.Name,
GrantorEmail = x.grantor.Email,
GrantorAvatarColor = x.grantor.AvatarColor,
});
}
}

View File

@@ -0,0 +1,13 @@
CREATE PROCEDURE [dbo].[EmergencyAccessDetails_ReadById]
@Id UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[EmergencyAccessDetailsView]
WHERE
[Id] = @Id
END

View File

@@ -0,0 +1,15 @@
CREATE PROCEDURE [dbo].[EmergencyAccessDetails_ReadManyByUserIds]
@UserIds [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[EmergencyAccessDetailsView]
WHERE
[GrantorId] IN (SELECT [Id] FROM @UserIds)
OR [GranteeId] IN (SELECT [Id] FROM @UserIds)
END
GO

View File

@@ -6,6 +6,7 @@ using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
@@ -19,17 +20,17 @@ public class DeleteEmergencyAccessCommandTests
/// throws a <see cref="BadRequestException"/> and does not call delete or send email.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteByIdGrantorIdAsync_EmergencyAccessNotFound_ThrowsBadRequest(
public async Task DeleteByIdAndUserIdAsync_EmergencyAccessNotFound_ThrowsBadRequestAsync(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
Guid emergencyAccessId,
Guid grantorId)
Guid userId)
{
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetDetailsByIdGrantorIdAsync(emergencyAccessId, grantorId)
.Returns((EmergencyAccessDetails)null);
.GetDetailsByIdAsync(emergencyAccessId)
.Returns((EmergencyAccessDetails?)null);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.DeleteByIdGrantorIdAsync(emergencyAccessId, grantorId));
() => sutProvider.Sut.DeleteByIdAndUserIdAsync(emergencyAccessId, userId));
Assert.Contains("Emergency Access not valid.", exception.Message);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
@@ -41,45 +42,194 @@ public class DeleteEmergencyAccessCommandTests
}
/// <summary>
/// Verifies successful deletion of an emergency access record by ID and grantor ID,
/// and ensures that a notification email is sent to the grantor.
/// Verifies that an emergency access record is deleted by ID and user ID,
/// and that a notification email is sent to the grantor.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteByIdGrantorIdAsync_DeletesEmergencyAccessAndSendsEmail(
public async Task DeleteByIdAndUserIdAsync_DeletesEmergencyAccessAndSendsEmailAsync(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
EmergencyAccessDetails emergencyAccessDetails)
{
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetDetailsByIdGrantorIdAsync(emergencyAccessDetails.Id, emergencyAccessDetails.GrantorId)
.GetDetailsByIdAsync(emergencyAccessDetails.Id)
.Returns(emergencyAccessDetails);
var result = await sutProvider.Sut.DeleteByIdGrantorIdAsync(emergencyAccessDetails.Id, emergencyAccessDetails.GrantorId);
await sutProvider.Sut.DeleteByIdAndUserIdAsync(emergencyAccessDetails.Id, emergencyAccessDetails.GrantorId);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.DeleteManyAsync(Arg.Any<ICollection<Guid>>());
.DeleteAsync(emergencyAccessDetails);
await sutProvider.GetDependency<IMailer>()
.Received(1)
.SendEmail(Arg.Any<EmergencyAccessRemoveGranteesMail>());
.SendEmail(Arg.Is<EmergencyAccessRemoveGranteesMail>(mail =>
mail.ToEmails.Contains(emergencyAccessDetails.GrantorEmail) &&
mail.View.RemovedGranteeEmails.Contains(emergencyAccessDetails.GranteeEmail)));
}
/// <summary>
/// Verifies that when a grantor has no emergency access records, the method returns
/// an empty collection and does not attempt to delete or send email.
/// Verifies that when the grantor email is null, the record is deleted
/// but no email notification is sent.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteAllByGrantorIdAsync_NoEmergencyAccessRecords_ReturnsEmptyCollection(
public async Task DeleteByIdAndUserIdAsync_NullGrantorEmail_DeletesButDoesNotSendEmailAsync(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
Guid grantorId)
EmergencyAccessDetails emergencyAccessDetails)
{
emergencyAccessDetails.GrantorEmail = null;
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetDetailsByIdAsync(emergencyAccessDetails.Id)
.Returns(emergencyAccessDetails);
await sutProvider.Sut.DeleteByIdAndUserIdAsync(emergencyAccessDetails.Id, emergencyAccessDetails.GrantorId);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.DeleteAsync(emergencyAccessDetails);
await sutProvider.GetDependency<IMailer>()
.DidNotReceiveWithAnyArgs()
.SendEmail<EmergencyAccessRemoveGranteesMailView>(default);
sutProvider.GetDependency<ILogger<DeleteEmergencyAccessCommand>>()
.Received(1)
.Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Is<object>(o => o.ToString().Contains(emergencyAccessDetails.GrantorId.ToString())
&& o.ToString().Contains("GrantorEmail missing: True")
&& o.ToString().Contains("GranteeEmail missing: False")),
null,
Arg.Any<Func<object, Exception?, string>>());
}
/// <summary>
/// Verifies that when the grantee email is null, the record is deleted
/// but no email notification is sent.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteByIdAndUserIdAsync_NullGranteeEmail_DeletesButDoesNotSendEmailAsync(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
EmergencyAccessDetails emergencyAccessDetails)
{
emergencyAccessDetails.GranteeEmail = null;
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetDetailsByIdAsync(emergencyAccessDetails.Id)
.Returns(emergencyAccessDetails);
await sutProvider.Sut.DeleteByIdAndUserIdAsync(emergencyAccessDetails.Id, emergencyAccessDetails.GrantorId);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.DeleteAsync(emergencyAccessDetails);
await sutProvider.GetDependency<IMailer>()
.DidNotReceiveWithAnyArgs()
.SendEmail<EmergencyAccessRemoveGranteesMailView>(default);
sutProvider.GetDependency<ILogger<DeleteEmergencyAccessCommand>>()
.Received(1)
.Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Is<object>(o => o.ToString().Contains(emergencyAccessDetails.GrantorId.ToString())
&& o.ToString().Contains("GrantorEmail missing: False")
&& o.ToString().Contains("GranteeEmail missing: True")),
null,
Arg.Any<Func<object, Exception?, string>>());
}
/// <summary>
/// Verifies that a grantee (not just a grantor) can delete an emergency access record,
/// and that the grantor still receives a notification email.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteByIdAndUserIdAsync_GranteeDeletes_DeletesAndSendsEmailAsync(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
EmergencyAccessDetails emergencyAccessDetails)
{
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetManyDetailsByGrantorIdAsync(grantorId)
.GetDetailsByIdAsync(emergencyAccessDetails.Id)
.Returns(emergencyAccessDetails);
// Act as the grantee, not the grantor
await sutProvider.Sut.DeleteByIdAndUserIdAsync(emergencyAccessDetails.Id, emergencyAccessDetails.GranteeId.Value);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.DeleteAsync(emergencyAccessDetails);
await sutProvider.GetDependency<IMailer>()
.Received(1)
.SendEmail(Arg.Is<EmergencyAccessRemoveGranteesMail>(mail =>
mail.ToEmails.Contains(emergencyAccessDetails.GrantorEmail) &&
mail.View.RemovedGranteeEmails.Contains(emergencyAccessDetails.GranteeEmail)));
}
/// <summary>
/// Verifies that a user who is neither the grantor nor the grantee cannot delete
/// the emergency access record and receives a <see cref="BadRequestException"/>.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteByIdAndUserIdAsync_UnauthorizedUser_ThrowsBadRequestAsync(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
EmergencyAccessDetails emergencyAccessDetails,
Guid unauthorizedUserId)
{
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetDetailsByIdAsync(emergencyAccessDetails.Id)
.Returns(emergencyAccessDetails);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.DeleteByIdAndUserIdAsync(emergencyAccessDetails.Id, unauthorizedUserId));
Assert.Contains("Emergency Access not valid.", exception.Message);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.DidNotReceiveWithAnyArgs()
.DeleteAsync(default);
await sutProvider.GetDependency<IMailer>()
.DidNotReceiveWithAnyArgs()
.SendEmail<EmergencyAccessRemoveGranteesMailView>(default);
}
/// <summary>
/// Verifies that <see cref="IDeleteEmergencyAccessCommand.DeleteAllByUserIdAsync"/> correctly
/// delegates to <see cref="IDeleteEmergencyAccessCommand.DeleteAllByUserIdsAsync"/>
/// using a single-element collection containing the provided user ID.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteAllByUserIdAsync_DelegatesToDeleteAllByUserIdsAsync(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
EmergencyAccessDetails emergencyAccessDetails,
Guid userId)
{
emergencyAccessDetails.GranteeId = userId;
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetManyDetailsByUserIdsAsync(Arg.Is<ICollection<Guid>>(ids => ids.Contains(userId)))
.Returns([emergencyAccessDetails]);
await sutProvider.Sut.DeleteAllByUserIdAsync(userId);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.GetManyDetailsByUserIdsAsync(Arg.Is<ICollection<Guid>>(ids =>
ids.Count == 1 && ids.Contains(userId)));
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.DeleteManyAsync(Arg.Is<ICollection<Guid>>(ids =>
ids.Count == 1 && ids.Contains(emergencyAccessDetails.Id)));
}
/// <summary>
/// Verifies that passing an empty list of user IDs does not attempt to delete or send email.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteAllByUserIdsAsync_EmptyList_DoesNotDeleteOrSendEmailAsync(
SutProvider<DeleteEmergencyAccessCommand> sutProvider)
{
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetManyDetailsByUserIdsAsync(Arg.Any<ICollection<Guid>>())
.Returns([]);
var result = await sutProvider.Sut.DeleteAllByGrantorIdAsync(grantorId);
await sutProvider.Sut.DeleteAllByUserIdsAsync([]);
Assert.NotNull(result);
Assert.Empty(result);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.DidNotReceiveWithAnyArgs()
.DeleteManyAsync(default);
@@ -89,92 +239,20 @@ public class DeleteEmergencyAccessCommandTests
}
/// <summary>
/// Verifies that when a grantor has multiple emergency access records, all records are deleted,
/// the details are returned, and a single notification email is sent to the grantor.
/// Verifies that when user IDs don't match any emergency access records,
/// the method does not attempt to delete or send email.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteAllByGrantorIdAsync_MultipleRecords_DeletesAllReturnsDetailsSendsSingleEmail(
public async Task DeleteAllByUserIdsAsync_NoRecordsFound_DoesNotDeleteOrSendEmailAsync(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
EmergencyAccessDetails emergencyAccessDetails1,
EmergencyAccessDetails emergencyAccessDetails2,
EmergencyAccessDetails emergencyAccessDetails3)
{
// Arrange
// link all details to the same grantor
emergencyAccessDetails2.GrantorId = emergencyAccessDetails1.GrantorId;
emergencyAccessDetails2.GrantorEmail = emergencyAccessDetails1.GrantorEmail;
emergencyAccessDetails3.GrantorId = emergencyAccessDetails1.GrantorId;
emergencyAccessDetails3.GrantorEmail = emergencyAccessDetails1.GrantorEmail;
var allDetails = new List<EmergencyAccessDetails>
{
emergencyAccessDetails1,
emergencyAccessDetails2,
emergencyAccessDetails3
};
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetManyDetailsByGrantorIdAsync(emergencyAccessDetails1.GrantorId)
.Returns(allDetails);
// Act
var result = await sutProvider.Sut.DeleteAllByGrantorIdAsync(emergencyAccessDetails1.GrantorId);
// Assert
Assert.NotNull(result);
Assert.Equal(3, result.Count);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.DeleteManyAsync(Arg.Any<ICollection<Guid>>());
await sutProvider.GetDependency<IMailer>()
.Received(1)
.SendEmail(Arg.Any<EmergencyAccessRemoveGranteesMail>());
}
/// <summary>
/// Verifies that when a grantor has a single emergency access record, it is deleted,
/// the details are returned, and a notification email is sent.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteAllByGrantorIdAsync_SingleRecord_DeletesAndReturnsDetailsSendsSingleEmail(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
EmergencyAccessDetails emergencyAccessDetails,
Guid grantorId)
List<Guid> userIds)
{
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetManyDetailsByGrantorIdAsync(grantorId)
.Returns([emergencyAccessDetails]);
var result = await sutProvider.Sut.DeleteAllByGrantorIdAsync(grantorId);
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal(emergencyAccessDetails.Id, result.First().Id);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.DeleteManyAsync(Arg.Any<ICollection<Guid>>());
await sutProvider.GetDependency<IMailer>()
.Received(1)
.SendEmail(Arg.Any<EmergencyAccessRemoveGranteesMail>());
}
/// <summary>
/// Verifies that when a grantee has no emergency access records, the method returns
/// an empty collection and does not attempt to delete or send email.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteAllByGranteeIdAsync_NoEmergencyAccessRecords_ReturnsEmptyCollection(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
Guid granteeId)
{
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetManyDetailsByGranteeIdAsync(granteeId)
.GetManyDetailsByUserIdsAsync(userIds)
.Returns([]);
var result = await sutProvider.Sut.DeleteAllByGranteeIdAsync(granteeId);
await sutProvider.Sut.DeleteAllByUserIdsAsync(userIds);
Assert.NotNull(result);
Assert.Empty(result);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.DidNotReceiveWithAnyArgs()
.DeleteManyAsync(default);
@@ -184,70 +262,614 @@ public class DeleteEmergencyAccessCommandTests
}
/// <summary>
/// Verifies that when a grantee has a single emergency access record, it is deleted,
/// the details are returned, and a notification email is sent to the grantor.
/// Verifies that when a single user ID is a grantee with multiple grantors,
/// all records are deleted and each grantor receives one email notification.
///
/// Scenario: Alice is a grantee with emergency access TO Bob's, Carol's, and David's vaults.
/// When Alice is removed, Bob, Carol, and David each receive an email notification.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteAllByGranteeIdAsync_SingleRecord_DeletesAndReturnsDetailsSendsSingleEmail(
public async Task DeleteAllByUserIdsAsync_SingleUserIdAsGranteeOnly_NotifiesGrantorsAsync(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
EmergencyAccessDetails emergencyAccessDetails,
Guid granteeId)
EmergencyAccessDetails bobAliceRecord,
EmergencyAccessDetails carolAliceRecord,
EmergencyAccessDetails davidAliceRecord,
Guid granteeUserIdAlice)
{
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetManyDetailsByGranteeIdAsync(granteeId)
.Returns([emergencyAccessDetails]);
var result = await sutProvider.Sut.DeleteAllByGranteeIdAsync(granteeId);
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal(emergencyAccessDetails.Id, result.First().Id);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.DeleteManyAsync(Arg.Any<ICollection<Guid>>());
await sutProvider.GetDependency<IMailer>()
.Received(1)
.SendEmail(Arg.Any<EmergencyAccessRemoveGranteesMail>());
}
/// <summary>
/// Verifies that when a grantee has multiple emergency access records from different grantors,
/// all records are deleted, the details are returned, and a single notification email is sent
/// to all affected grantors individually.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteAllByGranteeIdAsync_MultipleRecords_DeletesAllReturnsDetailsSendsMultipleEmails(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
EmergencyAccessDetails emergencyAccessDetails1,
EmergencyAccessDetails emergencyAccessDetails2,
EmergencyAccessDetails emergencyAccessDetails3)
{
// link all details to the same grantee
emergencyAccessDetails2.GranteeId = emergencyAccessDetails1.GranteeId;
emergencyAccessDetails2.GranteeEmail = emergencyAccessDetails1.GranteeEmail;
emergencyAccessDetails3.GranteeId = emergencyAccessDetails1.GranteeId;
emergencyAccessDetails3.GranteeEmail = emergencyAccessDetails1.GranteeEmail;
// Alice (grantee) has emergency access to Bob's, Carol's, and David's vaults
bobAliceRecord.GranteeId = granteeUserIdAlice; // Bob granted Alice access to Bob's vault
carolAliceRecord.GranteeId = granteeUserIdAlice; // Carol granted Alice access to Carol's vault
davidAliceRecord.GranteeId = granteeUserIdAlice; // David granted Alice access to David's vault
var allDetails = new List<EmergencyAccessDetails>
{
emergencyAccessDetails1,
emergencyAccessDetails2,
emergencyAccessDetails3
bobAliceRecord,
carolAliceRecord,
davidAliceRecord
};
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetManyDetailsByGranteeIdAsync((Guid)emergencyAccessDetails1.GranteeId)
.GetManyDetailsByUserIdsAsync(Arg.Is<ICollection<Guid>>(ids => ids.Contains(granteeUserIdAlice)))
.Returns(allDetails);
var result = await sutProvider.Sut.DeleteAllByGranteeIdAsync((Guid)emergencyAccessDetails1.GranteeId);
await sutProvider.Sut.DeleteAllByUserIdsAsync([granteeUserIdAlice]);
Assert.NotNull(result);
Assert.Equal(3, result.Count);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.DeleteManyAsync(Arg.Any<ICollection<Guid>>());
.DeleteManyAsync(Arg.Is<ICollection<Guid>>(ids =>
ids.Count == 3 &&
ids.Contains(bobAliceRecord.Id) &&
ids.Contains(carolAliceRecord.Id) &&
ids.Contains(davidAliceRecord.Id)));
// Each grantor gets one email
await sutProvider.GetDependency<IMailer>()
.Received(allDetails.Count)
.SendEmail(Arg.Any<EmergencyAccessRemoveGranteesMail>());
.Received(1)
.SendEmail(Arg.Is<EmergencyAccessRemoveGranteesMail>(mail =>
mail.ToEmails.Contains(bobAliceRecord.GrantorEmail)));
await sutProvider.GetDependency<IMailer>()
.Received(1)
.SendEmail(Arg.Is<EmergencyAccessRemoveGranteesMail>(mail =>
mail.ToEmails.Contains(carolAliceRecord.GrantorEmail)));
await sutProvider.GetDependency<IMailer>()
.Received(1)
.SendEmail(Arg.Is<EmergencyAccessRemoveGranteesMail>(mail =>
mail.ToEmails.Contains(davidAliceRecord.GrantorEmail)));
}
/// <summary>
/// Verifies that when a single user ID is a grantor with multiple grantees,
/// all records are deleted and the grantor is notified about their grantees being removed.
///
/// Scenario: Bob is a grantor who has given Alice and Carol emergency access to his vault.
/// When Bob is removed, Bob receives ONE email listing both Alice and Carol.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteAllByUserIdsAsync_SingleUserIdAsGrantorOnly_NotifiesGrantorAsync(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
EmergencyAccessDetails bobAliceRecord,
EmergencyAccessDetails bobCarolRecord,
Guid grantorUserIdBob)
{
// Bob (grantor) has given Alice and Carol emergency access to his vault
bobAliceRecord.GrantorId = grantorUserIdBob; // Bob granted Alice access to his vault
bobCarolRecord.GrantorId = grantorUserIdBob; // Bob granted Carol access to his vault
bobAliceRecord.GrantorEmail = "bob@example.com";
bobCarolRecord.GrantorEmail = "bob@example.com";
var allDetails = new List<EmergencyAccessDetails>
{
bobAliceRecord,
bobCarolRecord
};
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetManyDetailsByUserIdsAsync(Arg.Is<ICollection<Guid>>(ids => ids.Contains(grantorUserIdBob)))
.Returns(allDetails);
await sutProvider.Sut.DeleteAllByUserIdsAsync([grantorUserIdBob]);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.DeleteManyAsync(Arg.Is<ICollection<Guid>>(ids =>
ids.Count == 2 &&
ids.Contains(bobAliceRecord.Id) &&
ids.Contains(bobCarolRecord.Id)));
// Grantor receives one email listing both their grantees being removed
await sutProvider.GetDependency<IMailer>()
.Received(1)
.SendEmail(Arg.Is<EmergencyAccessRemoveGranteesMail>(mail =>
mail.ToEmails.Contains(bobAliceRecord.GrantorEmail) &&
mail.View.RemovedGranteeEmails.Contains(bobAliceRecord.GranteeEmail) &&
mail.View.RemovedGranteeEmails.Contains(bobCarolRecord.GranteeEmail)));
}
/// <summary>
/// Verifies that when a user ID is both a grantor and a grantee,
/// all affected grantors are notified: the user's grantors (for grantee role)
/// AND the user themselves (for grantor role with their grantees).
///
/// Scenario: Bob plays both roles:
/// - As GRANTEE: Bob has emergency access to Alice's and Carol's vaults
/// - As GRANTOR: Bob has given David and Emma emergency access to his vault
/// When Bob is removed, THREE emails are sent:
/// 1. Alice receives email: "Bob removed"
/// 2. Carol receives email: "Bob removed"
/// 3. Bob receives email: "David and Emma removed" (notified about his own grantees)
/// </summary>
[Theory, BitAutoData]
public async Task DeleteAllByUserIdsAsync_SingleUserIdBothRoles_NotifiesAllGrantorsAsync(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
EmergencyAccessDetails aliceBobRecord,
EmergencyAccessDetails carolBobRecord,
EmergencyAccessDetails bobDavidRecord,
EmergencyAccessDetails bobEmmaRecord,
Guid userIdBob)
{
// Bob as GRANTEE: has emergency access to Alice's and Carol's vaults
aliceBobRecord.GranteeId = userIdBob; // Alice granted Bob access to Alice's vault
aliceBobRecord.GranteeEmail = "bob@example.com";
carolBobRecord.GranteeId = userIdBob; // Carol granted Bob access to Carol's vault
carolBobRecord.GranteeEmail = "bob@example.com";
// Bob as GRANTOR: has given David and Emma emergency access to his vault
bobDavidRecord.GrantorId = userIdBob; // Bob granted David access to his vault
bobDavidRecord.GrantorEmail = "bob@example.com";
bobEmmaRecord.GrantorId = userIdBob; // Bob granted Emma access to his vault
bobEmmaRecord.GrantorEmail = "bob@example.com";
var allDetails = new List<EmergencyAccessDetails>
{
aliceBobRecord,
carolBobRecord,
bobDavidRecord,
bobEmmaRecord
};
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetManyDetailsByUserIdsAsync(Arg.Is<ICollection<Guid>>(ids => ids.Contains(userIdBob)))
.Returns(allDetails);
await sutProvider.Sut.DeleteAllByUserIdsAsync([userIdBob]);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.DeleteManyAsync(Arg.Is<ICollection<Guid>>(ids =>
ids.Count == 4 &&
ids.Contains(aliceBobRecord.Id) &&
ids.Contains(carolBobRecord.Id) &&
ids.Contains(bobDavidRecord.Id) &&
ids.Contains(bobEmmaRecord.Id)));
// Email 1: Alice receives "Bob removed" (Bob was grantee to Alice's vault)
await sutProvider.GetDependency<IMailer>()
.Received(1)
.SendEmail(Arg.Is<EmergencyAccessRemoveGranteesMail>(mail =>
mail.ToEmails.Contains(aliceBobRecord.GrantorEmail) &&
mail.View.RemovedGranteeEmails.Contains("bob@example.com")));
// Email 2: Carol receives "Bob removed" (Bob was grantee to Carol's vault)
await sutProvider.GetDependency<IMailer>()
.Received(1)
.SendEmail(Arg.Is<EmergencyAccessRemoveGranteesMail>(mail =>
mail.ToEmails.Contains(carolBobRecord.GrantorEmail) &&
mail.View.RemovedGranteeEmails.Contains("bob@example.com")));
// Email 3: Bob receives "David and Emma removed" (Bob was grantor, his grantees removed)
await sutProvider.GetDependency<IMailer>()
.Received(1)
.SendEmail(Arg.Is<EmergencyAccessRemoveGranteesMail>(mail =>
mail.ToEmails.Contains("bob@example.com") &&
mail.View.RemovedGranteeEmails.Contains(bobDavidRecord.GranteeEmail) &&
mail.View.RemovedGranteeEmails.Contains(bobEmmaRecord.GranteeEmail)));
}
/// <summary>
/// Verifies that multiple user IDs as grantees are properly deleted
/// and their respective grantors are notified.
///
/// Scenario: Alice and Bob are both grantees (to different grantors' vaults).
/// - Alice has emergency access to Carol's vault
/// - Bob has emergency access to David's vault
/// When Alice and Bob are removed, Carol and David each receive separate email notifications.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteAllByUserIdsAsync_MultipleUserIdsAllGrantees_SendsMultipleEmailsAsync(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
EmergencyAccessDetails carolAliceRecord,
EmergencyAccessDetails davidBobRecord,
Guid granteeUserIdAlice,
Guid granteeUserIdBob)
{
carolAliceRecord.GranteeId = granteeUserIdAlice; // Carol granted Alice access to Carol's vault
davidBobRecord.GranteeId = granteeUserIdBob; // David granted Bob access to David's vault
var allDetails = new List<EmergencyAccessDetails>
{
carolAliceRecord,
davidBobRecord
};
var userIds = new List<Guid> { granteeUserIdAlice, granteeUserIdBob };
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetManyDetailsByUserIdsAsync(userIds)
.Returns(allDetails);
await sutProvider.Sut.DeleteAllByUserIdsAsync(userIds);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.DeleteManyAsync(Arg.Is<ICollection<Guid>>(ids =>
ids.Count == 2 &&
ids.Contains(carolAliceRecord.Id) &&
ids.Contains(davidBobRecord.Id)));
// Carol gets email about Alice being removed
await sutProvider.GetDependency<IMailer>()
.Received(1)
.SendEmail(Arg.Is<EmergencyAccessRemoveGranteesMail>(mail =>
mail.ToEmails.Contains(carolAliceRecord.GrantorEmail)));
// David gets email about Bob being removed
await sutProvider.GetDependency<IMailer>()
.Received(1)
.SendEmail(Arg.Is<EmergencyAccessRemoveGranteesMail>(mail =>
mail.ToEmails.Contains(davidBobRecord.GrantorEmail)));
}
/// <summary>
/// Verifies that multiple user IDs as grantors are properly deleted
/// and each grantor is notified about their grantees being removed.
///
/// Scenario: Bob and Carol are both grantors (vault owners with grantees).
/// - Bob has given Alice emergency access to his vault
/// - Carol has given David emergency access to her vault
/// When Bob and Carol are removed, they each receive separate email notifications:
/// - Bob receives email: "Alice removed"
/// - Carol receives email: "David removed"
/// </summary>
[Theory, BitAutoData]
public async Task DeleteAllByUserIdsAsync_MultipleUserIdsAllGrantors_NotifiesEachGrantorAsync(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
EmergencyAccessDetails bobAliceRecord,
EmergencyAccessDetails carolDavidRecord,
Guid grantorUserIdBob,
Guid grantorUserIdCarol)
{
bobAliceRecord.GrantorId = grantorUserIdBob; // Bob (grantor) has given Alice emergency access
carolDavidRecord.GrantorId = grantorUserIdCarol; // Carol (grantor) has given David emergency access
var allDetails = new List<EmergencyAccessDetails>
{
bobAliceRecord,
carolDavidRecord
};
var userIds = new List<Guid> { grantorUserIdBob, grantorUserIdCarol };
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetManyDetailsByUserIdsAsync(userIds)
.Returns(allDetails);
await sutProvider.Sut.DeleteAllByUserIdsAsync(userIds);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.DeleteManyAsync(Arg.Is<ICollection<Guid>>(ids =>
ids.Count == 2 &&
ids.Contains(bobAliceRecord.Id) &&
ids.Contains(carolDavidRecord.Id)));
// Bob gets email about Alice being removed
await sutProvider.GetDependency<IMailer>()
.Received(1)
.SendEmail(Arg.Is<EmergencyAccessRemoveGranteesMail>(mail =>
mail.ToEmails.Contains(bobAliceRecord.GrantorEmail) &&
mail.View.RemovedGranteeEmails.Contains(bobAliceRecord.GranteeEmail)));
// Carol gets email about David being removed
await sutProvider.GetDependency<IMailer>()
.Received(1)
.SendEmail(Arg.Is<EmergencyAccessRemoveGranteesMail>(mail =>
mail.ToEmails.Contains(carolDavidRecord.GrantorEmail) &&
mail.View.RemovedGranteeEmails.Contains(carolDavidRecord.GranteeEmail)));
}
/// <summary>
/// Verifies that when multiple grantees share overlapping grantors,
/// each grantor receives exactly one email with only their specific removed grantees.
///
/// Scenario: Ali and Bob are grantees being removed, with overlapping grantors:
/// - Cara granted Ali emergency access to her vault
/// - Dave granted Ali and Bob emergency access to his vault
/// - Eve granted Bob emergency access to her vault
/// Expected email notifications:
/// - Cara receives email: "Ali removed" (only Ali, not Bob)
/// - Dave receives email: "Ali and Bob removed" (both, since Dave is shared)
/// - Eve receives email: "Bob removed" (only Bob, not Ali)
/// </summary>
[Theory, BitAutoData]
public async Task DeleteAllByUserIdsAsync_MultipleUsersOverlappingGrantors_EachGrantorGetsCorrectSubsetAsync(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
Guid granteeUserIdAli,
Guid granteeUserIdBob,
string grantorEmailCara,
string grantorEmailDave,
string grantorEmailEve,
string granteeEmailAli,
string granteeEmailBob)
{
// GrantorId is not set on these records as the command only uses GrantorEmail for
// grouping and notification — GrantorId plays no role in the logic under test.
// Cara (grantor) granted Ali emergency access to her vault
var caraAliRecord = new EmergencyAccessDetails
{
Id = Guid.NewGuid(),
GranteeId = granteeUserIdAli,
GranteeEmail = granteeEmailAli,
GrantorEmail = grantorEmailCara
};
// Dave (grantor) granted Ali emergency access to his vault
var daveAliRecord = new EmergencyAccessDetails
{
Id = Guid.NewGuid(),
GranteeId = granteeUserIdAli,
GranteeEmail = granteeEmailAli,
GrantorEmail = grantorEmailDave // Dave also granted Bob access
};
// Dave (grantor) granted Bob emergency access to his vault
var daveBobRecord = new EmergencyAccessDetails
{
Id = Guid.NewGuid(),
GranteeId = granteeUserIdBob,
GranteeEmail = granteeEmailBob,
GrantorEmail = grantorEmailDave // Dave also granted Ali access
};
// Eve (grantor) granted Bob emergency access to her vault
var eveBobRecord = new EmergencyAccessDetails
{
Id = Guid.NewGuid(),
GranteeId = granteeUserIdBob,
GranteeEmail = granteeEmailBob,
GrantorEmail = grantorEmailEve
};
var allDetails = new List<EmergencyAccessDetails> { caraAliRecord, daveAliRecord, daveBobRecord, eveBobRecord };
var userIdsToDelete = new List<Guid> { granteeUserIdAli, granteeUserIdBob };
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetManyDetailsByUserIdsAsync(userIdsToDelete)
.Returns(allDetails);
await sutProvider.Sut.DeleteAllByUserIdsAsync(userIdsToDelete);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.DeleteManyAsync(Arg.Is<ICollection<Guid>>(ids =>
ids.Count == 4 &&
ids.Contains(caraAliRecord.Id) &&
ids.Contains(daveAliRecord.Id) &&
ids.Contains(daveBobRecord.Id) &&
ids.Contains(eveBobRecord.Id)));
// Cara gets email with only Ali
await sutProvider.GetDependency<IMailer>()
.Received(1)
.SendEmail(Arg.Is<EmergencyAccessRemoveGranteesMail>(mail =>
mail.ToEmails.Contains(grantorEmailCara) &&
mail.View.RemovedGranteeEmails.Contains(granteeEmailAli)));
// Dave gets email with both Ali and Bob (shared grantor)
await sutProvider.GetDependency<IMailer>()
.Received(1)
.SendEmail(Arg.Is<EmergencyAccessRemoveGranteesMail>(mail =>
mail.ToEmails.Contains(grantorEmailDave) &&
mail.View.RemovedGranteeEmails.Contains(granteeEmailAli) &&
mail.View.RemovedGranteeEmails.Contains(granteeEmailBob)));
// Eve gets email with only Bob
await sutProvider.GetDependency<IMailer>()
.Received(1)
.SendEmail(Arg.Is<EmergencyAccessRemoveGranteesMail>(mail =>
mail.ToEmails.Contains(grantorEmailEve) &&
mail.View.RemovedGranteeEmails.Contains(granteeEmailBob)));
}
/// <summary>
/// Verifies that records with null grantee emails are handled gracefully
/// and don't cause errors during email notification processing.
///
/// Scenario: Bob granted Alice emergency access to his vault. Alice accepted, so EA.Email
/// was cleared and only her user account held her email. Alice's account was later deleted,
/// leaving both GranteeU.Email (LEFT JOIN miss) and EA.Email null — so GranteeEmail is null.
/// The record is deleted but no email is sent because there's no valid grantee email to include.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteAllByUserIdsAsync_NullGranteeEmail_HandledGracefullyAsync(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
Guid granteeUserIdAlice,
string grantorEmailBob)
{
// Alice accepted EA (EA.Email cleared), then her account was deleted (LEFT JOIN miss) — GranteeEmail is null
var bobAliceRecord = new EmergencyAccessDetails
{
Id = Guid.NewGuid(),
GranteeId = granteeUserIdAlice, // Alice's user ID (account since deleted)
GranteeEmail = null, // Null: EA.Email was cleared on accept, user account no longer exists
GrantorEmail = grantorEmailBob // Bob's email
};
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetManyDetailsByUserIdsAsync(Arg.Is<ICollection<Guid>>(ids => ids.Contains(granteeUserIdAlice)))
.Returns([bobAliceRecord]);
await sutProvider.Sut.DeleteAllByUserIdsAsync([granteeUserIdAlice]);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.DeleteManyAsync(Arg.Is<ICollection<Guid>>(ids =>
ids.Count == 1 && ids.Contains(bobAliceRecord.Id)));
// Email should not be sent if grantee email is null
await sutProvider.GetDependency<IMailer>()
.DidNotReceiveWithAnyArgs()
.SendEmail<EmergencyAccessRemoveGranteesMailView>(default);
sutProvider.GetDependency<ILogger<DeleteEmergencyAccessCommand>>()
.Received(1)
.Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Is<object>(o => o.ToString().Contains(granteeUserIdAlice.ToString())
&& o.ToString().Contains("missing GranteeEmail")),
null,
Arg.Any<Func<object, Exception?, string>>());
}
/// <summary>
/// Verifies that records with null grantor emails are filtered out
/// and don't cause errors during email notification processing.
///
/// Scenario: Bob granted Alice emergency access to his vault, then Bob's account was deleted.
/// Unlike GranteeEmail (which falls back to EA.Email), GrantorEmail has no fallback — it comes
/// entirely from the LEFT JOIN on the User table. When the grantor's account is deleted,
/// the LEFT JOIN misses and GrantorEmail is null. The record is deleted but no email is sent.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteAllByUserIdsAsync_NullGrantorEmail_DeletesButDoesNotSendEmailAsync(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
Guid grantorUserIdBob,
string granteeEmailAlice)
{
// Bob's account was deleted — LEFT JOIN misses, no fallback column exists for GrantorEmail
var bobAliceRecord = new EmergencyAccessDetails
{
Id = Guid.NewGuid(),
GrantorId = grantorUserIdBob, // Bob's user ID (account since deleted)
GrantorEmail = null, // Null: no EA.Email fallback exists for grantors, account no longer exists
GranteeEmail = granteeEmailAlice // Alice's email
};
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetManyDetailsByUserIdsAsync(Arg.Is<ICollection<Guid>>(ids => ids.Contains(grantorUserIdBob)))
.Returns([bobAliceRecord]);
await sutProvider.Sut.DeleteAllByUserIdsAsync([grantorUserIdBob]);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.DeleteManyAsync(Arg.Is<ICollection<Guid>>(ids =>
ids.Count == 1 && ids.Contains(bobAliceRecord.Id)));
// Email should not be sent if grantor email is null
await sutProvider.GetDependency<IMailer>()
.DidNotReceiveWithAnyArgs()
.SendEmail<EmergencyAccessRemoveGranteesMailView>(default);
sutProvider.GetDependency<ILogger<DeleteEmergencyAccessCommand>>()
.Received(1)
.Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Is<object>(o => o.ToString().Contains(grantorUserIdBob.ToString())
&& o.ToString().Contains("missing GrantorEmail")),
null,
Arg.Any<Func<object, Exception?, string>>());
}
/// <summary>
/// Verifies that duplicate grantee emails for the same grantor are deduplicated
/// so the grantor receives exactly one email listing each grantee address only once.
///
/// Scenario: Bob has two EA records pointing to the same grantee email (e.g., from
/// a re-invite edge case where the prior record wasn't cleaned up). When Bob is removed,
/// he receives ONE email listing the grantee's email only once — not duplicated.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteAllByUserIdsAsync_DuplicateGranteeEmails_DeduplicatesEmailsInNotificationAsync(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
Guid grantorUserIdBob,
string granteeEmailAlice)
{
const string grantorEmailBob = "bob@example.com";
// Two records sharing the same grantor and grantee email — grantee email should be deduplicated
var bobAliceRecord1 = new EmergencyAccessDetails
{
Id = Guid.NewGuid(),
GrantorId = grantorUserIdBob,
GrantorEmail = grantorEmailBob,
GranteeEmail = granteeEmailAlice
};
var bobAliceRecord2 = new EmergencyAccessDetails
{
Id = Guid.NewGuid(),
GrantorId = grantorUserIdBob,
GrantorEmail = grantorEmailBob,
GranteeEmail = granteeEmailAlice // Same grantee email as record 1
};
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetManyDetailsByUserIdsAsync(Arg.Is<ICollection<Guid>>(ids => ids.Contains(grantorUserIdBob)))
.Returns([bobAliceRecord1, bobAliceRecord2]);
await sutProvider.Sut.DeleteAllByUserIdsAsync([grantorUserIdBob]);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.DeleteManyAsync(Arg.Is<ICollection<Guid>>(ids =>
ids.Count == 2 &&
ids.Contains(bobAliceRecord1.Id) &&
ids.Contains(bobAliceRecord2.Id)));
// Bob receives one email with the grantee email appearing exactly once
await sutProvider.GetDependency<IMailer>()
.Received(1)
.SendEmail(Arg.Is<EmergencyAccessRemoveGranteesMail>(mail =>
mail.ToEmails.Contains(grantorEmailBob) &&
mail.View.RemovedGranteeEmails.Count() == 1 &&
mail.View.RemovedGranteeEmails.Contains(granteeEmailAlice)));
}
/// <summary>
/// Verifies that when a grantor has multiple grantees and only some have null emails,
/// the grantor still receives an email listing only the non-null grantee emails.
///
/// Scenario: Bob granted Alice and Carol emergency access to his vault.
/// Carol's account was later deleted, leaving her GranteeEmail null.
/// When Bob is removed, he receives ONE email listing only Alice — Carol is excluded
/// because there is no valid email address to include.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteAllByUserIdsAsync_PartialNullGranteeEmails_SendsEmailForNonNullGranteesOnlyAsync(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
Guid grantorUserIdBob,
Guid granteeUserIdCarol,
string granteeEmailAlice)
{
const string grantorEmailBob = "bob@example.com";
// Alice's record: valid grantee email
var bobAliceRecord = new EmergencyAccessDetails
{
Id = Guid.NewGuid(),
GrantorId = grantorUserIdBob,
GrantorEmail = grantorEmailBob,
GranteeEmail = granteeEmailAlice
};
// Carol's record: null grantee email (her account was deleted)
var bobCarolRecord = new EmergencyAccessDetails
{
Id = Guid.NewGuid(),
GrantorId = grantorUserIdBob,
GrantorEmail = grantorEmailBob,
GranteeId = granteeUserIdCarol, // Carol's user ID (account since deleted)
GranteeEmail = null
};
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetManyDetailsByUserIdsAsync(Arg.Is<ICollection<Guid>>(ids => ids.Contains(grantorUserIdBob)))
.Returns([bobAliceRecord, bobCarolRecord]);
await sutProvider.Sut.DeleteAllByUserIdsAsync([grantorUserIdBob]);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.DeleteManyAsync(Arg.Is<ICollection<Guid>>(ids =>
ids.Count == 2 &&
ids.Contains(bobAliceRecord.Id) &&
ids.Contains(bobCarolRecord.Id)));
// Bob receives one email listing only Alice (Carol excluded — null grantee email)
await sutProvider.GetDependency<IMailer>()
.Received(1)
.SendEmail(Arg.Is<EmergencyAccessRemoveGranteesMail>(mail =>
mail.ToEmails.Contains(grantorEmailBob) &&
mail.View.RemovedGranteeEmails.Count() == 1 &&
mail.View.RemovedGranteeEmails.Contains(granteeEmailAlice)));
// Carol's record (null grantee email) should trigger a warning with her user ID
sutProvider.GetDependency<ILogger<DeleteEmergencyAccessCommand>>()
.Received(1)
.Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Is<object>(o => o.ToString().Contains(granteeUserIdCarol.ToString())
&& o.ToString().Contains("missing GranteeEmail")),
null,
Arg.Any<Func<object, Exception?, string>>());
}
}

View File

@@ -389,7 +389,7 @@ public class EmergencyAccessServiceTests
[Theory, BitAutoData]
public async Task DeleteAsync_EmergencyAccessGrantorIdNotEqual_ThrowsBadRequest(
SutProvider<EmergencyAccessService> sutProvider,
User invitingUser,
User randomUser,
Core.Auth.Entities.EmergencyAccess emergencyAccess)
{
emergencyAccess.GrantorId = Guid.NewGuid();
@@ -398,7 +398,7 @@ public class EmergencyAccessServiceTests
.Returns(emergencyAccess);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.DeleteAsync(emergencyAccess.Id, invitingUser.Id));
() => sutProvider.Sut.DeleteAsync(emergencyAccess.Id, randomUser.Id));
Assert.Contains("Emergency Access not valid.", exception.Message);
}
@@ -406,7 +406,7 @@ public class EmergencyAccessServiceTests
[Theory, BitAutoData]
public async Task DeleteAsync_EmergencyAccessGranteeIdNotEqual_ThrowsBadRequest(
SutProvider<EmergencyAccessService> sutProvider,
User invitingUser,
User randomUser,
Core.Auth.Entities.EmergencyAccess emergencyAccess)
{
emergencyAccess.GranteeId = Guid.NewGuid();
@@ -415,28 +415,49 @@ public class EmergencyAccessServiceTests
.Returns(emergencyAccess);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.DeleteAsync(emergencyAccess.Id, invitingUser.Id));
() => sutProvider.Sut.DeleteAsync(emergencyAccess.Id, randomUser.Id));
Assert.Contains("Emergency Access not valid.", exception.Message);
}
[Theory, BitAutoData]
public async Task DeleteAsync_EmergencyAccessIsDeleted_Success(
SutProvider<EmergencyAccessService> sutProvider,
User user,
Core.Auth.Entities.EmergencyAccess emergencyAccess)
public async Task DeleteAsync_GrantorDeletes_Success(
SutProvider<EmergencyAccessService> sutProvider,
User grantor,
User grantee,
Core.Auth.Entities.EmergencyAccess emergencyAccess)
{
emergencyAccess.GranteeId = user.Id;
emergencyAccess.GrantorId = user.Id;
emergencyAccess.GrantorId = grantor.Id;
emergencyAccess.GranteeId = grantee.Id;
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(emergencyAccess);
.GetByIdAsync(Arg.Any<Guid>())
.Returns(emergencyAccess);
await sutProvider.Sut.DeleteAsync(emergencyAccess.Id, user.Id);
await sutProvider.Sut.DeleteAsync(emergencyAccess.Id, grantor.Id);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.DeleteAsync(emergencyAccess);
.Received(1)
.DeleteAsync(emergencyAccess);
}
[Theory, BitAutoData]
public async Task DeleteAsync_GranteeDeletes_Success(
SutProvider<EmergencyAccessService> sutProvider,
User grantor,
User grantee,
Core.Auth.Entities.EmergencyAccess emergencyAccess)
{
emergencyAccess.GrantorId = grantor.Id;
emergencyAccess.GranteeId = grantee.Id;
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(emergencyAccess);
await sutProvider.Sut.DeleteAsync(emergencyAccess.Id, grantee.Id);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.DeleteAsync(emergencyAccess);
}
[Theory, BitAutoData]

View File

@@ -48,7 +48,7 @@ public class EmergencyAccessRepositoriesTests
/// All 3 records are then deleted in a single call to DeleteManyAsync.
/// </summary>
[DatabaseTheory, DatabaseData]
public async Task DeleteManyAsync_DeletesMultipleGranteeRecords_UpdatesUserRevisionDates(
public async Task DeleteManyAsync_DeletesMultipleGranteeRecordsAsync(
IUserRepository userRepository,
IEmergencyAccessRepository emergencyAccessRepository)
{
@@ -77,9 +77,6 @@ public class EmergencyAccessRepositoriesTests
SecurityStamp = "stamp",
});
// The inmemory datetime has a precision issue, so we need to refresh the user to get the stored AccountRevisionDate
invitedGranteeUser2 = await userRepository.GetByIdAsync(invitedGranteeUser2.Id);
var granteeUser3 = await userRepository.CreateAsync(new User
{
Name = "Test Grantee User 3",
@@ -124,15 +121,329 @@ public class EmergencyAccessRepositoriesTests
var granteeEmergencyAccess = await emergencyAccessRepository.GetManyDetailsByGranteeIdAsync(grantee.Id);
Assert.Empty(granteeEmergencyAccess);
}
}
// Only the Status.Confirmed grantee's AccountRevisionDate should be updated
var updatedConfirmedGrantee = await userRepository.GetByIdAsync(confirmedGranteeUser1.Id);
Assert.NotNull(updatedConfirmedGrantee);
Assert.NotEqual(updatedConfirmedGrantee.AccountRevisionDate, confirmedGranteeUser1.AccountRevisionDate);
/// <summary>
/// Verifies GetManyDetailsByUserIdsAsync returns all emergency access records
/// where the user IDs are either grantors or grantees.
/// </summary>
[DatabaseTheory, DatabaseData]
public async Task GetManyDetailsByUserIdsAsync_ReturnsAllMatchingRecords(
IUserRepository userRepository,
IEmergencyAccessRepository emergencyAccessRepository)
{
// Arrange - Create users
var grantorUser1 = await userRepository.CreateAsync(new User
{
Name = "Grantor 1",
Email = $"test+grantor1{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
// Invited user should not have an updated AccountRevisionDate
var updatedInvitedGrantee = await userRepository.GetByIdAsync(invitedGranteeUser2.Id);
Assert.NotNull(updatedInvitedGrantee);
Assert.Equal(updatedInvitedGrantee.AccountRevisionDate, invitedGranteeUser2.AccountRevisionDate);
var grantorUser2 = await userRepository.CreateAsync(new User
{
Name = "Grantor 2",
Email = $"test+grantor2{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
var granteeUser1 = await userRepository.CreateAsync(new User
{
Name = "Grantee 1",
Email = $"test+grantee1{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
var granteeUser2 = await userRepository.CreateAsync(new User
{
Name = "Grantee 2",
Email = $"test+grantee2{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
// Create emergency access records
// Grantor1 -> Grantee1 (matches both queried IDs)
var ea1 = await emergencyAccessRepository.CreateAsync(new EmergencyAccess
{
GrantorId = grantorUser1.Id,
GranteeId = granteeUser1.Id,
Status = EmergencyAccessStatusType.Confirmed,
});
// Grantor2 -> Grantee1 (matches via grantee)
var ea2 = await emergencyAccessRepository.CreateAsync(new EmergencyAccess
{
GrantorId = grantorUser2.Id,
GranteeId = granteeUser1.Id,
Status = EmergencyAccessStatusType.Confirmed,
});
// Grantor1 -> Grantee2 (matches via grantor)
var ea3 = await emergencyAccessRepository.CreateAsync(new EmergencyAccess
{
GrantorId = grantorUser1.Id,
GranteeId = granteeUser2.Id,
Status = EmergencyAccessStatusType.Accepted,
});
// Grantor2 -> Grantee2 (should NOT be returned - neither user is in the query)
await emergencyAccessRepository.CreateAsync(new EmergencyAccess
{
GrantorId = grantorUser2.Id,
GranteeId = granteeUser2.Id,
Status = EmergencyAccessStatusType.Confirmed,
});
// Act - Query with Grantor1 and Grantee1 user IDs
var userIds = new List<Guid> { grantorUser1.Id, granteeUser1.Id };
var results = await emergencyAccessRepository.GetManyDetailsByUserIdsAsync(userIds);
// Assert - Should return exactly the 3 records involving Grantor1 or Grantee1:
// - Grantor1 -> Grantee1 (matches both, returned once)
// - Grantor2 -> Grantee1 (matches via grantee)
// - Grantor1 -> Grantee2 (matches via grantor)
Assert.NotNull(results);
Assert.Equal(3, results.Count);
var resultIds = results.Select(r => r.Id).ToHashSet();
Assert.Contains(ea1.Id, resultIds);
Assert.Contains(ea2.Id, resultIds);
Assert.Contains(ea3.Id, resultIds);
}
/// <summary>
/// Verifies GetManyDetailsByUserIdsAsync handles an empty list gracefully
/// and returns an empty collection.
/// </summary>
[DatabaseTheory, DatabaseData]
public async Task GetManyDetailsByUserIdsAsync_HandlesEmptyList(
IEmergencyAccessRepository emergencyAccessRepository)
{
// Act
var results = await emergencyAccessRepository.GetManyDetailsByUserIdsAsync([]);
// Assert
Assert.NotNull(results);
Assert.Empty(results);
}
/// <summary>
/// Verifies GetManyDetailsByUserIdsAsync includes full details from both
/// grantor and grantee users (emails, names populated via JOIN).
/// </summary>
[DatabaseTheory, DatabaseData]
public async Task GetManyDetailsByUserIdsAsync_IncludesDetailsFromBothUsers(
IUserRepository userRepository,
IEmergencyAccessRepository emergencyAccessRepository)
{
// Arrange
var grantorEmail = $"test+grantor{Guid.NewGuid()}@email.com";
var granteeEmail = $"test+grantee{Guid.NewGuid()}@email.com";
var grantorUser = await userRepository.CreateAsync(new User
{
Name = "Grantor Name",
Email = grantorEmail,
AvatarColor = "#ff0000",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
var granteeUser = await userRepository.CreateAsync(new User
{
Name = "Grantee Name",
Email = granteeEmail,
AvatarColor = "#0000ff",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
await emergencyAccessRepository.CreateAsync(new EmergencyAccess
{
GrantorId = grantorUser.Id,
GranteeId = granteeUser.Id,
Status = EmergencyAccessStatusType.Confirmed,
});
// Act
var results = await emergencyAccessRepository.GetManyDetailsByUserIdsAsync([grantorUser.Id]);
// Assert
Assert.NotNull(results);
Assert.Single(results);
var record = results.First();
Assert.Equal(grantorEmail, record.GrantorEmail);
Assert.Equal(granteeEmail, record.GranteeEmail);
Assert.Equal("Grantor Name", record.GrantorName);
Assert.Equal("Grantee Name", record.GranteeName);
Assert.Equal("#ff0000", record.GrantorAvatarColor);
Assert.Equal("#0000ff", record.GranteeAvatarColor);
}
/// <summary>
/// Verifies GetManyDetailsByUserIdsAsync returns records when the queried user ID
/// appears only as a grantee (not as a grantor in any record).
/// </summary>
[DatabaseTheory, DatabaseData]
public async Task GetManyDetailsByUserIdsAsync_GranteeOnlyQuery_ReturnsMatchingRecordsAsync(
IUserRepository userRepository,
IEmergencyAccessRepository emergencyAccessRepository)
{
// Arrange
var grantorUser = await userRepository.CreateAsync(new User
{
Name = "Grantor",
Email = $"test+grantor{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
var granteeUser = await userRepository.CreateAsync(new User
{
Name = "Grantee",
Email = $"test+grantee{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
var unrelatedUser = await userRepository.CreateAsync(new User
{
Name = "Unrelated",
Email = $"test+unrelated{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
var expectedRecord = await emergencyAccessRepository.CreateAsync(new EmergencyAccess
{
GrantorId = grantorUser.Id,
GranteeId = granteeUser.Id,
Status = EmergencyAccessStatusType.Confirmed,
});
// Record that should NOT be returned - granteeUser is not involved
await emergencyAccessRepository.CreateAsync(new EmergencyAccess
{
GrantorId = grantorUser.Id,
GranteeId = unrelatedUser.Id,
Status = EmergencyAccessStatusType.Confirmed,
});
// Act - query using only the grantee's ID; granteeUser has no grantor records
var results = await emergencyAccessRepository.GetManyDetailsByUserIdsAsync([granteeUser.Id]);
// Assert
Assert.NotNull(results);
Assert.Single(results);
Assert.Equal(expectedRecord.Id, results.First().Id);
}
/// <summary>
/// Verifies GetDetailsByIdAsync returns the correct EmergencyAccessDetails record,
/// including email and name fields populated via the view JOIN.
/// </summary>
[DatabaseTheory, DatabaseData]
public async Task GetDetailsByIdAsync_ReturnsDetails_WhenRecordExistsAsync(
IUserRepository userRepository,
IEmergencyAccessRepository emergencyAccessRepository)
{
// Arrange
var grantorEmail = $"test+grantor{Guid.NewGuid()}@email.com";
var granteeEmail = $"test+grantee{Guid.NewGuid()}@email.com";
var grantorUser = await userRepository.CreateAsync(new User
{
Name = "Grantor Name",
Email = grantorEmail,
ApiKey = "TEST",
SecurityStamp = "stamp",
});
var granteeUser = await userRepository.CreateAsync(new User
{
Name = "Grantee Name",
Email = granteeEmail,
ApiKey = "TEST",
SecurityStamp = "stamp",
});
var ea = await emergencyAccessRepository.CreateAsync(new EmergencyAccess
{
GrantorId = grantorUser.Id,
GranteeId = granteeUser.Id,
Status = EmergencyAccessStatusType.Confirmed,
});
// Act
var result = await emergencyAccessRepository.GetDetailsByIdAsync(ea.Id);
// Assert
Assert.NotNull(result);
Assert.Equal(ea.Id, result.Id);
Assert.Equal(grantorEmail, result.GrantorEmail);
Assert.Equal(granteeEmail, result.GranteeEmail);
}
/// <summary>
/// Verifies GetDetailsByIdAsync returns null when no record matches the given ID.
/// </summary>
[DatabaseTheory, DatabaseData]
public async Task GetDetailsByIdAsync_ReturnsNull_WhenRecordDoesNotExistAsync(
IEmergencyAccessRepository emergencyAccessRepository)
{
// Act
var result = await emergencyAccessRepository.GetDetailsByIdAsync(Guid.NewGuid());
// Assert
Assert.Null(result);
}
/// <summary>
/// Verifies GetManyDetailsByUserIdsAsync returns invited emergency access records
/// (where GranteeId is null and only Email is set) when querying by grantor ID,
/// and that GranteeEmail falls back to the invite email address.
/// </summary>
[DatabaseTheory, DatabaseData]
public async Task GetManyDetailsByUserIdsAsync_InvitedRecord_ReturnedByGrantorIdAsync(
IUserRepository userRepository,
IEmergencyAccessRepository emergencyAccessRepository)
{
// Arrange
var grantorUser = await userRepository.CreateAsync(new User
{
Name = "Grantor",
Email = $"test+grantor{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
var inviteEmail = $"test+invited{Guid.NewGuid()}@email.com";
var invitedRecord = await emergencyAccessRepository.CreateAsync(new EmergencyAccess
{
GrantorId = grantorUser.Id,
GranteeId = null,
Email = inviteEmail,
Status = EmergencyAccessStatusType.Invited,
});
// Act
var results = await emergencyAccessRepository.GetManyDetailsByUserIdsAsync([grantorUser.Id]);
// Assert
Assert.NotNull(results);
Assert.Single(results);
var record = results.First();
Assert.Equal(invitedRecord.Id, record.Id);
Assert.Null(record.GranteeId);
Assert.Equal(inviteEmail, record.GranteeEmail); // falls back to EA.Email when no registered grantee
Assert.Null(record.GranteeName);
Assert.Null(record.GranteeAvatarColor);
}
}

View File

@@ -0,0 +1,14 @@
CREATE OR ALTER PROCEDURE [dbo].[EmergencyAccessDetails_ReadById]
@Id UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[EmergencyAccessDetailsView]
WHERE
[Id] = @Id
END
GO

View File

@@ -0,0 +1,15 @@
CREATE OR ALTER PROCEDURE [dbo].[EmergencyAccessDetails_ReadManyByUserIds]
@UserIds [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON
SELECT
*
FROM
[dbo].[EmergencyAccessDetailsView]
WHERE
[GrantorId] IN (SELECT [Id] FROM @UserIds)
OR [GranteeId] IN (SELECT [Id] FROM @UserIds)
END
GO