2807b5a3ea
Row 65 — trigger FIRED, fixed. BundleUnlockAttempt rows were pruned only by
nodes actively serving unlocks, so a cluster that stopped importing entirely
left its last rows in place forever. The row's own trigger was "Transport gains
a central scheduler a prune tick could ride" — it has one now:
BundleSessionEvictionService, the central-only 1-minute session sweep added by
the transport-session fixes. It now also ticks the new
IBundleUnlockAttemptWindow.PruneAsync. Both drivers share the ONE 15-minute CAS
cadence claim, so ticking on a 1-minute sweep costs at most one bounded DELETE
per quarter hour and the two can never prune concurrently. PruneAsync is
contractually non-throwing (unlike TryRegisterAttemptAsync, which throws and
lets the limiter own the fail-open decision) — a background sweep has no caller
to report to and must survive a database blip. Both Transport docs updated;
three tests in FleetWideUnlockThrottleTests, incl. the retention sweep running
with no unlock traffic anywhere in the test, which is the whole point.
Row 58 — closed with a pin, and the OLD pin was found blind. The existing
Deployments_Dispose_UnsubscribesFromNotifier is behavioural: it is satisfied by
the _disposed guard alone, so deleting the `-=` from Dispose leaves it green
while the singleton notifier keeps a delegate pinning the circuit-scoped
component. Verified by mutation — with the `-=` removed the old test still
passed and only the two new pins failed. Added:
* a structural retention pin asserting the notifier's invocation list actually
shrinks to 0 on Dispose;
* a source scan pinning the row's stated TRIGGER ("a second page subscribes,
or any subscriber lands without a matching Dispose unsubscribe") — every
CentralUI file that subscribes must also unsubscribe and be IDisposable,
with a non-empty guard so the scan cannot pass vacuously.
Row 60 — closed with a pin, and the register's premise corrected.
GlobalArtifactSnapshot does NOT hold EF-materialised entities: it is projected
into pure Commons DTOs before the fan-out. The EF state that genuinely crosses
into the parallel send is the tracked Site entities the lambda closes over, and
that is safe only while lazy loading is off. Pinned in ConfigurationDatabase
.Tests two ways — no proxies extension on the built DbContextOptions, and no
EntityFrameworkCore.Proxies package reference anywhere in the repo (the
structurally stronger half: UseLazyLoadingProxies() is not a compilable call
without it). The real invariant is now spelled out at the fan-out itself.
Row 59 — no product change; added the missing tripwire remark stating the
constraint the safety argument rests on (serial by design, one scoped DbContext,
do not Task.WhenAll; if it gets slow the fix is a scope PER SITE, not a shared
scope under fan-out). Line numbers in the register have drifted to :528-545.
Rows 36, 37, 64: verification only, no change — see the sweep report.
328 lines
14 KiB
C#
328 lines
14 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using NSubstitute;
|
|
using NSubstitute.ExceptionExtensions;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
|
|
|
/// <summary>
|
|
/// Register row 52: the bundle-unlock throttle's counters used to be per-node AND
|
|
/// per-process, so the effective fleet cap was N x <c>MaxUnlockAttemptsPerIpPerHour</c>
|
|
/// per client and a node restart silently cleared the window. These tests stand up TWO
|
|
/// independent limiter instances — the stand-in for central-a and central-b — over ONE
|
|
/// shared relational store and prove they enforce a single combined window, plus the
|
|
/// fail-open behaviour a store outage must produce.
|
|
/// </summary>
|
|
public sealed class FleetWideUnlockThrottleTests : IDisposable
|
|
{
|
|
private readonly SqliteConnection _connection;
|
|
private readonly ServiceProvider _provider;
|
|
|
|
public FleetWideUnlockThrottleTests()
|
|
{
|
|
// One open in-memory SQLite connection for the fixture's lifetime — the database
|
|
// lives exactly as long as the connection, and both "nodes" resolve scoped
|
|
// contexts over it, which is what makes the store genuinely shared.
|
|
_connection = new SqliteConnection("DataSource=:memory:");
|
|
_connection.Open();
|
|
|
|
var services = new ServiceCollection();
|
|
services.AddDbContext<ScadaBridgeDbContext>(opts => opts.UseSqlite(_connection));
|
|
services.AddScoped<IBundleUnlockAttemptRepository, BundleUnlockAttemptRepository>();
|
|
_provider = services.BuildServiceProvider();
|
|
|
|
using var scope = _provider.CreateScope();
|
|
scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>().Database.EnsureCreated();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_provider.Dispose();
|
|
_connection.Dispose();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a limiter over the shared store, standing in for one central node's
|
|
/// singleton. Each call returns a fully independent instance (its own process-local
|
|
/// fallback buckets, its own prune claim) — exactly the isolation the two real nodes
|
|
/// have.
|
|
/// </summary>
|
|
/// <param name="clock">Clock the node observes.</param>
|
|
/// <returns>A limiter whose window is the shared store.</returns>
|
|
private BundleUnlockRateLimiter NewNode(TimeProvider clock) => new(
|
|
clock,
|
|
BundleUnlockRateLimiter.DefaultWindow,
|
|
new SqlBundleUnlockAttemptWindow(
|
|
_provider.GetRequiredService<IServiceScopeFactory>(), clock),
|
|
logger: null);
|
|
|
|
[Fact]
|
|
public async Task TwoNodesSharingOneStore_EnforceASingleCombinedWindow()
|
|
{
|
|
// The headline row-52 contract: attempts spread across both central nodes count
|
|
// against ONE budget. Before this, five attempts per node were both allowed.
|
|
var clock = new TestClock(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero));
|
|
var nodeA = NewNode(clock);
|
|
var nodeB = NewNode(clock);
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
{
|
|
Assert.True(await nodeA.TryRegisterAttemptAsync("10.0.0.1", 10));
|
|
Assert.True(await nodeB.TryRegisterAttemptAsync("10.0.0.1", 10));
|
|
}
|
|
|
|
// The combined budget of 10 is now spent — on EITHER node.
|
|
Assert.False(await nodeA.TryRegisterAttemptAsync("10.0.0.1", 10));
|
|
Assert.False(await nodeB.TryRegisterAttemptAsync("10.0.0.1", 10));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeniedAttempts_BurnNoBudget()
|
|
{
|
|
// Register row 44's contract, preserved through the move to the shared store: a
|
|
// rejected attempt is not recorded, so hammering cannot roll the window forward
|
|
// and extend the client's own lockout.
|
|
var clock = new TestClock(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero));
|
|
var nodeA = NewNode(clock);
|
|
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
Assert.True(await nodeA.TryRegisterAttemptAsync("10.0.0.1", 3));
|
|
}
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
{
|
|
Assert.False(await nodeA.TryRegisterAttemptAsync("10.0.0.1", 3));
|
|
}
|
|
|
|
// Exactly the 3 permitted attempts were persisted — the 5 denials added nothing.
|
|
using var scope = _provider.CreateScope();
|
|
var repo = scope.ServiceProvider.GetRequiredService<IBundleUnlockAttemptRepository>();
|
|
var recorded = await repo.CountAttemptsSinceAsync(
|
|
"10.0.0.1", clock.GetUtcNow().UtcDateTime.AddHours(-1));
|
|
Assert.Equal(3, recorded);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheWindowIsRestartDurable()
|
|
{
|
|
// A node restart used to clear the window outright. A brand-new limiter instance
|
|
// (the restarted process) must still see the budget the old one spent.
|
|
var clock = new TestClock(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero));
|
|
var before = NewNode(clock);
|
|
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
Assert.True(await before.TryRegisterAttemptAsync("10.0.0.1", 3));
|
|
}
|
|
|
|
var afterRestart = NewNode(clock);
|
|
Assert.False(await afterRestart.TryRegisterAttemptAsync("10.0.0.1", 3));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheWindowSlides_SoAnAgedOutBudgetIsReleased()
|
|
{
|
|
var clock = new TestClock(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero));
|
|
var node = NewNode(clock);
|
|
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
Assert.True(await node.TryRegisterAttemptAsync("10.0.0.1", 3));
|
|
}
|
|
|
|
Assert.False(await node.TryRegisterAttemptAsync("10.0.0.1", 3));
|
|
|
|
clock.Advance(TimeSpan.FromMinutes(61));
|
|
Assert.True(await node.TryRegisterAttemptAsync("10.0.0.1", 3));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BucketsAreIsolatedPerClient()
|
|
{
|
|
var clock = new TestClock(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero));
|
|
var node = NewNode(clock);
|
|
|
|
Assert.True(await node.TryRegisterAttemptAsync("10.0.0.1", 1));
|
|
Assert.False(await node.TryRegisterAttemptAsync("10.0.0.1", 1));
|
|
|
|
// A different client — and the shared unattributed bucket — are unaffected.
|
|
Assert.True(await node.TryRegisterAttemptAsync("10.0.0.2", 1));
|
|
Assert.True(await node.TryRegisterAttemptAsync("<unattributed>", 1));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StoreFailure_FailsOpen_AndDoesNotBlockTheUnlock()
|
|
{
|
|
// Availability of legitimate imports outranks a best-effort rate cap: a database
|
|
// error must permit the attempt, not take the import surface down with the DB.
|
|
var window = Substitute.For<IBundleUnlockAttemptWindow>();
|
|
window
|
|
.TryRegisterAttemptAsync(
|
|
Arg.Any<string>(), Arg.Any<int>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
|
|
.ThrowsAsync(new InvalidOperationException("database unavailable"));
|
|
|
|
var limiter = new BundleUnlockRateLimiter(
|
|
TimeProvider.System, BundleUnlockRateLimiter.DefaultWindow, window, logger: null);
|
|
|
|
for (var i = 0; i < 20; i++)
|
|
{
|
|
Assert.True(await limiter.TryRegisterAttemptAsync("10.0.0.1", 1));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CallerCancellation_IsNotSwallowedByTheFailOpenPath()
|
|
{
|
|
// Fail-open must not hide the caller's own cancellation — that would turn an
|
|
// aborted request into a silently permitted attempt.
|
|
var window = Substitute.For<IBundleUnlockAttemptWindow>();
|
|
window
|
|
.TryRegisterAttemptAsync(
|
|
Arg.Any<string>(), Arg.Any<int>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
|
|
.ThrowsAsync(new OperationCanceledException());
|
|
|
|
var limiter = new BundleUnlockRateLimiter(
|
|
TimeProvider.System, BundleUnlockRateLimiter.DefaultWindow, window, logger: null);
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
await cts.CancelAsync();
|
|
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
|
() => limiter.TryRegisterAttemptAsync("10.0.0.1", 10, cts.Token));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WithoutASharedWindow_TheLimiterStillEnforcesAProcessLocalCap()
|
|
{
|
|
// Hosts that register Transport without a shared window keep the pre-row-52
|
|
// behaviour rather than losing the cap entirely.
|
|
var clock = new TestClock(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero));
|
|
var limiter = new BundleUnlockRateLimiter(clock, BundleUnlockRateLimiter.DefaultWindow);
|
|
|
|
Assert.False(limiter.IsFleetWide);
|
|
Assert.True(await limiter.TryRegisterAttemptAsync("10.0.0.1", 1));
|
|
Assert.False(await limiter.TryRegisterAttemptAsync("10.0.0.1", 1));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AgedRowsArePrunedOpportunistically_OnceThePruneIntervalHasElapsed()
|
|
{
|
|
// No background service: the prune rides the unlock path, claimed at most once per
|
|
// PruneMinInterval. Rows are retained for PruneRetentionWindows x window so a prune
|
|
// racing a count can never delete a row the count still needs.
|
|
var clock = new TestClock(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero));
|
|
var node = NewNode(clock);
|
|
|
|
Assert.True(await node.TryRegisterAttemptAsync("10.0.0.1", 10));
|
|
|
|
// Move past both the retention horizon (2 hours) and the prune cadence.
|
|
clock.Advance(TimeSpan.FromHours(3));
|
|
Assert.True(await node.TryRegisterAttemptAsync("10.0.0.2", 10));
|
|
|
|
using var scope = _provider.CreateScope();
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
var remaining = await ctx.BundleUnlockAttempts.AsNoTracking().ToListAsync();
|
|
|
|
// The aged row is gone; the one just written by the pruning attempt survives.
|
|
Assert.Equal("10.0.0.2", Assert.Single(remaining).ClientKey);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RecentRowsSurviveAPrunePass()
|
|
{
|
|
// Negative control for the prune horizon: an attempt still inside the retention
|
|
// window must not be deleted, or the throttle would forget live budget.
|
|
var clock = new TestClock(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero));
|
|
var node = NewNode(clock);
|
|
|
|
Assert.True(await node.TryRegisterAttemptAsync("10.0.0.1", 10));
|
|
|
|
// Past the prune cadence (15 min) but inside the 2-hour retention horizon.
|
|
clock.Advance(TimeSpan.FromMinutes(30));
|
|
Assert.True(await node.TryRegisterAttemptAsync("10.0.0.2", 10));
|
|
|
|
using var scope = _provider.CreateScope();
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
Assert.Equal(2, await ctx.BundleUnlockAttempts.AsNoTracking().CountAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ScheduledPrune_ClearsAgedRows_WithNoUnlockTrafficAtAll()
|
|
{
|
|
// Register row 65: the prune used to ride the unlock path ONLY, so a cluster that
|
|
// stopped importing entirely left its last rows in place indefinitely. The
|
|
// periodic sweep now drives PruneAsync directly — no second unlock attempt is made
|
|
// anywhere in this test, which is the whole point.
|
|
var clock = new TestClock(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero));
|
|
var window = new SqlBundleUnlockAttemptWindow(
|
|
_provider.GetRequiredService<IServiceScopeFactory>(), clock);
|
|
|
|
Assert.True(await window.TryRegisterAttemptAsync(
|
|
"10.0.0.1", 10, BundleUnlockRateLimiter.DefaultWindow));
|
|
|
|
// Past both the 2-hour retention horizon and the 15-minute prune cadence.
|
|
clock.Advance(TimeSpan.FromHours(3));
|
|
await window.PruneAsync(BundleUnlockRateLimiter.DefaultWindow);
|
|
|
|
using var scope = _provider.CreateScope();
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
Assert.Empty(await ctx.BundleUnlockAttempts.AsNoTracking().ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ScheduledPrune_RespectsTheRetentionHorizonAndTheCadenceClaim()
|
|
{
|
|
// Two negative controls in one: a row inside the retention horizon survives, and
|
|
// the shared cadence claim means a burst of ticks cannot prune more often than
|
|
// PruneMinInterval (so ticking on the 1-minute session sweep is cheap).
|
|
var clock = new TestClock(new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero));
|
|
var window = new SqlBundleUnlockAttemptWindow(
|
|
_provider.GetRequiredService<IServiceScopeFactory>(), clock);
|
|
|
|
Assert.True(await window.TryRegisterAttemptAsync(
|
|
"10.0.0.1", 10, BundleUnlockRateLimiter.DefaultWindow));
|
|
|
|
// Recent row, past the cadence: pruning runs but must delete nothing.
|
|
clock.Advance(TimeSpan.FromMinutes(30));
|
|
for (var i = 0; i < 5; i++)
|
|
{
|
|
await window.PruneAsync(BundleUnlockRateLimiter.DefaultWindow);
|
|
}
|
|
|
|
using var scope = _provider.CreateScope();
|
|
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
|
Assert.Equal(1, await ctx.BundleUnlockAttempts.AsNoTracking().CountAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ScheduledPrune_DoesNotThrow_WhenTheStoreIsUnavailable()
|
|
{
|
|
// The sweep that drives this has no caller to report to, so PruneAsync's contract
|
|
// is "never throw" — a database blip must not kill the background service loop.
|
|
var services = new ServiceCollection();
|
|
services.AddScoped<IBundleUnlockAttemptRepository>(
|
|
_ => throw new InvalidOperationException("database unavailable"));
|
|
using var broken = services.BuildServiceProvider();
|
|
|
|
var window = new SqlBundleUnlockAttemptWindow(
|
|
broken.GetRequiredService<IServiceScopeFactory>(), TimeProvider.System);
|
|
|
|
await window.PruneAsync(BundleUnlockRateLimiter.DefaultWindow);
|
|
}
|
|
|
|
/// <summary>Minimal mutable-clock <see cref="TimeProvider"/> for deterministic windows.</summary>
|
|
private sealed class TestClock : TimeProvider
|
|
{
|
|
private DateTimeOffset _now;
|
|
public TestClock(DateTimeOffset start) { _now = start; }
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
public void Advance(TimeSpan by) => _now = _now.Add(by);
|
|
}
|
|
}
|