From b9cdf76b3f40431ec23a61d967f16f57071ab010 Mon Sep 17 00:00:00 2001 From: enmande <3836813+enmande@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:08:40 -0400 Subject: [PATCH 1/3] feat(emergency-access) [PM-33788] Update expired recoveries query. --- .../Auth/Repositories/EmergencyAccessRepository.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Infrastructure.EntityFramework/Auth/Repositories/EmergencyAccessRepository.cs b/src/Infrastructure.EntityFramework/Auth/Repositories/EmergencyAccessRepository.cs index 57eefd52c9d3..75e5e61c6dc4 100644 --- a/src/Infrastructure.EntityFramework/Auth/Repositories/EmergencyAccessRepository.cs +++ b/src/Infrastructure.EntityFramework/Auth/Repositories/EmergencyAccessRepository.cs @@ -69,7 +69,9 @@ public async Task> GetExpiredRecoveriesAsync var dbContext = GetDatabaseContext(scope); var view = new EmergencyAccessDetailsViewQuery(); var query = view.Run(dbContext).Where(ea => - ea.Status == EmergencyAccessStatusType.RecoveryInitiated + ea.Status == EmergencyAccessStatusType.RecoveryInitiated && + ea.RecoveryInitiatedDate.HasValue && + ea.RecoveryInitiatedDate.Value.AddDays(ea.WaitTimeDays) <= DateTime.UtcNow ); return await query.ToListAsync(); } From 4fdfb3ef221e94fc64d93a855b59d959bdab9f48 Mon Sep 17 00:00:00 2001 From: enmande <3836813+enmande@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:15:17 -0400 Subject: [PATCH 2/3] feat(emergency-access) [PM-33788] Update many to notify query. --- .../Auth/Repositories/EmergencyAccessRepository.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Infrastructure.EntityFramework/Auth/Repositories/EmergencyAccessRepository.cs b/src/Infrastructure.EntityFramework/Auth/Repositories/EmergencyAccessRepository.cs index 75e5e61c6dc4..ed811e9e0983 100644 --- a/src/Infrastructure.EntityFramework/Auth/Repositories/EmergencyAccessRepository.cs +++ b/src/Infrastructure.EntityFramework/Auth/Repositories/EmergencyAccessRepository.cs @@ -124,7 +124,11 @@ public async Task> GetManyToNotifyAsync() var dbContext = GetDatabaseContext(scope); var view = new EmergencyAccessDetailsViewQuery(); var query = view.Run(dbContext).Where(ea => - ea.Status == EmergencyAccessStatusType.RecoveryInitiated + ea.Status == EmergencyAccessStatusType.RecoveryInitiated && + ea.RecoveryInitiatedDate.HasValue && + ea.RecoveryInitiatedDate.Value.AddDays(ea.WaitTimeDays - 1) <= DateTime.UtcNow && + ea.LastNotificationDate.HasValue && + ea.LastNotificationDate.Value.AddDays(1) <= DateTime.UtcNow ); var notifies = await query.Select(ea => new EmergencyAccessNotify { From 52fa8a0252b13311b31448b65ce5a1c8d597d3b0 Mon Sep 17 00:00:00 2001 From: enmande <3836813+enmande@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:06:45 -0400 Subject: [PATCH 3/3] test(emergency-access) [PM-33788] Update integration tests for recovery time. --- .../EmergencyAccessRepositoryTests.cs | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) diff --git a/test/Infrastructure.IntegrationTest/Auth/Repositories/EmergencyAccessRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Auth/Repositories/EmergencyAccessRepositoryTests.cs index 67507f29d8b6..1015ef052021 100644 --- a/test/Infrastructure.IntegrationTest/Auth/Repositories/EmergencyAccessRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Auth/Repositories/EmergencyAccessRepositoryTests.cs @@ -446,4 +446,258 @@ public async Task GetManyDetailsByUserIdsAsync_InvitedRecord_ReturnedByGrantorId Assert.Null(record.GranteeName); Assert.Null(record.GranteeAvatarColor); } + + /// + /// Verifies GetExpiredRecoveriesAsync only returns records whose wait period has elapsed. + /// + [DatabaseTheory, DatabaseData] + public async Task GetExpiredRecoveriesAsync_ReturnsOnly_ExpiredRecoveries( + IUserRepository userRepository, + IEmergencyAccessRepository emergencyAccessRepository) + { + // Arrange + var grantor = await userRepository.CreateAsync(new User + { + Name = "Grantor", + Email = $"test+grantor{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var grantee = await userRepository.CreateAsync(new User + { + Name = "Grantee", + Email = $"test+grantee{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + // Record 1: expired — initiated 10 days ago with 5-day wait (should be returned) + var expired = await emergencyAccessRepository.CreateAsync(new EmergencyAccess + { + GrantorId = grantor.Id, + GranteeId = grantee.Id, + Status = EmergencyAccessStatusType.RecoveryInitiated, + WaitTimeDays = 5, + RecoveryInitiatedDate = DateTime.UtcNow.AddDays(-10), + }); + + // Record 2: not yet expired — initiated now with 30-day wait (should NOT be returned) + await emergencyAccessRepository.CreateAsync(new EmergencyAccess + { + GrantorId = grantor.Id, + GranteeId = grantee.Id, + Status = EmergencyAccessStatusType.RecoveryInitiated, + WaitTimeDays = 30, + RecoveryInitiatedDate = DateTime.UtcNow, + }); + + // Record 3: null RecoveryInitiatedDate (should NOT be returned) + await emergencyAccessRepository.CreateAsync(new EmergencyAccess + { + GrantorId = grantor.Id, + GranteeId = grantee.Id, + Status = EmergencyAccessStatusType.RecoveryInitiated, + WaitTimeDays = 1, + RecoveryInitiatedDate = null, + }); + + // Record 4: wrong status — expired date but Confirmed status (should NOT be returned) + await emergencyAccessRepository.CreateAsync(new EmergencyAccess + { + GrantorId = grantor.Id, + GranteeId = grantee.Id, + Status = EmergencyAccessStatusType.Confirmed, + WaitTimeDays = 1, + RecoveryInitiatedDate = DateTime.UtcNow.AddDays(-10), + }); + + // Act + var results = await emergencyAccessRepository.GetExpiredRecoveriesAsync(); + + // Assert — only the expired RecoveryInitiated record should be returned + var resultIds = results.Select(r => r.Id).ToHashSet(); + Assert.Contains(expired.Id, resultIds); + // The other 3 records must not appear + Assert.DoesNotContain(results, r => r.Id != expired.Id + && r.GrantorId == grantor.Id); + } + + /// + /// Verifies GetExpiredRecoveriesAsync returns empty when no recoveries have elapsed. + /// + [DatabaseTheory, DatabaseData] + public async Task GetExpiredRecoveriesAsync_ReturnsEmpty_WhenNoneExpired( + IUserRepository userRepository, + IEmergencyAccessRepository emergencyAccessRepository) + { + // Arrange + var grantor = await userRepository.CreateAsync(new User + { + Name = "Grantor", + Email = $"test+grantor{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var grantee = await userRepository.CreateAsync(new User + { + Name = "Grantee", + Email = $"test+grantee{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var ea = await emergencyAccessRepository.CreateAsync(new EmergencyAccess + { + GrantorId = grantor.Id, + GranteeId = grantee.Id, + Status = EmergencyAccessStatusType.RecoveryInitiated, + WaitTimeDays = 30, + RecoveryInitiatedDate = DateTime.UtcNow, + }); + + // Act + var results = await emergencyAccessRepository.GetExpiredRecoveriesAsync(); + + // Assert — the record's wait period hasn't elapsed, so it must not appear + Assert.DoesNotContain(results, r => r.Id == ea.Id); + } + + /// + /// Verifies GetManyToNotifyAsync only returns records that are within 1 day of expiry + /// AND whose last notification was more than 24 hours ago. + /// + [DatabaseTheory, DatabaseData] + public async Task GetManyToNotifyAsync_ReturnsOnly_EligibleNotifications( + IUserRepository userRepository, + IEmergencyAccessRepository emergencyAccessRepository) + { + // Arrange + var grantor = await userRepository.CreateAsync(new User + { + Name = "Grantor", + Email = $"test+grantor{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var grantee = await userRepository.CreateAsync(new User + { + Name = "Grantee", + Email = $"test+grantee{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + // Record 1: eligible — within notification window and last notified >24h ago + // WaitTimeDays=5, initiated 5 days ago → (5-1)=4 days after initiation is the + // notification threshold, which is 1 day ago → eligible. + // LastNotificationDate 2 days ago → >24h → eligible. + var eligible = await emergencyAccessRepository.CreateAsync(new EmergencyAccess + { + GrantorId = grantor.Id, + GranteeId = grantee.Id, + Status = EmergencyAccessStatusType.RecoveryInitiated, + WaitTimeDays = 5, + RecoveryInitiatedDate = DateTime.UtcNow.AddDays(-5), + LastNotificationDate = DateTime.UtcNow.AddDays(-2), + }); + + // Record 2: too recently notified — in window but LastNotificationDate is now + await emergencyAccessRepository.CreateAsync(new EmergencyAccess + { + GrantorId = grantor.Id, + GranteeId = grantee.Id, + Status = EmergencyAccessStatusType.RecoveryInitiated, + WaitTimeDays = 5, + RecoveryInitiatedDate = DateTime.UtcNow.AddDays(-5), + LastNotificationDate = DateTime.UtcNow, + }); + + // Record 3: not in notification window — initiated recently with long wait + await emergencyAccessRepository.CreateAsync(new EmergencyAccess + { + GrantorId = grantor.Id, + GranteeId = grantee.Id, + Status = EmergencyAccessStatusType.RecoveryInitiated, + WaitTimeDays = 30, + RecoveryInitiatedDate = DateTime.UtcNow, + LastNotificationDate = DateTime.UtcNow.AddDays(-2), + }); + + // Record 4: null RecoveryInitiatedDate (should NOT be returned) + await emergencyAccessRepository.CreateAsync(new EmergencyAccess + { + GrantorId = grantor.Id, + GranteeId = grantee.Id, + Status = EmergencyAccessStatusType.RecoveryInitiated, + WaitTimeDays = 1, + RecoveryInitiatedDate = null, + LastNotificationDate = DateTime.UtcNow.AddDays(-2), + }); + + // Record 5: wrong status — eligible dates but Confirmed status (should NOT be returned) + await emergencyAccessRepository.CreateAsync(new EmergencyAccess + { + GrantorId = grantor.Id, + GranteeId = grantee.Id, + Status = EmergencyAccessStatusType.Confirmed, + WaitTimeDays = 5, + RecoveryInitiatedDate = DateTime.UtcNow.AddDays(-5), + LastNotificationDate = DateTime.UtcNow.AddDays(-2), + }); + + // Act + var results = await emergencyAccessRepository.GetManyToNotifyAsync(); + + // Assert — only the eligible record should be returned + var resultIds = results.Select(r => r.Id).ToHashSet(); + Assert.Contains(eligible.Id, resultIds); + Assert.DoesNotContain(results, r => r.Id != eligible.Id + && r.GrantorId == grantor.Id); + } + + /// + /// Verifies GetManyToNotifyAsync excludes records with null LastNotificationDate. + /// + [DatabaseTheory, DatabaseData] + public async Task GetManyToNotifyAsync_ExcludesRecords_WithNullLastNotificationDate( + IUserRepository userRepository, + IEmergencyAccessRepository emergencyAccessRepository) + { + // Arrange + var grantor = await userRepository.CreateAsync(new User + { + Name = "Grantor", + Email = $"test+grantor{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var grantee = await userRepository.CreateAsync(new User + { + Name = "Grantee", + Email = $"test+grantee{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + // Record with null LastNotificationDate — should not be returned + var ea = await emergencyAccessRepository.CreateAsync(new EmergencyAccess + { + GrantorId = grantor.Id, + GranteeId = grantee.Id, + Status = EmergencyAccessStatusType.RecoveryInitiated, + WaitTimeDays = 1, + RecoveryInitiatedDate = DateTime.UtcNow.AddDays(-10), + LastNotificationDate = null, + }); + + // Act + var results = await emergencyAccessRepository.GetManyToNotifyAsync(); + + // Assert + Assert.DoesNotContain(results, r => r.Id == ea.Id); + } }