Vote budgets: monotonic windows, a key cap, 30 mints an hour and a readable 429
FixedWindowBudget times windows on the clock's monotonic timestamps so a wall-clock step cannot stretch or reset one, expires windows from a start-ordered queue instead of a periodic sweep, and holds at most MaxTrackedKeys keys: a new key at the cap is denied with the true wait and a held key is never evicted. MintPermits is 30 per hour per the operator ruling, and a 429 now carries a plain-text reason; a refused new device is told it can sign in. Not yet built or tested at this commit. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2c8384e44a
commit
a8c3ecf495
8 changed files with 219 additions and 61 deletions
|
|
@ -28,12 +28,19 @@ public sealed class ConceptVoteOptions
|
|||
|
||||
public TimeSpan CastWindow { get; init; } = TimeSpan.FromMinutes(10);
|
||||
|
||||
// Mint: a new device cookie is a new voter identity, the cheapest stuffing lever, and a household has few devices,
|
||||
// so 5 new devices per hour per network prefix.
|
||||
public int MintPermits { get; init; } = 5;
|
||||
// Mint: a new device cookie is a new voter identity, the cheapest stuffing lever. Anonymous visitors share an
|
||||
// address behind carrier, school and office NAT (the research note records it), so 30 new devices per hour per
|
||||
// address (operator ruling concept-mill-votes:ruling:mint-budget-30-per-hour); per-device and per-prefix cast
|
||||
// limits still bound stuffing.
|
||||
public int MintPermits { get; init; } = 30;
|
||||
|
||||
public TimeSpan MintWindow { get; init; } = TimeSpan.FromHours(1);
|
||||
|
||||
// The most partitions each budget holds at once. At the cap a partition not yet held is refused with a 429 until
|
||||
// the oldest window ends; held partitions are never evicted, so a rotating caller cannot escape a spent budget.
|
||||
// 100000 partitions is a few tens of MB; a real audience is far below it.
|
||||
public int MaxTrackedKeys { get; init; } = 100_000;
|
||||
|
||||
// The origins whose pages may read the public tally export from a browser (CORS). Each entry is exactly
|
||||
// https://host[:non-default-port], lower case, no path; empty allows none. StartupConfigurationValidator
|
||||
// refuses anything else outside Development and Testing, because "*" would allow every origin.
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ namespace Bifrost.Web.Features.ConceptVotes;
|
|||
public sealed class ConceptVoteRateLimits(IOptions<ConceptVoteOptions> options, TimeProvider clock)
|
||||
{
|
||||
private readonly ConceptVoteOptions opts = options.Value;
|
||||
private readonly FixedWindowBudget cast = new(options.Value.CastPermits, options.Value.CastWindow, clock);
|
||||
private readonly FixedWindowBudget mint = new(options.Value.MintPermits, options.Value.MintWindow, clock);
|
||||
private readonly FixedWindowBudget cast = new(options.Value.CastPermits, options.Value.CastWindow, options.Value.MaxTrackedKeys, clock);
|
||||
private readonly FixedWindowBudget mint = new(options.Value.MintPermits, options.Value.MintWindow, options.Value.MaxTrackedKeys, clock);
|
||||
|
||||
public TimeSpan? DenyCast(IPAddress? client, VoterIdentity? known) =>
|
||||
cast.TryAcquire(known?.UserAccountId is { } account ? "account:" + account.ToString("N") : NetKey(client), out var wait)
|
||||
|
|
|
|||
|
|
@ -1,28 +1,37 @@
|
|||
namespace Bifrost.Web.Features.Shared;
|
||||
|
||||
// The one rate-limit mechanism: a fixed-window permit budget per caller-chosen partition key, read against an injected
|
||||
// clock. Keys are opaque and are never stored beyond their window: an entry whose window has ended is dropped by the
|
||||
// next sweep, so the table is bounded by the distinct keys seen in one window. System.Threading.RateLimiting is not
|
||||
// used because its limiters time themselves on Stopwatch and timers, which a test cannot move.
|
||||
// The one rate-limit mechanism: a fixed-window permit budget per caller-chosen partition key, timed on the injected
|
||||
// clock's monotonic timestamps (never its wall time, which can step), so a clock adjustment cannot stretch or reset a
|
||||
// window. Windows all have one length, so they end in the order they began: a queue of window starts expires them from
|
||||
// the front and the table never holds a key past its window. The table holds at most maxKeys keys; a new key arriving
|
||||
// at the cap is denied until the oldest window ends, with the true wait, so a rotating caller cannot evict a spent key
|
||||
// to escape its budget and the refusal is a 429 that says when to retry, never a silent drop.
|
||||
// System.Threading.RateLimiting is not used because its limiters time themselves on Stopwatch and timers, which a
|
||||
// test cannot move.
|
||||
public sealed class FixedWindowBudget
|
||||
{
|
||||
private readonly int permits;
|
||||
private readonly TimeSpan window;
|
||||
private readonly int maxKeys;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly long origin;
|
||||
private readonly Dictionary<string, Entry> entries = new(StringComparer.Ordinal);
|
||||
private readonly Queue<string> byStart = new();
|
||||
private readonly object gate = new();
|
||||
private DateTimeOffset nextSweep = DateTimeOffset.MinValue;
|
||||
|
||||
public FixedWindowBudget(int permits, TimeSpan window, TimeProvider clock)
|
||||
public FixedWindowBudget(int permits, TimeSpan window, int maxKeys, TimeProvider clock)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(permits, 1);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(window, TimeSpan.Zero);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(maxKeys, 1);
|
||||
this.permits = permits;
|
||||
this.window = window;
|
||||
this.maxKeys = maxKeys;
|
||||
this.clock = clock;
|
||||
origin = clock.GetTimestamp();
|
||||
}
|
||||
|
||||
// How many keys are held now: bounded by the distinct keys seen in one window, not by every key ever seen.
|
||||
// How many keys are held now: the keys whose window is still open, never more than maxKeys.
|
||||
public int TrackedKeys
|
||||
{
|
||||
get
|
||||
|
|
@ -34,44 +43,47 @@ public sealed class FixedWindowBudget
|
|||
}
|
||||
}
|
||||
|
||||
// Takes one permit for the key. When none is left, returns false and how long until its window ends.
|
||||
// Takes one permit for the key. When none is left, or the table is full and the key is new, returns false and how
|
||||
// long until a window ends and the answer can change.
|
||||
public bool TryAcquire(string key, out TimeSpan retryAfter)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
lock (gate)
|
||||
{
|
||||
if (now >= nextSweep)
|
||||
var now = clock.GetElapsedTime(origin);
|
||||
while (byStart.TryPeek(out var oldest) && entries[oldest].Start + window <= now)
|
||||
{
|
||||
foreach (var expired in entries.Where(x => x.Value.Start + window <= now).Select(x => x.Key).ToList())
|
||||
entries.Remove(byStart.Dequeue());
|
||||
}
|
||||
|
||||
if (entries.TryGetValue(key, out var entry))
|
||||
{
|
||||
if (entry.Count >= permits)
|
||||
{
|
||||
entries.Remove(expired);
|
||||
retryAfter = entry.Start + window - now;
|
||||
return false;
|
||||
}
|
||||
|
||||
nextSweep = now + window;
|
||||
}
|
||||
|
||||
if (!entries.TryGetValue(key, out var entry) || entry.Start + window <= now)
|
||||
{
|
||||
entries[key] = new Entry(now);
|
||||
retryAfter = TimeSpan.Zero;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entry.Count < permits)
|
||||
{
|
||||
entry.Count++;
|
||||
retryAfter = TimeSpan.Zero;
|
||||
return true;
|
||||
}
|
||||
|
||||
retryAfter = entry.Start + window - now;
|
||||
return false;
|
||||
if (entries.Count >= maxKeys)
|
||||
{
|
||||
retryAfter = entries[byStart.Peek()].Start + window - now;
|
||||
return false;
|
||||
}
|
||||
|
||||
entries[key] = new Entry(now);
|
||||
byStart.Enqueue(key);
|
||||
retryAfter = TimeSpan.Zero;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Entry(DateTimeOffset start)
|
||||
private sealed class Entry(TimeSpan start)
|
||||
{
|
||||
public DateTimeOffset Start { get; } = start;
|
||||
public TimeSpan Start { get; } = start;
|
||||
|
||||
public int Count { get; set; } = 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,11 @@ public sealed class StartupConfigurationValidator(
|
|||
failures.Add("ConceptVotes:MintPermits must be at least 1 and ConceptVotes:MintWindow must be positive.");
|
||||
}
|
||||
|
||||
if (conceptVotes.MaxTrackedKeys < 1)
|
||||
{
|
||||
failures.Add("ConceptVotes:MaxTrackedKeys must be at least 1.");
|
||||
}
|
||||
|
||||
if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing"))
|
||||
{
|
||||
Refuse(failures);
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ public sealed class VoteModel(
|
|||
{
|
||||
var actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
var voter = ResolveVoter(actor);
|
||||
if (Throttled(limits.DenyCast(HttpContext.Connection.RemoteIpAddress, voter)) is { } throttled)
|
||||
if (Throttled(limits.DenyCast(HttpContext.Connection.RemoteIpAddress, voter), CastRefusal) is { } throttled)
|
||||
{
|
||||
return throttled;
|
||||
}
|
||||
|
|
@ -100,7 +100,7 @@ public sealed class VoteModel(
|
|||
var actor = await actorAccessor.GetAsync(cancellationToken);
|
||||
var client = HttpContext.Connection.RemoteIpAddress;
|
||||
var voter = ResolveVoter(actor);
|
||||
if (Throttled(limits.DenyCast(client, voter)) is { } throttled)
|
||||
if (Throttled(limits.DenyCast(client, voter), CastRefusal) is { } throttled)
|
||||
{
|
||||
return throttled;
|
||||
}
|
||||
|
|
@ -112,7 +112,7 @@ public sealed class VoteModel(
|
|||
|
||||
if (voter is null)
|
||||
{
|
||||
if (Throttled(limits.DenyMint(client)) is { } mintThrottled)
|
||||
if (Throttled(limits.DenyMint(client), MintRefusal) is { } mintThrottled)
|
||||
{
|
||||
return mintThrottled;
|
||||
}
|
||||
|
|
@ -132,8 +132,14 @@ public sealed class VoteModel(
|
|||
private VoterIdentity? ResolveVoter(CurrentBifrostActor actor) =>
|
||||
VoterIdentity.For(actor, null) ?? VoterIdentity.For(actor, deviceCookie.TryRead(HttpContext));
|
||||
|
||||
// A denied budget is a 429 that says when to retry; it writes no row and sets no cookie.
|
||||
private IActionResult? Throttled(TimeSpan? retryAfter)
|
||||
// A denied budget is a 429 that says when to retry, in words a voter can read; it writes no row, sets no cookie and
|
||||
// repeats nothing of the request. A refused new device is told it can sign in, which has a budget of its own.
|
||||
private const string CastRefusal = "You are voting too fast. Please wait a little before voting again.";
|
||||
|
||||
private const string MintRefusal =
|
||||
"Too many new voters have voted from your network recently. You can sign in to vote now, or try again later.";
|
||||
|
||||
private IActionResult? Throttled(TimeSpan? retryAfter, string refusal)
|
||||
{
|
||||
if (retryAfter is not { } wait)
|
||||
{
|
||||
|
|
@ -141,7 +147,12 @@ public sealed class VoteModel(
|
|||
}
|
||||
|
||||
Response.Headers.RetryAfter = ((int)Math.Ceiling(wait.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
return new StatusCodeResult(StatusCodes.Status429TooManyRequests);
|
||||
return new ContentResult
|
||||
{
|
||||
StatusCode = StatusCodes.Status429TooManyRequests,
|
||||
ContentType = "text/plain; charset=utf-8",
|
||||
Content = refusal
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<bool> LoadAsync(string collection, string key, VoterIdentity? voter, CancellationToken cancellationToken)
|
||||
|
|
|
|||
|
|
@ -206,6 +206,10 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture<ConceptVoteRateLim
|
|||
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
|
||||
Assert.Null(SetCookie(denied));
|
||||
Assert.Equal(rowsBefore, (await Rows(collection)).Count);
|
||||
Assert.StartsWith("text/plain", denied.Content.Headers.ContentType?.MediaType);
|
||||
var refusal = await denied.Content.ReadAsStringAsync();
|
||||
Assert.Contains("sign in", refusal, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("203.0.113", refusal);
|
||||
|
||||
// An hour after the first mint the budget is whole again.
|
||||
_factory.Clock.Advance(TimeSpan.FromMinutes(50));
|
||||
|
|
@ -247,12 +251,39 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture<ConceptVoteRateLim
|
|||
Assert.Null(SetCookie(denied));
|
||||
var after = Assert.Single(await Rows(collection));
|
||||
Assert.Equal(before.Stance, after.Stance);
|
||||
var seen = await denied.Content.ReadAsStringAsync()
|
||||
var body = await denied.Content.ReadAsStringAsync();
|
||||
Assert.Contains("too fast", body);
|
||||
var seen = body
|
||||
+ string.Join("\n", denied.Headers.Select(h => h.Key + ":" + string.Join(",", h.Value)));
|
||||
Assert.DoesNotContain("203.0.113", seen);
|
||||
Assert.DoesNotContain(logs.Lines, line => line.Contains("203.0.113", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defaults_allow_thirty_new_devices_an_hour_per_address()
|
||||
{
|
||||
var defaults = new Bifrost.Web.Configuration.ConceptVoteOptions();
|
||||
|
||||
Assert.Equal(30, defaults.MintPermits);
|
||||
Assert.Equal(TimeSpan.FromHours(1), defaults.MintWindow);
|
||||
Assert.Equal(100_000, defaults.MaxTrackedKeys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Both_budgets_hold_at_most_the_configured_number_of_partitions_and_refuse_a_new_one_at_the_cap()
|
||||
{
|
||||
var clock = new TestClock();
|
||||
var limits = new Bifrost.Web.Features.ConceptVotes.ConceptVoteRateLimits(
|
||||
Microsoft.Extensions.Options.Options.Create(new Bifrost.Web.Configuration.ConceptVoteOptions { MaxTrackedKeys = 1 }),
|
||||
clock);
|
||||
|
||||
Assert.Null(limits.DenyCast(IPAddress.Parse("198.51.100.1"), null));
|
||||
Assert.NotNull(limits.DenyCast(IPAddress.Parse("198.51.100.2"), null));
|
||||
Assert.Null(limits.DenyMint(IPAddress.Parse("198.51.100.1")));
|
||||
Assert.NotNull(limits.DenyMint(IPAddress.Parse("198.51.100.2")));
|
||||
Assert.Null(limits.DenyCast(IPAddress.Parse("198.51.100.1"), null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_are_not_under_the_vote_budgets()
|
||||
{
|
||||
|
|
@ -387,10 +418,19 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture<ConceptVoteRateLim
|
|||
public sealed class TestClock : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = DateTimeOffset.UtcNow;
|
||||
private long _ticks = 5_000_000;
|
||||
|
||||
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan by) => _now += by;
|
||||
public override long GetTimestamp() => _ticks;
|
||||
|
||||
public void Advance(TimeSpan by)
|
||||
{
|
||||
_now += by;
|
||||
_ticks += by.Ticks;
|
||||
}
|
||||
}
|
||||
|
||||
// The proxy's connection: TestServer has no socket, so the peer address is set ahead of the app pipeline.
|
||||
|
|
|
|||
|
|
@ -5,12 +5,13 @@ namespace Bifrost.Web.Tests;
|
|||
public sealed class FixedWindowBudgetTests
|
||||
{
|
||||
private static readonly TimeSpan Window = TimeSpan.FromSeconds(60);
|
||||
private const int Cap = 1000;
|
||||
|
||||
[Fact]
|
||||
public void Permits_are_spent_per_key_and_the_next_is_denied_with_the_time_left()
|
||||
{
|
||||
var clock = new Clock();
|
||||
var budget = new FixedWindowBudget(2, Window, clock);
|
||||
var budget = new FixedWindowBudget(2, Window, Cap, clock);
|
||||
|
||||
Assert.True(budget.TryAcquire("a", out _));
|
||||
clock.Advance(TimeSpan.FromSeconds(10));
|
||||
|
|
@ -25,7 +26,7 @@ public sealed class FixedWindowBudgetTests
|
|||
public void A_window_opens_at_its_first_permit_and_closes_exactly_one_window_later()
|
||||
{
|
||||
var clock = new Clock();
|
||||
var budget = new FixedWindowBudget(1, Window, clock);
|
||||
var budget = new FixedWindowBudget(1, Window, Cap, clock);
|
||||
Assert.True(budget.TryAcquire("a", out _));
|
||||
|
||||
clock.Advance(Window - TimeSpan.FromTicks(1));
|
||||
|
|
@ -42,7 +43,7 @@ public sealed class FixedWindowBudgetTests
|
|||
public void A_denied_attempt_does_not_extend_the_window()
|
||||
{
|
||||
var clock = new Clock();
|
||||
var budget = new FixedWindowBudget(1, Window, clock);
|
||||
var budget = new FixedWindowBudget(1, Window, Cap, clock);
|
||||
budget.TryAcquire("a", out _);
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
|
|
@ -55,16 +56,16 @@ public sealed class FixedWindowBudgetTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void A_sweep_drops_only_expired_windows_and_a_live_one_keeps_counting_to_its_exact_end()
|
||||
public void An_expired_window_is_dropped_and_a_live_one_keeps_counting_to_its_exact_end()
|
||||
{
|
||||
var clock = new Clock();
|
||||
var budget = new FixedWindowBudget(1, Window, clock);
|
||||
var budget = new FixedWindowBudget(1, Window, Cap, clock);
|
||||
budget.TryAcquire("a", out _);
|
||||
clock.Advance(TimeSpan.FromSeconds(30));
|
||||
budget.TryAcquire("b", out _);
|
||||
clock.Advance(TimeSpan.FromSeconds(30));
|
||||
|
||||
// The sweep due at 60 s drops a, which ended, and keeps b, which has 30 s left.
|
||||
// At 60 s a has ended and b has 30 s left.
|
||||
budget.TryAcquire("c", out _);
|
||||
Assert.Equal(2, budget.TrackedKeys);
|
||||
Assert.False(budget.TryAcquire("b", out var wait));
|
||||
|
|
@ -77,29 +78,79 @@ public sealed class FixedWindowBudgetTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void Sweeping_is_at_most_once_per_window_so_a_flood_of_keys_costs_one_pass_not_one_per_request()
|
||||
public void A_wall_clock_step_back_does_not_extend_a_window_or_its_retry_after()
|
||||
{
|
||||
var clock = new Clock();
|
||||
var budget = new FixedWindowBudget(1, Window, clock);
|
||||
var budget = new FixedWindowBudget(1, Window, Cap, clock);
|
||||
budget.TryAcquire("a", out _);
|
||||
clock.Advance(TimeSpan.FromSeconds(30));
|
||||
clock.Advance(TimeSpan.FromSeconds(10));
|
||||
clock.StepWall(-TimeSpan.FromHours(1));
|
||||
|
||||
Assert.False(budget.TryAcquire("a", out var wait));
|
||||
Assert.Equal(TimeSpan.FromSeconds(50), wait);
|
||||
clock.Advance(TimeSpan.FromSeconds(50));
|
||||
Assert.True(budget.TryAcquire("a", out _));
|
||||
Assert.Equal(1, budget.TrackedKeys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_wall_clock_step_forward_does_not_reset_a_window()
|
||||
{
|
||||
var clock = new Clock();
|
||||
var budget = new FixedWindowBudget(1, Window, Cap, clock);
|
||||
budget.TryAcquire("a", out _);
|
||||
clock.StepWall(TimeSpan.FromHours(1));
|
||||
|
||||
Assert.False(budget.TryAcquire("a", out var wait));
|
||||
Assert.Equal(Window, wait);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_new_key_at_the_cap_is_denied_until_the_oldest_window_ends_and_a_held_key_is_never_evicted()
|
||||
{
|
||||
var clock = new Clock();
|
||||
var budget = new FixedWindowBudget(1, Window, 2, clock);
|
||||
budget.TryAcquire("a", out _);
|
||||
clock.Advance(TimeSpan.FromSeconds(20));
|
||||
budget.TryAcquire("b", out _);
|
||||
clock.Advance(TimeSpan.FromSeconds(31));
|
||||
budget.TryAcquire("c", out _);
|
||||
clock.Advance(TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.False(budget.TryAcquire("c", out var wait));
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), wait);
|
||||
Assert.Equal(2, budget.TrackedKeys);
|
||||
|
||||
// b ended at 90 s but the next sweep is not due until 121 s.
|
||||
clock.Advance(TimeSpan.FromSeconds(39));
|
||||
budget.TryAcquire("d", out _);
|
||||
// Rotating new keys cannot push a spent key out to get its budget back.
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
Assert.False(budget.TryAcquire("rot" + i, out _));
|
||||
}
|
||||
|
||||
Assert.Equal(3, budget.TrackedKeys);
|
||||
Assert.False(budget.TryAcquire("a", out wait));
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), wait);
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(30));
|
||||
Assert.True(budget.TryAcquire("c", out _));
|
||||
Assert.Equal(2, budget.TrackedKeys);
|
||||
Assert.False(budget.TryAcquire("b", out wait));
|
||||
Assert.Equal(TimeSpan.FromSeconds(20), wait);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_held_key_under_its_permits_is_served_even_when_the_table_is_full()
|
||||
{
|
||||
var clock = new Clock();
|
||||
var budget = new FixedWindowBudget(2, Window, 1, clock);
|
||||
Assert.True(budget.TryAcquire("a", out _));
|
||||
|
||||
Assert.True(budget.TryAcquire("a", out _));
|
||||
Assert.False(budget.TryAcquire("a", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expired_windows_are_dropped_so_the_table_does_not_grow_with_every_key_ever_seen()
|
||||
{
|
||||
var clock = new Clock();
|
||||
var budget = new FixedWindowBudget(1, Window, clock);
|
||||
var budget = new FixedWindowBudget(1, Window, Cap, clock);
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
budget.TryAcquire("k" + i, out _);
|
||||
|
|
@ -120,15 +171,33 @@ public sealed class FixedWindowBudgetTests
|
|||
public void A_budget_that_cannot_be_enforced_is_refused(int permits, int seconds)
|
||||
{
|
||||
Assert.ThrowsAny<ArgumentException>(
|
||||
() => new FixedWindowBudget(permits, TimeSpan.FromSeconds(seconds), new Clock()));
|
||||
() => new FixedWindowBudget(permits, TimeSpan.FromSeconds(seconds), Cap, new Clock()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_cap_below_one_is_refused()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new FixedWindowBudget(1, Window, 0, new Clock()));
|
||||
}
|
||||
|
||||
// Time advances monotonic and wall together; StepWall moves only the wall clock, as an NTP step would.
|
||||
private sealed class Clock : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = new(2026, 10, 11, 12, 0, 0, TimeSpan.Zero);
|
||||
private DateTimeOffset _wall = new(2026, 10, 11, 12, 0, 0, TimeSpan.Zero);
|
||||
private long _ticks = 5_000_000;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
|
||||
|
||||
public void Advance(TimeSpan by) => _now += by;
|
||||
public override DateTimeOffset GetUtcNow() => _wall;
|
||||
|
||||
public override long GetTimestamp() => _ticks;
|
||||
|
||||
public void Advance(TimeSpan by)
|
||||
{
|
||||
_wall += by;
|
||||
_ticks += by.Ticks;
|
||||
}
|
||||
|
||||
public void StepWall(TimeSpan by) => _wall += by;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,6 +229,18 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
|
|||
Validate(keysPath: "/keys", environment: environment);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Development")]
|
||||
[InlineData("Testing")]
|
||||
[InlineData("Production")]
|
||||
public void Every_environment_refuses_a_vote_table_cap_below_one(string environment)
|
||||
{
|
||||
var refusal = Assert.Throws<InvalidOperationException>(
|
||||
() => Validate(keysPath: "/keys", environment: environment, maxTrackedKeys: 0));
|
||||
|
||||
Assert.Contains("ConceptVotes:MaxTrackedKeys", refusal.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("*")]
|
||||
[InlineData("")]
|
||||
|
|
@ -315,7 +327,8 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
|
|||
int castPermits = 30,
|
||||
TimeSpan? castWindow = null,
|
||||
int mintPermits = 5,
|
||||
TimeSpan? mintWindow = null)
|
||||
TimeSpan? mintWindow = null,
|
||||
int maxTrackedKeys = 100_000)
|
||||
{
|
||||
var values = new Dictionary<string, string?>
|
||||
{
|
||||
|
|
@ -347,7 +360,8 @@ public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
|
|||
CastPermits = castPermits,
|
||||
CastWindow = castWindow ?? TimeSpan.FromMinutes(10),
|
||||
MintPermits = mintPermits,
|
||||
MintWindow = mintWindow ?? TimeSpan.FromHours(1)
|
||||
MintWindow = mintWindow ?? TimeSpan.FromHours(1),
|
||||
MaxTrackedKeys = maxTrackedKeys
|
||||
})).Validate();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue