fix(notification-service): evict stale OAuth2 token cache entries after credential rotation (review 2026-10-21 06/MEM-3)

This commit is contained in:
Joseph Doherty
2026-10-22 09:17:52 -04:00
parent 7e08a1dbfb
commit ebf4b96789
3 changed files with 119 additions and 4 deletions
@@ -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
{