fix(notification-service): evict stale OAuth2 token cache entries after credential rotation (review 2026-10-21 06/MEM-3)
This commit is contained in:
@@ -231,6 +231,67 @@ public class OAuth2TokenServiceTests
|
||||
Assert.Equal("https://outlook.office365.com/.default", handler.LastForm!["scope"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a credential rotation does not leave the old credential's cache entry
|
||||
/// behind forever: once the old token has expired and the entry has been idle past the
|
||||
/// stale grace, the next token request evicts it (review 2026-10-21 06/MEM-3).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_RotatedCredential_StaleEntryIsEvicted()
|
||||
{
|
||||
var handler = new CountingHttpMessageHandler(HttpStatusCode.OK, TokenJson("t", expiresIn: 3600));
|
||||
var factory = CreateMockFactory(new HttpClient(handler));
|
||||
var clock = new ManualTimeProvider(DateTimeOffset.Parse("2026-10-22T00:00:00Z"));
|
||||
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance, clock);
|
||||
|
||||
await service.GetTokenAsync("tenant:client:old-secret");
|
||||
Assert.Equal(1, service.CachedEntryCount);
|
||||
|
||||
// Rotate: the old entry is still fresh, so it survives the first request with the new secret.
|
||||
await service.GetTokenAsync("tenant:client:new-secret");
|
||||
Assert.Equal(2, service.CachedEntryCount);
|
||||
|
||||
// Well past the old token's expiry plus the stale grace — the next request sweeps it.
|
||||
clock.Advance(TimeSpan.FromHours(3));
|
||||
await service.GetTokenAsync("tenant:client:new-secret");
|
||||
Assert.Equal(1, service.CachedEntryCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an entry whose token was never acquired (bad credentials) is also evicted
|
||||
/// once idle past the stale grace, rather than lingering with a null token forever.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetTokenAsync_FailedCredential_IdleEntryIsEvicted()
|
||||
{
|
||||
var handler = new CountingHttpMessageHandler(HttpStatusCode.OK, TokenJson("t", expiresIn: 3600));
|
||||
var factory = CreateMockFactory(new HttpClient(handler));
|
||||
var clock = new ManualTimeProvider(DateTimeOffset.Parse("2026-10-22T00:00:00Z"));
|
||||
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance, clock);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => service.GetTokenAsync("not-a-triple"));
|
||||
Assert.Equal(1, service.CachedEntryCount);
|
||||
|
||||
clock.Advance(TimeSpan.FromHours(2));
|
||||
await service.GetTokenAsync("tenant:client:secret");
|
||||
Assert.Equal(1, service.CachedEntryCount);
|
||||
}
|
||||
|
||||
/// <summary>A hand-advanced clock for the cache-eviction tests.</summary>
|
||||
private sealed class ManualTimeProvider(DateTimeOffset start) : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = start;
|
||||
|
||||
/// <summary>Moves the clock forward.</summary>
|
||||
/// <param name="by">The amount to advance.</param>
|
||||
public void Advance(TimeSpan by) => _now += by;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
|
||||
private static string TokenJson(string accessToken, int expiresIn) =>
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user