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 Microsoft.Extensions.Logging; using NSubstitute; using Xunit; namespace Bit.Core.Test.Auth.UserFeatures.EmergencyAccess; [SutProviderCustomize] public class DeleteEmergencyAccessCommandTests { /// /// Verifies that attempting to delete a non-existent emergency access record /// throws a and does not call delete or send email. /// [Theory, BitAutoData] public async Task DeleteByIdAndUserIdAsync_EmergencyAccessNotFound_ThrowsBadRequestAsync( SutProvider sutProvider, Guid emergencyAccessId, Guid userId) { sutProvider.GetDependency() .GetDetailsByIdAsync(emergencyAccessId) .Returns((EmergencyAccessDetails?)null); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.DeleteByIdAndUserIdAsync(emergencyAccessId, userId)); Assert.Contains("Emergency Access not valid.", exception.Message); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .DeleteAsync(default); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .SendEmail(default); } /// /// Verifies that an emergency access record is deleted by ID and user ID, /// and that a notification email is sent to the grantor. /// [Theory, BitAutoData] public async Task DeleteByIdAndUserIdAsync_DeletesEmergencyAccessAndSendsEmailAsync( SutProvider sutProvider, EmergencyAccessDetails emergencyAccessDetails) { sutProvider.GetDependency() .GetDetailsByIdAsync(emergencyAccessDetails.Id) .Returns(emergencyAccessDetails); await sutProvider.Sut.DeleteByIdAndUserIdAsync(emergencyAccessDetails.Id, emergencyAccessDetails.GrantorId); await sutProvider.GetDependency() .Received(1) .DeleteAsync(emergencyAccessDetails); await sutProvider.GetDependency() .Received(1) .SendEmail(Arg.Is(mail => mail.ToEmails.Contains(emergencyAccessDetails.GrantorEmail) && mail.View.RemovedGranteeEmails.Contains(emergencyAccessDetails.GranteeEmail))); } /// /// Verifies that when the grantor email is null, the record is deleted /// but no email notification is sent. /// [Theory, BitAutoData] public async Task DeleteByIdAndUserIdAsync_NullGrantorEmail_DeletesButDoesNotSendEmailAsync( SutProvider sutProvider, EmergencyAccessDetails emergencyAccessDetails) { emergencyAccessDetails.GrantorEmail = null; sutProvider.GetDependency() .GetDetailsByIdAsync(emergencyAccessDetails.Id) .Returns(emergencyAccessDetails); await sutProvider.Sut.DeleteByIdAndUserIdAsync(emergencyAccessDetails.Id, emergencyAccessDetails.GrantorId); await sutProvider.GetDependency() .Received(1) .DeleteAsync(emergencyAccessDetails); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .SendEmail(default); sutProvider.GetDependency>() .Received(1) .Log( LogLevel.Warning, Arg.Any(), Arg.Is(o => o.ToString().Contains(emergencyAccessDetails.GrantorId.ToString()) && o.ToString().Contains("GrantorEmail missing: True") && o.ToString().Contains("GranteeEmail missing: False")), null, Arg.Any>()); } /// /// Verifies that when the grantee email is null, the record is deleted /// but no email notification is sent. /// [Theory, BitAutoData] public async Task DeleteByIdAndUserIdAsync_NullGranteeEmail_DeletesButDoesNotSendEmailAsync( SutProvider sutProvider, EmergencyAccessDetails emergencyAccessDetails) { emergencyAccessDetails.GranteeEmail = null; sutProvider.GetDependency() .GetDetailsByIdAsync(emergencyAccessDetails.Id) .Returns(emergencyAccessDetails); await sutProvider.Sut.DeleteByIdAndUserIdAsync(emergencyAccessDetails.Id, emergencyAccessDetails.GrantorId); await sutProvider.GetDependency() .Received(1) .DeleteAsync(emergencyAccessDetails); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .SendEmail(default); sutProvider.GetDependency>() .Received(1) .Log( LogLevel.Warning, Arg.Any(), Arg.Is(o => o.ToString().Contains(emergencyAccessDetails.GrantorId.ToString()) && o.ToString().Contains("GrantorEmail missing: False") && o.ToString().Contains("GranteeEmail missing: True")), null, Arg.Any>()); } /// /// Verifies that a grantee (not just a grantor) can delete an emergency access record, /// and that the grantor still receives a notification email. /// [Theory, BitAutoData] public async Task DeleteByIdAndUserIdAsync_GranteeDeletes_DeletesAndSendsEmailAsync( SutProvider sutProvider, EmergencyAccessDetails emergencyAccessDetails) { sutProvider.GetDependency() .GetDetailsByIdAsync(emergencyAccessDetails.Id) .Returns(emergencyAccessDetails); // Act as the grantee, not the grantor await sutProvider.Sut.DeleteByIdAndUserIdAsync(emergencyAccessDetails.Id, emergencyAccessDetails.GranteeId.Value); await sutProvider.GetDependency() .Received(1) .DeleteAsync(emergencyAccessDetails); await sutProvider.GetDependency() .Received(1) .SendEmail(Arg.Is(mail => mail.ToEmails.Contains(emergencyAccessDetails.GrantorEmail) && mail.View.RemovedGranteeEmails.Contains(emergencyAccessDetails.GranteeEmail))); } /// /// Verifies that a user who is neither the grantor nor the grantee cannot delete /// the emergency access record and receives a . /// [Theory, BitAutoData] public async Task DeleteByIdAndUserIdAsync_UnauthorizedUser_ThrowsBadRequestAsync( SutProvider sutProvider, EmergencyAccessDetails emergencyAccessDetails, Guid unauthorizedUserId) { sutProvider.GetDependency() .GetDetailsByIdAsync(emergencyAccessDetails.Id) .Returns(emergencyAccessDetails); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.DeleteByIdAndUserIdAsync(emergencyAccessDetails.Id, unauthorizedUserId)); Assert.Contains("Emergency Access not valid.", exception.Message); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .DeleteAsync(default); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .SendEmail(default); } /// /// Verifies that correctly /// delegates to /// using a single-element collection containing the provided user ID. /// [Theory, BitAutoData] public async Task DeleteAllByUserIdAsync_DelegatesToDeleteAllByUserIdsAsync( SutProvider sutProvider, EmergencyAccessDetails emergencyAccessDetails, Guid userId) { emergencyAccessDetails.GranteeId = userId; sutProvider.GetDependency() .GetManyDetailsByUserIdsAsync(Arg.Is>(ids => ids.Contains(userId))) .Returns([emergencyAccessDetails]); await sutProvider.Sut.DeleteAllByUserIdAsync(userId); await sutProvider.GetDependency() .Received(1) .GetManyDetailsByUserIdsAsync(Arg.Is>(ids => ids.Count == 1 && ids.Contains(userId))); await sutProvider.GetDependency() .Received(1) .DeleteManyAsync(Arg.Is>(ids => ids.Count == 1 && ids.Contains(emergencyAccessDetails.Id))); } /// /// Verifies that passing an empty list of user IDs does not attempt to delete or send email. /// [Theory, BitAutoData] public async Task DeleteAllByUserIdsAsync_EmptyList_DoesNotDeleteOrSendEmailAsync( SutProvider sutProvider) { sutProvider.GetDependency() .GetManyDetailsByUserIdsAsync(Arg.Any>()) .Returns([]); await sutProvider.Sut.DeleteAllByUserIdsAsync([]); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .DeleteManyAsync(default); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .SendEmail(default); } /// /// Verifies that when user IDs don't match any emergency access records, /// the method does not attempt to delete or send email. /// [Theory, BitAutoData] public async Task DeleteAllByUserIdsAsync_NoRecordsFound_DoesNotDeleteOrSendEmailAsync( SutProvider sutProvider, List userIds) { sutProvider.GetDependency() .GetManyDetailsByUserIdsAsync(userIds) .Returns([]); await sutProvider.Sut.DeleteAllByUserIdsAsync(userIds); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .DeleteManyAsync(default); await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .SendEmail(default); } /// /// 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. /// [Theory, BitAutoData] public async Task DeleteAllByUserIdsAsync_SingleUserIdAsGranteeOnly_NotifiesGrantorsAsync( SutProvider sutProvider, EmergencyAccessDetails bobAliceRecord, EmergencyAccessDetails carolAliceRecord, EmergencyAccessDetails davidAliceRecord, Guid granteeUserIdAlice) { // 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 { bobAliceRecord, carolAliceRecord, davidAliceRecord }; sutProvider.GetDependency() .GetManyDetailsByUserIdsAsync(Arg.Is>(ids => ids.Contains(granteeUserIdAlice))) .Returns(allDetails); await sutProvider.Sut.DeleteAllByUserIdsAsync([granteeUserIdAlice]); await sutProvider.GetDependency() .Received(1) .DeleteManyAsync(Arg.Is>(ids => ids.Count == 3 && ids.Contains(bobAliceRecord.Id) && ids.Contains(carolAliceRecord.Id) && ids.Contains(davidAliceRecord.Id))); // Each grantor gets one email await sutProvider.GetDependency() .Received(1) .SendEmail(Arg.Is(mail => mail.ToEmails.Contains(bobAliceRecord.GrantorEmail))); await sutProvider.GetDependency() .Received(1) .SendEmail(Arg.Is(mail => mail.ToEmails.Contains(carolAliceRecord.GrantorEmail))); await sutProvider.GetDependency() .Received(1) .SendEmail(Arg.Is(mail => mail.ToEmails.Contains(davidAliceRecord.GrantorEmail))); } /// /// 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. /// [Theory, BitAutoData] public async Task DeleteAllByUserIdsAsync_SingleUserIdAsGrantorOnly_NotifiesGrantorAsync( SutProvider 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 { bobAliceRecord, bobCarolRecord }; sutProvider.GetDependency() .GetManyDetailsByUserIdsAsync(Arg.Is>(ids => ids.Contains(grantorUserIdBob))) .Returns(allDetails); await sutProvider.Sut.DeleteAllByUserIdsAsync([grantorUserIdBob]); await sutProvider.GetDependency() .Received(1) .DeleteManyAsync(Arg.Is>(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() .Received(1) .SendEmail(Arg.Is(mail => mail.ToEmails.Contains(bobAliceRecord.GrantorEmail) && mail.View.RemovedGranteeEmails.Contains(bobAliceRecord.GranteeEmail) && mail.View.RemovedGranteeEmails.Contains(bobCarolRecord.GranteeEmail))); } /// /// 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) /// [Theory, BitAutoData] public async Task DeleteAllByUserIdsAsync_SingleUserIdBothRoles_NotifiesAllGrantorsAsync( SutProvider 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 { aliceBobRecord, carolBobRecord, bobDavidRecord, bobEmmaRecord }; sutProvider.GetDependency() .GetManyDetailsByUserIdsAsync(Arg.Is>(ids => ids.Contains(userIdBob))) .Returns(allDetails); await sutProvider.Sut.DeleteAllByUserIdsAsync([userIdBob]); await sutProvider.GetDependency() .Received(1) .DeleteManyAsync(Arg.Is>(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() .Received(1) .SendEmail(Arg.Is(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() .Received(1) .SendEmail(Arg.Is(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() .Received(1) .SendEmail(Arg.Is(mail => mail.ToEmails.Contains("bob@example.com") && mail.View.RemovedGranteeEmails.Contains(bobDavidRecord.GranteeEmail) && mail.View.RemovedGranteeEmails.Contains(bobEmmaRecord.GranteeEmail))); } /// /// 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. /// [Theory, BitAutoData] public async Task DeleteAllByUserIdsAsync_MultipleUserIdsAllGrantees_SendsMultipleEmailsAsync( SutProvider 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 { carolAliceRecord, davidBobRecord }; var userIds = new List { granteeUserIdAlice, granteeUserIdBob }; sutProvider.GetDependency() .GetManyDetailsByUserIdsAsync(userIds) .Returns(allDetails); await sutProvider.Sut.DeleteAllByUserIdsAsync(userIds); await sutProvider.GetDependency() .Received(1) .DeleteManyAsync(Arg.Is>(ids => ids.Count == 2 && ids.Contains(carolAliceRecord.Id) && ids.Contains(davidBobRecord.Id))); // Carol gets email about Alice being removed await sutProvider.GetDependency() .Received(1) .SendEmail(Arg.Is(mail => mail.ToEmails.Contains(carolAliceRecord.GrantorEmail))); // David gets email about Bob being removed await sutProvider.GetDependency() .Received(1) .SendEmail(Arg.Is(mail => mail.ToEmails.Contains(davidBobRecord.GrantorEmail))); } /// /// 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" /// [Theory, BitAutoData] public async Task DeleteAllByUserIdsAsync_MultipleUserIdsAllGrantors_NotifiesEachGrantorAsync( SutProvider 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 { bobAliceRecord, carolDavidRecord }; var userIds = new List { grantorUserIdBob, grantorUserIdCarol }; sutProvider.GetDependency() .GetManyDetailsByUserIdsAsync(userIds) .Returns(allDetails); await sutProvider.Sut.DeleteAllByUserIdsAsync(userIds); await sutProvider.GetDependency() .Received(1) .DeleteManyAsync(Arg.Is>(ids => ids.Count == 2 && ids.Contains(bobAliceRecord.Id) && ids.Contains(carolDavidRecord.Id))); // Bob gets email about Alice being removed await sutProvider.GetDependency() .Received(1) .SendEmail(Arg.Is(mail => mail.ToEmails.Contains(bobAliceRecord.GrantorEmail) && mail.View.RemovedGranteeEmails.Contains(bobAliceRecord.GranteeEmail))); // Carol gets email about David being removed await sutProvider.GetDependency() .Received(1) .SendEmail(Arg.Is(mail => mail.ToEmails.Contains(carolDavidRecord.GrantorEmail) && mail.View.RemovedGranteeEmails.Contains(carolDavidRecord.GranteeEmail))); } /// /// 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) /// [Theory, BitAutoData] public async Task DeleteAllByUserIdsAsync_MultipleUsersOverlappingGrantors_EachGrantorGetsCorrectSubsetAsync( SutProvider 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 { caraAliRecord, daveAliRecord, daveBobRecord, eveBobRecord }; var userIdsToDelete = new List { granteeUserIdAli, granteeUserIdBob }; sutProvider.GetDependency() .GetManyDetailsByUserIdsAsync(userIdsToDelete) .Returns(allDetails); await sutProvider.Sut.DeleteAllByUserIdsAsync(userIdsToDelete); await sutProvider.GetDependency() .Received(1) .DeleteManyAsync(Arg.Is>(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() .Received(1) .SendEmail(Arg.Is(mail => mail.ToEmails.Contains(grantorEmailCara) && mail.View.RemovedGranteeEmails.Contains(granteeEmailAli))); // Dave gets email with both Ali and Bob (shared grantor) await sutProvider.GetDependency() .Received(1) .SendEmail(Arg.Is(mail => mail.ToEmails.Contains(grantorEmailDave) && mail.View.RemovedGranteeEmails.Contains(granteeEmailAli) && mail.View.RemovedGranteeEmails.Contains(granteeEmailBob))); // Eve gets email with only Bob await sutProvider.GetDependency() .Received(1) .SendEmail(Arg.Is(mail => mail.ToEmails.Contains(grantorEmailEve) && mail.View.RemovedGranteeEmails.Contains(granteeEmailBob))); } /// /// 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. /// [Theory, BitAutoData] public async Task DeleteAllByUserIdsAsync_NullGranteeEmail_HandledGracefullyAsync( SutProvider 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() .GetManyDetailsByUserIdsAsync(Arg.Is>(ids => ids.Contains(granteeUserIdAlice))) .Returns([bobAliceRecord]); await sutProvider.Sut.DeleteAllByUserIdsAsync([granteeUserIdAlice]); await sutProvider.GetDependency() .Received(1) .DeleteManyAsync(Arg.Is>(ids => ids.Count == 1 && ids.Contains(bobAliceRecord.Id))); // Email should not be sent if grantee email is null await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .SendEmail(default); sutProvider.GetDependency>() .Received(1) .Log( LogLevel.Warning, Arg.Any(), Arg.Is(o => o.ToString().Contains(granteeUserIdAlice.ToString()) && o.ToString().Contains("missing GranteeEmail")), null, Arg.Any>()); } /// /// 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. /// [Theory, BitAutoData] public async Task DeleteAllByUserIdsAsync_NullGrantorEmail_DeletesButDoesNotSendEmailAsync( SutProvider 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() .GetManyDetailsByUserIdsAsync(Arg.Is>(ids => ids.Contains(grantorUserIdBob))) .Returns([bobAliceRecord]); await sutProvider.Sut.DeleteAllByUserIdsAsync([grantorUserIdBob]); await sutProvider.GetDependency() .Received(1) .DeleteManyAsync(Arg.Is>(ids => ids.Count == 1 && ids.Contains(bobAliceRecord.Id))); // Email should not be sent if grantor email is null await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() .SendEmail(default); sutProvider.GetDependency>() .Received(1) .Log( LogLevel.Warning, Arg.Any(), Arg.Is(o => o.ToString().Contains(grantorUserIdBob.ToString()) && o.ToString().Contains("missing GrantorEmail")), null, Arg.Any>()); } /// /// 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. /// [Theory, BitAutoData] public async Task DeleteAllByUserIdsAsync_DuplicateGranteeEmails_DeduplicatesEmailsInNotificationAsync( SutProvider 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() .GetManyDetailsByUserIdsAsync(Arg.Is>(ids => ids.Contains(grantorUserIdBob))) .Returns([bobAliceRecord1, bobAliceRecord2]); await sutProvider.Sut.DeleteAllByUserIdsAsync([grantorUserIdBob]); await sutProvider.GetDependency() .Received(1) .DeleteManyAsync(Arg.Is>(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() .Received(1) .SendEmail(Arg.Is(mail => mail.ToEmails.Contains(grantorEmailBob) && mail.View.RemovedGranteeEmails.Count() == 1 && mail.View.RemovedGranteeEmails.Contains(granteeEmailAlice))); } /// /// 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. /// [Theory, BitAutoData] public async Task DeleteAllByUserIdsAsync_PartialNullGranteeEmails_SendsEmailForNonNullGranteesOnlyAsync( SutProvider 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() .GetManyDetailsByUserIdsAsync(Arg.Is>(ids => ids.Contains(grantorUserIdBob))) .Returns([bobAliceRecord, bobCarolRecord]); await sutProvider.Sut.DeleteAllByUserIdsAsync([grantorUserIdBob]); await sutProvider.GetDependency() .Received(1) .DeleteManyAsync(Arg.Is>(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() .Received(1) .SendEmail(Arg.Is(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>() .Received(1) .Log( LogLevel.Warning, Arg.Any(), Arg.Is(o => o.ToString().Contains(granteeUserIdCarol.ToString()) && o.ToString().Contains("missing GranteeEmail")), null, Arg.Any>()); } }