fix(inbound): prune expired ApiMethodCache entries; pin exact-name scopes (review 2026-10-21 05/M3)

This commit is contained in:
Joseph Doherty
2026-10-22 08:29:13 -04:00
parent d7e87c0688
commit 6c9dfaf700
3 changed files with 67 additions and 0 deletions
@@ -122,4 +122,24 @@ public class ApiMethodCacheTests
/// <inheritdoc />
public override DateTimeOffset GetUtcNow() => _now;
}
/// <summary>Verifies that expired entries are pruned on a miss once the cache is large.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GetOrFetchAsync_Miss_PrunesExpiredEntries()
{
// Review 2026-10-21 05/M3: expired entries for methods no longer called were never evicted.
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var cache = new ApiMethodCache(TimeSpan.FromSeconds(1), time);
Task<ApiMethod?> Fetch(CancellationToken _) => Task.FromResult<ApiMethod?>(null);
for (var i = 0; i < 300; i++)
await cache.GetOrFetchAsync("m" + i, Fetch, CancellationToken.None);
Assert.Equal(300, cache.Count);
time.Advance(TimeSpan.FromSeconds(2));
await cache.GetOrFetchAsync("fresh", Fetch, CancellationToken.None);
Assert.Equal(1, cache.Count);
}
}
@@ -327,6 +327,27 @@ public sealed class EndpointExtensionsTests : IDisposable
Assert.Equal(NotApprovedBodyJson, body);
}
/// <summary>Verifies that scopes are exact method names, not prefixes or wildcards.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("*")]
[InlineData("sec*")]
[InlineData("sec")]
public async Task ValidKey_WildcardOrPrefixScope_DoesNotGrantMethod(string scope)
{
// Review 2026-10-21 05/M3: ApiMethodCache bounds its growth on the invariant that scopes
// are exact names; pin it so a future wildcard scope has to revisit the cache.
var method = SeedMethod(1, "secured", "return 1;");
using var host = await BuildHostAsync(method);
var token = await SeedKeyAsync(host, "key1", "caller", new[] { scope });
var client = host.GetTestClient();
var response = await client.SendAsync(BuildPost("secured", "{}", token));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
/// <summary>Verifies that an unknown method returns 403 with a body identical to the not-in-scope body.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]