1
0
mirror of https://github.com/bitwarden/server synced 2026-02-19 19:03:30 +00:00

[PM-26376] Emergency Access Delete Command (#6857)

* feat: Add initial DeleteEmergencyContactCommand

* chore: remove nullable enable and add comments

* test: add tests for new delete command

* test: update tests to test IMailer was called.

* feat: add delete by GranteeId and allow for multiple grantors to be contacted.

* feat: add DeleteMany stored procedure for EmergencyAccess

* test: add database tests for new SP

* feat: commands use DeleteManyById for emergencyAccessDeletes

* claude: send one email per grantor instead of a bulk email to all grantors. Modified tests to validate.

* feat: change revision dates for confirmed grantees; 

* feat: add AccountRevisionDate bump for grantee users in the confirmed status

* test: update integration test to validate only confirmed users are updated as well as proper deletion of emergency access
This commit is contained in:
Ike
2026-02-03 16:43:44 -05:00
committed by GitHub
parent 82e1a6bd71
commit 68e67e1853
23 changed files with 792 additions and 183 deletions

View File

@@ -30,7 +30,7 @@ public class EmergencyAccessRotationValidatorTests
KeyEncrypted = e.KeyEncrypted,
Type = e.Type
}).ToList();
userEmergencyAccess.Add(new EmergencyAccessDetails { Id = Guid.NewGuid(), KeyEncrypted = "TestKey" });
userEmergencyAccess.Add(new EmergencyAccessDetails { Id = Guid.NewGuid(), GrantorEmail = "grantor@example.com", KeyEncrypted = "TestKey" });
sutProvider.GetDependency<IEmergencyAccessRepository>().GetManyDetailsByGrantorIdAsync(user.Id)
.Returns(userEmergencyAccess);

View File

@@ -0,0 +1,253 @@
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.UserFeatures.EmergencyAccess.Commands;
using Bit.Core.Auth.UserFeatures.EmergencyAccess.Mail;
using Bit.Core.Exceptions;
using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Auth.UserFeatures.EmergencyAccess;
[SutProviderCustomize]
public class DeleteEmergencyAccessCommandTests
{
/// <summary>
/// Verifies that attempting to delete a non-existent emergency access record
/// throws a <see cref="BadRequestException"/> and does not call delete or send email.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteByIdGrantorIdAsync_EmergencyAccessNotFound_ThrowsBadRequest(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
Guid emergencyAccessId,
Guid grantorId)
{
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetDetailsByIdGrantorIdAsync(emergencyAccessId, grantorId)
.Returns((EmergencyAccessDetails)null);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.DeleteByIdGrantorIdAsync(emergencyAccessId, grantorId));
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 successful deletion of an emergency access record by ID and grantor ID,
/// and ensures that a notification email is sent to the grantor.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteByIdGrantorIdAsync_DeletesEmergencyAccessAndSendsEmail(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
EmergencyAccessDetails emergencyAccessDetails)
{
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetDetailsByIdGrantorIdAsync(emergencyAccessDetails.Id, emergencyAccessDetails.GrantorId)
.Returns(emergencyAccessDetails);
var result = await sutProvider.Sut.DeleteByIdGrantorIdAsync(emergencyAccessDetails.Id, emergencyAccessDetails.GrantorId);
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 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 DeleteAllByGrantorIdAsync_NoEmergencyAccessRecords_ReturnsEmptyCollection(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
Guid grantorId)
{
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetManyDetailsByGrantorIdAsync(grantorId)
.Returns([]);
var result = await sutProvider.Sut.DeleteAllByGrantorIdAsync(grantorId);
Assert.NotNull(result);
Assert.Empty(result);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.DidNotReceiveWithAnyArgs()
.DeleteManyAsync(default);
await sutProvider.GetDependency<IMailer>()
.DidNotReceiveWithAnyArgs()
.SendEmail<EmergencyAccessRemoveGranteesMailView>(default);
}
/// <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.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteAllByGrantorIdAsync_MultipleRecords_DeletesAllReturnsDetailsSendsSingleEmail(
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)
{
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)
.Returns([]);
var result = await sutProvider.Sut.DeleteAllByGranteeIdAsync(granteeId);
Assert.NotNull(result);
Assert.Empty(result);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.DidNotReceiveWithAnyArgs()
.DeleteManyAsync(default);
await sutProvider.GetDependency<IMailer>()
.DidNotReceiveWithAnyArgs()
.SendEmail<EmergencyAccessRemoveGranteesMailView>(default);
}
/// <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.
/// </summary>
[Theory, BitAutoData]
public async Task DeleteAllByGranteeIdAsync_SingleRecord_DeletesAndReturnsDetailsSendsSingleEmail(
SutProvider<DeleteEmergencyAccessCommand> sutProvider,
EmergencyAccessDetails emergencyAccessDetails,
Guid granteeId)
{
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;
var allDetails = new List<EmergencyAccessDetails>
{
emergencyAccessDetails1,
emergencyAccessDetails2,
emergencyAccessDetails3
};
sutProvider.GetDependency<IEmergencyAccessRepository>()
.GetManyDetailsByGranteeIdAsync((Guid)emergencyAccessDetails1.GranteeId)
.Returns(allDetails);
var result = await sutProvider.Sut.DeleteAllByGranteeIdAsync((Guid)emergencyAccessDetails1.GranteeId);
Assert.NotNull(result);
Assert.Equal(3, result.Count);
await sutProvider.GetDependency<IEmergencyAccessRepository>()
.Received(1)
.DeleteManyAsync(Arg.Any<ICollection<Guid>>());
await sutProvider.GetDependency<IMailer>()
.Received(allDetails.Count)
.SendEmail(Arg.Any<EmergencyAccessRemoveGranteesMail>());
}
}

View File

@@ -26,7 +26,7 @@ public class EmergencyAccessMailTests
[Theory, BitAutoData]
public async Task SendEmergencyAccessRemoveGranteesEmail_SingleGrantee_Success(
string grantorEmail,
string granteeName)
string granteeEmail)
{
// Arrange
var logger = Substitute.For<ILogger<HandlebarMailRenderer>>();
@@ -41,7 +41,7 @@ public class EmergencyAccessMailTests
ToEmails = [grantorEmail],
View = new EmergencyAccessRemoveGranteesMailView
{
RemovedGranteeNames = [granteeName]
RemovedGranteeEmails = [granteeEmail]
}
};
@@ -58,8 +58,8 @@ public class EmergencyAccessMailTests
Assert.Contains(grantorEmail, sentMessage.ToEmails);
// Verify the content contains the grantee name
Assert.Contains(granteeName, sentMessage.TextContent);
Assert.Contains(granteeName, sentMessage.HtmlContent);
Assert.Contains(granteeEmail, sentMessage.TextContent);
Assert.Contains(granteeEmail, sentMessage.HtmlContent);
}
/// <summary>
@@ -77,14 +77,14 @@ public class EmergencyAccessMailTests
new HandlebarMailRenderer(logger, globalSettings),
deliveryService);
var granteeNames = new[] { "Alice", "Bob", "Carol" };
var granteeEmails = new[] { "Alice@test.dev", "Bob@test.dev", "Carol@test.dev" };
var mail = new EmergencyAccessRemoveGranteesMail
{
ToEmails = [grantorEmail],
View = new EmergencyAccessRemoveGranteesMailView
{
RemovedGranteeNames = granteeNames
RemovedGranteeEmails = granteeEmails
}
};
@@ -98,10 +98,10 @@ public class EmergencyAccessMailTests
// Assert - All grantee names should appear in the email
Assert.NotNull(sentMessage);
foreach (var granteeName in granteeNames)
foreach (var granteeEmail in granteeEmails)
{
Assert.Contains(granteeName, sentMessage.TextContent);
Assert.Contains(granteeName, sentMessage.HtmlContent);
Assert.Contains(granteeEmail, sentMessage.TextContent);
Assert.Contains(granteeEmail, sentMessage.HtmlContent);
}
}
@@ -119,14 +119,14 @@ public class EmergencyAccessMailTests
View = new EmergencyAccessRemoveGranteesMailView
{
// Required: at least one removed grantee name
RemovedGranteeNames = ["Example Grantee"]
RemovedGranteeEmails = ["Example Grantee"]
}
};
// Assert
Assert.NotNull(mail);
Assert.NotNull(mail.View);
Assert.NotEmpty(mail.View.RemovedGranteeNames);
Assert.NotEmpty(mail.View.RemovedGranteeEmails);
}
/// <summary>
@@ -141,13 +141,13 @@ public class EmergencyAccessMailTests
var mail = new EmergencyAccessRemoveGranteesMail
{
ToEmails = [grantorEmail],
View = new EmergencyAccessRemoveGranteesMailView { RemovedGranteeNames = [granteeName] }
View = new EmergencyAccessRemoveGranteesMailView { RemovedGranteeEmails = [granteeName] }
};
// Assert
Assert.NotNull(mail);
Assert.NotNull(mail.View);
Assert.Equal(_emergencyAccessMailSubject, mail.Subject);
Assert.Equal(_emergencyAccessHelpUrl, mail.View.EmergencyAccessHelpPageUrl);
Assert.Equal(_emergencyAccessHelpUrl, EmergencyAccessRemoveGranteesMailView.EmergencyAccessHelpPageUrl);
}
}

View File

@@ -42,4 +42,97 @@ public class EmergencyAccessRepositoriesTests
Assert.NotNull(updatedGrantee);
Assert.NotEqual(updatedGrantee.AccountRevisionDate, granteeUser.AccountRevisionDate);
}
/// <summary>
/// Creates 3 Emergency Access records all connected to a single grantor, but separate grantees.
/// All 3 records are then deleted in a single call to DeleteManyAsync.
/// </summary>
[DatabaseTheory, DatabaseData]
public async Task DeleteManyAsync_DeletesMultipleGranteeRecords_UpdatesUserRevisionDates(
IUserRepository userRepository,
IEmergencyAccessRepository emergencyAccessRepository)
{
// Arrange
var grantorUser = await userRepository.CreateAsync(new User
{
Name = "Test Grantor User",
Email = $"test+grantor{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
var confirmedGranteeUser1 = await userRepository.CreateAsync(new User
{
Name = "Test Grantee User 1",
Email = $"test+grantee{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
var invitedGranteeUser2 = await userRepository.CreateAsync(new User
{
Name = "Test Grantee User 2",
Email = $"test+grantee{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
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",
Email = $"test+grantee{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
var confirmedEmergencyAccess = await emergencyAccessRepository.CreateAsync(new EmergencyAccess
{
GrantorId = grantorUser.Id,
GranteeId = confirmedGranteeUser1.Id,
Status = EmergencyAccessStatusType.Confirmed,
});
var invitedEmergencyAccess = await emergencyAccessRepository.CreateAsync(new EmergencyAccess
{
GrantorId = grantorUser.Id,
GranteeId = invitedGranteeUser2.Id,
Status = EmergencyAccessStatusType.Invited,
});
var acceptedEmergencyAccess = await emergencyAccessRepository.CreateAsync(new EmergencyAccess
{
GrantorId = grantorUser.Id,
GranteeId = granteeUser3.Id,
Status = EmergencyAccessStatusType.Accepted,
});
// Act
await emergencyAccessRepository.DeleteManyAsync([confirmedEmergencyAccess.Id, invitedEmergencyAccess.Id, acceptedEmergencyAccess.Id]);
// Assert
// ensure Grantor records deleted
var grantorEmergencyAccess = await emergencyAccessRepository.GetManyDetailsByGrantorIdAsync(grantorUser.Id);
Assert.Empty(grantorEmergencyAccess);
// ensure Grantee records deleted
foreach (var grantee in (List<User>)[confirmedGranteeUser1, invitedGranteeUser2, granteeUser3])
{
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);
// Invited user should not have an updated AccountRevisionDate
var updatedInvitedGrantee = await userRepository.GetByIdAsync(invitedGranteeUser2.Id);
Assert.NotNull(updatedInvitedGrantee);
Assert.Equal(updatedInvitedGrantee.AccountRevisionDate, invitedGranteeUser2.AccountRevisionDate);
}
}