Skip to content

Commit a5999d1

Browse files
Merge pull request #234 from DuendeSoftware/ev/atm/extend-expiration-tests
Improve the test coverage of auto cache tuning.
2 parents 5f06f55 + 59a66a9 commit a5999d1

File tree

8 files changed

+313
-14
lines changed

8 files changed

+313
-14
lines changed

access-token-management/test/AccessTokenManagement.Tests/AccessTokenHandler/AccessTokenHandlerTests.cs

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,53 @@ public async Task Access_tokens_are_cached(FixtureType type)
4747
}
4848

4949
[Fact]
50-
public async Task Caching_of_tokens_is_optimized()
50+
public async Task Uses_auto_tuning_in_cache_expiration()
5151
{
52-
var fixture = await GetInitializedFixture(FixtureType.ClientCredentialsWithAutotuning);
52+
// hybrid cache doesn't allow us to set the cache expiration based on the
53+
// lifetime of a token after it's retrieved. To circumvent this, we implemented cache autotuning.
54+
// Cache Auto tuning does the following:
55+
// the first time a token is retrieved, the cache expiration from the default setting is used
56+
// however, after that, it will remember the lifetime of the token, and use that to set the cache expiration
5357

58+
var fixture = (ClientCredentialsFixtureWithAutotuning)
59+
await GetInitializedFixture(FixtureType.ClientCredentialsWithAutotuning);
60+
61+
// We get an access token. The cache interval is not known, so we expect it to be cached for the default cache duration
62+
await EnsureTokenNumber(fixture, 1);
63+
64+
// Ensure it's cached.
5465
await fixture.HttpClient.GetAsync("/").CheckHttpStatusCode();
55-
await Task.Delay(100);
56-
await fixture.HttpClient.GetAsync("/").CheckHttpStatusCode();
57-
await Task.Delay(100);
58-
await fixture.HttpClient.GetAsync("/").CheckHttpStatusCode();
66+
fixture.ApiEndpoint.LastUsedAccessToken.ShouldBe("access_token_1");
67+
await EnsureTokenNumber(fixture, 1);
5968

60-
fixture.ApiEndpoint.LastUsedAccessToken.ShouldBe("access_token_2");
69+
// Increase the time by too little time
70+
AdvanceTimeBy(fixture, fixture.CacheExpiration - TimeSpan.FromSeconds(2));
71+
await EnsureTokenNumber(fixture, 1);
72+
73+
// Increase the time by a bit more - now we expect the token to be expired, and a new one to be fetched
74+
AdvanceTimeBy(fixture, TimeSpan.FromSeconds(2));
75+
await EnsureTokenNumber(fixture, 2);
76+
77+
// Now increase the time by the cache expiration again. It should NOT be expired now
78+
// because the auto tuning has kicked in and has used the token expiration lifetime
79+
// (which is much longer)
80+
AdvanceTimeBy(fixture, fixture.CacheExpiration);
81+
await EnsureTokenNumber(fixture, 2);
82+
83+
// But if we wait for the token expiration, then it should expire.
84+
AdvanceTimeBy(fixture, fixture.TokenExpiration);
85+
await EnsureTokenNumber(fixture, 3);
6186
}
87+
88+
private static void AdvanceTimeBy(AccessTokenHandlingBaseFixture fixture, TimeSpan by)
89+
=> fixture.The.CurrentDate += by;
90+
91+
private static async Task EnsureTokenNumber(AccessTokenHandlingBaseFixture fixture, int number)
92+
{
93+
await fixture.HttpClient.GetAsync("/").CheckHttpStatusCode();
94+
fixture.ApiEndpoint.LastUsedAccessToken.ShouldBe("access_token_" + number);
95+
}
96+
6297
[Theory]
6398
[MemberData(nameof(AllFixtures))]
6499
public async Task Will_refresh_token_when_access_token_is_rejected(FixtureType type)

access-token-management/test/AccessTokenManagement.Tests/AccessTokenHandler/Fixtures/AccessTokenHandlingBaseFixture.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Duende.AccessTokenManagement.AccessTokenHandler.Helpers;
55
using Duende.AccessTokenManagement.DPoP;
66
using Duende.AccessTokenManagement.Framework;
7+
using Microsoft.Extensions.Caching.Memory;
78
using Microsoft.Extensions.DependencyInjection;
89
using Microsoft.Extensions.Logging;
910

@@ -18,6 +19,7 @@ internal abstract class AccessTokenHandlingBaseFixture : IAsyncDisposable
1819
public readonly ApiHttpMessageHandler ApiEndpoint = new();
1920

2021
public readonly TokenHttpMessageHandler TokenEndpoint = new();
22+
public TimeSpan TokenExpiration = TimeSpan.FromSeconds(3600);
2123

2224
protected ServiceCollection Services = null!;
2325

@@ -32,9 +34,13 @@ internal abstract class AccessTokenHandlingBaseFixture : IAsyncDisposable
3234
public async ValueTask InitializeAsync(ITestOutputHelper output, DPoPProofKey? dPoPJsonWebKey)
3335
{
3436
ApiEndpoint.DefaultRespondOkWithToken();
35-
TokenEndpoint.DefaultRespondWithAccessToken();
37+
TokenEndpoint.DefaultRespondWithAccessToken((int)TokenExpiration.TotalSeconds);
3638

3739
Services = new ServiceCollection();
40+
Services.Configure<MemoryCacheOptions>(options =>
41+
{
42+
options.Clock = new FakeTimeProvider(() => The.CurrentDate);
43+
});
3844
Services.AddLogging(log => log.AddProvider(new TestLoggerProvider(output.Write, "test")));
3945
Services.AddHttpClient("tokenHttpClient")
4046
.ConfigureHttpClient(c =>

access-token-management/test/AccessTokenManagement.Tests/AccessTokenHandler/Fixtures/ClientCredentialsFixtureWithAutoTuning.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,28 @@
22
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
33

44
using Duende.AccessTokenManagement.DPoP;
5+
using Duende.AccessTokenManagement.Framework;
6+
using Duende.AccessTokenManagement.Tests;
7+
using Microsoft.Extensions.Caching.Distributed;
58
using Microsoft.Extensions.DependencyInjection;
69

710
namespace Duende.AccessTokenManagement.AccessTokenHandler.Fixtures;
811

912
internal class ClientCredentialsFixtureWithAutotuning : AccessTokenHandlingBaseFixture
1013
{
14+
public TimeSpan CacheExpiration = TimeSpan.FromMinutes(5);
15+
1116
public override ValueTask InitializeAsync(DPoPProofKey? dPoPJsonWebKey)
1217
{
18+
Services.AddDistributedMemoryCache();
19+
Services.AddSingleton<IDistributedCache>(new FakeDistributedCache(new FakeTimeProvider(() => The.CurrentDate)));
1320
Services.AddClientCredentialsTokenManagement(options =>
1421
{
1522
options.UseCacheAutoTuning = true;
16-
options.DefaultCacheLifetime = TimeSpan.FromMilliseconds(200);
23+
24+
// explicitly set the local cache expiration very low. this makes sure the remote cache is used.
25+
options.LocalCacheExpiration = TimeSpan.FromMilliseconds(10);
26+
options.DefaultCacheLifetime = CacheExpiration;
1727
})
1828
.AddClient("tokenClient", opt =>
1929
{

access-token-management/test/AccessTokenManagement.Tests/AccessTokenHandler/Helpers/TokenHttpMessageHandler.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,10 @@ public void RespondWithTokenType(string tokenType) => this.Expect(HttpMethod.Pos
3939
});
4040
});
4141

42-
public void DefaultRespondWithAccessToken() => this.When(HttpMethod.Post, TokenEndpoint.ToString())
43-
.Respond(_ =>
42+
public void DefaultRespondWithAccessToken(int? expireInSeconds = 3600) => this.When(HttpMethod.Post, TokenEndpoint.ToString())
43+
.Respond(request =>
4444
{
45-
var initialTokenResponse = BuildAccessToken();
45+
var initialTokenResponse = BuildAccessToken(expireInSeconds);
4646

4747
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
4848
{
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Copyright (c) Duende Software. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
3+
4+
using System.Collections.Concurrent;
5+
using Microsoft.Extensions.Caching.Distributed;
6+
7+
namespace Duende.AccessTokenManagement.Tests;
8+
9+
/// <summary>
10+
/// Implementation of a IDistributedCache for testing purposes that supports absolute and sliding expiration.
11+
/// </summary>
12+
/// <param name="timeProvider"></param>
13+
public class FakeDistributedCache(TimeProvider timeProvider) : IDistributedCache
14+
{
15+
private readonly TimeProvider _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
16+
private readonly ConcurrentDictionary<string, CacheItem> _store = new();
17+
18+
private class CacheItem
19+
{
20+
public byte[] Value { get; }
21+
public DateTimeOffset? AbsoluteExpiration { get; }
22+
public TimeSpan? SlidingExpiration { get; }
23+
public DateTimeOffset LastAccess { get; set; }
24+
25+
public CacheItem(byte[] value, DateTimeOffset? absoluteExpiration, TimeSpan? slidingExpiration, DateTimeOffset now)
26+
{
27+
Value = value;
28+
AbsoluteExpiration = absoluteExpiration;
29+
SlidingExpiration = slidingExpiration;
30+
LastAccess = now;
31+
}
32+
33+
public bool IsExpired(DateTimeOffset now)
34+
{
35+
if (AbsoluteExpiration.HasValue && now >= AbsoluteExpiration.Value)
36+
{
37+
return true;
38+
}
39+
40+
if (SlidingExpiration.HasValue && now - LastAccess >= SlidingExpiration.Value)
41+
{
42+
return true;
43+
}
44+
45+
return false;
46+
}
47+
}
48+
49+
public byte[]? Get(string key)
50+
{
51+
var now = _timeProvider.GetUtcNow();
52+
if (_store.TryGetValue(key, out var item))
53+
{
54+
if (item.IsExpired(now))
55+
{
56+
_store.TryRemove(key, out _);
57+
return null;
58+
}
59+
if (item.SlidingExpiration.HasValue)
60+
{
61+
item.LastAccess = now;
62+
}
63+
64+
return item.Value;
65+
}
66+
return null;
67+
}
68+
69+
public Task<byte[]?> GetAsync(string key, CancellationToken token = default) => Task.FromResult(Get(key));
70+
71+
public void Set(string key, byte[] value, DistributedCacheEntryOptions options)
72+
{
73+
var now = _timeProvider.GetUtcNow();
74+
DateTimeOffset? absoluteExpiration = null;
75+
TimeSpan? slidingExpiration = null;
76+
77+
if (options.AbsoluteExpirationRelativeToNow.HasValue)
78+
{
79+
absoluteExpiration = now + options.AbsoluteExpirationRelativeToNow.Value;
80+
}
81+
else if (options.AbsoluteExpiration.HasValue)
82+
{
83+
absoluteExpiration = options.AbsoluteExpiration.Value;
84+
}
85+
86+
if (options.SlidingExpiration.HasValue)
87+
{
88+
slidingExpiration = options.SlidingExpiration.Value;
89+
}
90+
91+
var item = new CacheItem(value, absoluteExpiration, slidingExpiration, now);
92+
_store[key] = item;
93+
}
94+
95+
public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default)
96+
{
97+
Set(key, value, options);
98+
return Task.CompletedTask;
99+
}
100+
101+
public void Refresh(string key)
102+
{
103+
var now = _timeProvider.GetUtcNow();
104+
if (_store.TryGetValue(key, out var item))
105+
{
106+
if (item.IsExpired(now))
107+
{
108+
_store.TryRemove(key, out _);
109+
}
110+
else if (item.SlidingExpiration.HasValue)
111+
{
112+
item.LastAccess = now;
113+
}
114+
}
115+
}
116+
117+
public Task RefreshAsync(string key, CancellationToken token = default)
118+
{
119+
Refresh(key);
120+
return Task.CompletedTask;
121+
}
122+
123+
public void Remove(string key) => _store.TryRemove(key, out _);
124+
125+
public Task RemoveAsync(string key, CancellationToken token = default)
126+
{
127+
Remove(key);
128+
return Task.CompletedTask;
129+
}
130+
}
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
// Copyright (c) Duende Software. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
33

4+
using Microsoft.Extensions.Internal;
5+
46
namespace Duende.AccessTokenManagement.Framework;
5-
internal class FakeTimeProvider(Func<DateTimeOffset> utcNow) : TimeProvider
7+
internal class FakeTimeProvider(Func<DateTimeOffset> utcNow) : TimeProvider, ISystemClock
68
{
79
public override DateTimeOffset GetUtcNow() => utcNow();
10+
public DateTimeOffset UtcNow => utcNow();
811
}

access-token-management/test/AccessTokenManagement.Tests/Framework/TestData.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public partial class TestData
2828

2929
public DPoPProofKey JsonWebKey { get; } = BuildDPoPJsonWebKey();
3030

31-
public DateTimeOffset CurrentDate { get; } = new(2000, 1, 2, 3, 4, 5, TimeSpan.FromHours(6));
31+
public DateTimeOffset CurrentDate { get; set; } = new(2000, 1, 2, 3, 4, 5, TimeSpan.FromHours(6));
3232

3333
public int ExpiresInSeconds { get; set; } = 60;
3434

0 commit comments

Comments
 (0)