Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/TransportGateTests.cs
T
Joseph Doherty 5ab26a4233 fix(transport): enforce the documented per-IP bundle-unlock throttle (row 44)
BundleUnlockRateLimiter was registered (ServiceCollectionExtensions), injected
into BundleImporter, unit-tested, documented on
TransportOptions.MaxUnlockAttemptsPerIpPerHour = 10 and pinned in
appsettings.Central.json — but _unlockRateLimiter was never invoked. A
brute-force passphrase attacker against a stolen bundle was bounded only by the
per-bundle 3-strike lockout, which a re-export side-steps (new ContentHash).

LoadAsync now registers every unlock attempt against the limiter. Ordering is
load-bearing: AFTER the per-bundle lockout check (hammering a locked bundle
spends no hourly budget) and BEFORE the decrypt (so a denial burns no per-bundle
strike — otherwise the throttle would be a way to lock a bundle someone is
legitimately importing). Over budget throws the new
BundleUnlockThrottledException, fail-closed with a clear message on both
surfaces; it lives in Commons because the management surface (Commons-only
references) must catch it to return a curated error rather than MapFault's
generic internal-error text.

Client key plumbing:
- Central UI: new scoped CircuitClientAddressProvider snapshots
  Connection.RemoteIpAddress at circuit establishment — the same constraint and
  the same pattern as CookieAuthenticationStateProvider, since HttpContext is
  null by the time a circuit submits a passphrase.
- CLI/management: additive ManagementEnvelope.ClientIp, stamped by
  ManagementEndpoints and threaded through DispatchCommand to the preview/import
  bundle handlers.
- No attributable address => a single SHARED "<unattributed>" bucket, not a
  bypass: unknown clients get a stricter budget, never none. Per-client
  isolation is only real behind a ForwardedHeaders-trusted proxy, the same
  caveat the login throttle carries (Component-Security.md).

Docs: Component-Transport.md + docs/components/Transport.md record the two-level
ordering, the client-key sourcing, the shared-bucket fallback and a
troubleshooting entry for the new error.

Tests: 6 importer tests (cap enforced; denial != passphrase failure, no strike
burned; per-client isolation; shared unattributed bucket; unencrypted bundles
consume no budget; a locked bundle spends no hourly budget) — 4 of the 6 fail
with the limiter call disabled, i.e. against the dead-code state. Plus
ManagementActor tests pinning the ClientIp forwarding and the curated
throttle error.
2026-08-15 06:01:14 -04:00

94 lines
4.0 KiB
C#

using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// Verifies the static <c>TransportGate</c> single-flight guard (arch-review S4):
/// transport bundle commands (export/preview/import) queue rather than run
/// concurrently, because each import holds ~4 concurrent whole-artifact copies of
/// the payload and two concurrent large imports on one central node are a
/// realistic OOM path. The gate is exercised through the real import handler so
/// the test tracks the actual concurrency shape of <see cref="IBundleImporter"/>.
/// </summary>
public class TransportGateTests : TestKit, IDisposable
{
private readonly IBundleImporter _importer;
private readonly ServiceCollection _services;
public TransportGateTests()
{
_importer = Substitute.For<IBundleImporter>();
_services = new ServiceCollection();
_services.AddScoped(_ => _importer);
}
private IActorRef CreateActor()
{
var sp = _services.BuildServiceProvider();
return Sys.ActorOf(Props.Create(() => new ManagementActor(
sp, NullLogger<ManagementActor>.Instance)));
}
private static ManagementEnvelope Envelope(object command) =>
new(new AuthenticatedUser("admin", "Admin", new[] { "Administrator" }, Array.Empty<string>()),
command, Guid.NewGuid().ToString("N"));
void IDisposable.Dispose() => Shutdown();
[Fact]
public async Task ImportBundle_ConcurrentCommands_AreSingleFlighted()
{
// LoadAsync blocks on this TCS so the first import stays inside the gate.
var release = new TaskCompletionSource<BundleSession>(
TaskCreationOptions.RunContinuationsAsynchronously);
var loadStarted = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
var loadCalls = 0;
var session = new BundleSession { SessionId = Guid.NewGuid() };
_importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
.Returns(_ =>
{
Interlocked.Increment(ref loadCalls);
loadStarted.TrySetResult();
return release.Task;
});
_importer.PreviewAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(new ImportPreview(session.SessionId, Array.Empty<ImportPreviewItem>()));
_importer.ApplyAsync(
Arg.Any<Guid>(), Arg.Any<IReadOnlyList<ImportResolution>>(),
Arg.Any<string>(), Arg.Any<CancellationToken>(), Arg.Any<BundleNameMap?>())
.Returns(new ImportResult(
Guid.NewGuid(), 0, 0, 0, 0, Array.Empty<int>(), "corr"));
var actor = CreateActor();
// A trivially-valid base64 payload; the substitute importer never parses it.
var bundle = Convert.ToBase64String(new byte[] { 1, 2, 3, 4 });
actor.Tell(Envelope(new ImportBundleCommand(bundle, null, "skip")));
actor.Tell(Envelope(new ImportBundleCommand(bundle, null, "skip")));
// The first import must reach LoadAsync...
await loadStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
// ...and the second must be parked at the gate: give it ample time to
// (wrongly) proceed, then assert it did not.
await Task.Delay(750);
Assert.Equal(1, Volatile.Read(ref loadCalls));
// Release the first; the second should now proceed and both should answer.
release.SetResult(session);
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
AwaitAssert(() => Assert.Equal(2, Volatile.Read(ref loadCalls)),
TimeSpan.FromSeconds(5));
}
}