677263af57
The per-IP unlock throttle's counters were a process-local ConcurrentDictionary on each central node, so the effective fleet cap was N x MaxUnlockAttemptsPerIpPerHour per client (failing over to the other node handed the client a fresh budget) and a node restart cleared the window outright. The window now lives in the central MS SQL BundleUnlockAttempt table — the HA store both central nodes already share, so no new infrastructure, no new port, no consensus protocol: - Commons: BundleUnlockAttempt entity + IBundleUnlockAttemptRepository. - ConfigurationDatabase: entity config (one index, IX_BundleUnlockAttempt_Client_Attempted, serving both the windowed count and the prune), repository (AsNoTracking count, ExecuteDeleteAsync prune), DbSet, DI registration, migration AddBundleUnlockAttemptTable + the hand-written idempotent production script under docs/plans/sql/. - Transport: IBundleUnlockAttemptWindow seam + SqlBundleUnlockAttemptWindow (singleton; resolves the scoped repository from its own scope per call, like the central purge singletons). BundleUnlockRateLimiter keeps the policy and gains TryRegisterAttemptAsync; BundleImporter.LoadAsync calls it. Behaviour preserved from row 44: locked bundles spend no hourly budget (the lockout check still runs first), throttle denials burn no per-bundle strike and are NOT recorded so they cannot roll the window forward, unattributable clients share the stricter <unattributed> bucket, and the budget counts attempts rather than failures. Config keys and their semantics are unchanged, so the rig's 500 override stays valid. Three deliberate trades, documented in both Transport docs: - FAILS OPEN. A store error logs a Warning naming the client and cap, and permits the attempt. This is a best-effort rate cap over an already authenticated, Administrator-gated surface — the availability of legitimate imports outranks the cap, and a DB blip must not make the import wizard unusable. Falling back to the process-local window was considered and rejected: it would still block a legitimate operator's 11th import. - Approximate. Count-then-insert is not atomic across nodes, so simultaneous attempts can overshoot by the number in flight — bounded, self-correcting within the window, and not worth provider-specific raw SQL. - Pruned opportunistically on the unlock path (CAS-claimed at most every 15 min per process, bounded to 1000 rows, retaining 2x the window, best-effort try/catch) rather than by a new background service — Transport owns no central scheduler. Same shape as DeploymentService's terminal-record sweep. Tests: Transport.IntegrationTests 116 passed (+10 — two limiter instances over one store enforcing a single combined window, restart durability, denials burning no budget, sliding release, bucket isolation, store-failure fail-open, caller-cancellation not swallowed, no-shared-window fallback, prune and its negative control); ConfigurationDatabase 395 passed (+5).
91 lines
3.7 KiB
C#
91 lines
3.7 KiB
C#
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Repositories;
|
|
|
|
/// <summary>
|
|
/// Register row 52 coverage for <see cref="BundleUnlockAttemptRepository"/> over the
|
|
/// in-memory SQLite harness — the fleet-wide bundle-unlock throttle window's persistence
|
|
/// layer. Exercises the windowed count (including the bucket-isolation and window-boundary
|
|
/// cases the throttle depends on) and the bounded prune.
|
|
/// </summary>
|
|
public class BundleUnlockAttemptRepositoryTests
|
|
{
|
|
// Fixed UTC base so the window assertions are deterministic.
|
|
private static readonly DateTime Base = new(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc);
|
|
|
|
private static ScadaBridgeDbContext NewContext() => SqliteTestHelper.CreateInMemoryContext();
|
|
|
|
[Fact]
|
|
public async Task CountAttemptsSinceAsync_CountsOnlyTheGivenBucketInsideTheWindow()
|
|
{
|
|
await using var ctx = NewContext();
|
|
var repo = new BundleUnlockAttemptRepository(ctx);
|
|
|
|
// Two inside the trailing hour, one aged out, one from a different client.
|
|
await repo.RecordAttemptAsync("10.0.0.1", Base.AddMinutes(-10));
|
|
await repo.RecordAttemptAsync("10.0.0.1", Base.AddMinutes(-50));
|
|
await repo.RecordAttemptAsync("10.0.0.1", Base.AddMinutes(-90));
|
|
await repo.RecordAttemptAsync("10.0.0.2", Base.AddMinutes(-5));
|
|
|
|
var count = await repo.CountAttemptsSinceAsync("10.0.0.1", Base.AddHours(-1));
|
|
|
|
Assert.Equal(2, count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CountAttemptsSinceAsync_IsInclusiveAtTheWindowFloor()
|
|
{
|
|
// The floor is inclusive, matching the in-memory limiter's prune predicate
|
|
// (entries at exactly the cutoff still count). Pinned because an off-by-one here
|
|
// would silently hand every client one extra attempt per window.
|
|
await using var ctx = NewContext();
|
|
var repo = new BundleUnlockAttemptRepository(ctx);
|
|
|
|
await repo.RecordAttemptAsync("10.0.0.1", Base.AddHours(-1));
|
|
|
|
Assert.Equal(1, await repo.CountAttemptsSinceAsync("10.0.0.1", Base.AddHours(-1)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CountAttemptsSinceAsync_UnknownBucket_ReturnsZero()
|
|
{
|
|
await using var ctx = NewContext();
|
|
var repo = new BundleUnlockAttemptRepository(ctx);
|
|
|
|
Assert.Equal(0, await repo.CountAttemptsSinceAsync("<unattributed>", Base.AddHours(-1)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PruneAttemptsOlderThanAsync_DeletesOnlyAgedRows_AndIsBounded()
|
|
{
|
|
await using var ctx = NewContext();
|
|
var repo = new BundleUnlockAttemptRepository(ctx);
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
{
|
|
await repo.RecordAttemptAsync("10.0.0.1", Base.AddHours(-5).AddMinutes(i));
|
|
}
|
|
|
|
await repo.RecordAttemptAsync("10.0.0.1", Base.AddMinutes(-1));
|
|
|
|
// Bounded: only 3 of the 5 eligible rows go in this pass.
|
|
var deleted = await repo.PruneAttemptsOlderThanAsync(Base.AddHours(-2), maxRows: 3);
|
|
Assert.Equal(3, deleted);
|
|
|
|
// The recent row is untouched, and a second pass clears the remainder.
|
|
Assert.Equal(1, await repo.CountAttemptsSinceAsync("10.0.0.1", Base.AddHours(-1)));
|
|
Assert.Equal(2, await repo.PruneAttemptsOlderThanAsync(Base.AddHours(-2), maxRows: 3));
|
|
Assert.Equal(0, await repo.PruneAttemptsOlderThanAsync(Base.AddHours(-2), maxRows: 3));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PruneAttemptsOlderThanAsync_RejectsANonPositiveBatchSize()
|
|
{
|
|
await using var ctx = NewContext();
|
|
var repo = new BundleUnlockAttemptRepository(ctx);
|
|
|
|
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
|
|
() => repo.PruneAttemptsOlderThanAsync(Base, maxRows: 0));
|
|
}
|
|
}
|