mirror of
https://github.com/bitwarden/server.git
synced 2026-04-11 03:50:43 -05:00
* feat(emergency-access) [PM-33788] Update expired recoveries query. * feat(emergency-access) [PM-33788] Update many to notify query. * test(emergency-access) [PM-33788] Update integration tests for recovery time.
704 lines
26 KiB
C#
704 lines
26 KiB
C#
using Bit.Core.Auth.Entities;
|
|
using Bit.Core.Auth.Enums;
|
|
using Bit.Core.Entities;
|
|
using Bit.Core.Repositories;
|
|
using Xunit;
|
|
|
|
namespace Bit.Infrastructure.IntegrationTest.Auth.Repositories;
|
|
|
|
public class EmergencyAccessRepositoriesTests
|
|
{
|
|
[DatabaseTheory, DatabaseData]
|
|
public async Task DeleteAsync_UpdatesRevisionDate(IUserRepository userRepository,
|
|
IEmergencyAccessRepository emergencyAccessRepository)
|
|
{
|
|
var grantorUser = await userRepository.CreateAsync(new User
|
|
{
|
|
Name = "Test Grantor User",
|
|
Email = $"test+grantor{Guid.NewGuid()}@email.com",
|
|
ApiKey = "TEST",
|
|
SecurityStamp = "stamp",
|
|
});
|
|
|
|
var granteeUser = await userRepository.CreateAsync(new User
|
|
{
|
|
Name = "Test Grantee User",
|
|
Email = $"test+grantee{Guid.NewGuid()}@email.com",
|
|
ApiKey = "TEST",
|
|
SecurityStamp = "stamp",
|
|
});
|
|
|
|
var emergencyAccess = await emergencyAccessRepository.CreateAsync(new EmergencyAccess
|
|
{
|
|
GrantorId = grantorUser.Id,
|
|
GranteeId = granteeUser.Id,
|
|
Status = EmergencyAccessStatusType.Confirmed,
|
|
});
|
|
|
|
await emergencyAccessRepository.DeleteAsync(emergencyAccess);
|
|
|
|
var updatedGrantee = await userRepository.GetByIdAsync(granteeUser.Id);
|
|
|
|
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_DeletesMultipleGranteeRecordsAsync(
|
|
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",
|
|
});
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <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",
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies GetExpiredRecoveriesAsync only returns records whose wait period has elapsed.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies GetExpiredRecoveriesAsync returns empty when no recoveries have elapsed.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies GetManyToNotifyAsync only returns records that are within 1 day of expiry
|
|
/// AND whose last notification was more than 24 hours ago.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies GetManyToNotifyAsync excludes records with null LastNotificationDate.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
}
|