Merge branch 'transport-session-fixes' — abandoned-session release + per-IP unlock throttle enforced (register rows 43, 44)
This commit is contained in:
+198
-2
@@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
@@ -17,6 +18,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||
using TransportImportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.TransportImport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages.Design;
|
||||
@@ -44,6 +46,9 @@ public class TransportImportPageTests : BunitContext
|
||||
// the Map tests) can render the wizard without a missing-service failure.
|
||||
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||
|
||||
/// <summary>Client address the wizard reports for the per-IP unlock throttle (register row 44).</summary>
|
||||
private const string TestClientIp = "203.0.113.7";
|
||||
|
||||
public TransportImportPageTests()
|
||||
{
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
@@ -51,6 +56,11 @@ public class TransportImportPageTests : BunitContext
|
||||
Services.AddSingleton(_importer);
|
||||
Services.AddSingleton(_auditService);
|
||||
|
||||
// Register row 44: the wizard passes the circuit's client IP to LoadAsync so
|
||||
// the per-IP unlock throttle can attribute the attempt. bUnit has no HTTP
|
||||
// context, so use the provider's explicit-address test seam.
|
||||
Services.AddSingleton(new CircuitClientAddressProvider(TestClientIp));
|
||||
|
||||
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Array.Empty<Site>());
|
||||
_siteRepo.GetDataConnectionsBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
@@ -131,7 +141,7 @@ public class TransportImportPageTests : BunitContext
|
||||
public async Task Decryption_failure_increments_attempt_counter()
|
||||
{
|
||||
// Set up the importer to throw CryptographicException for wrong passphrases.
|
||||
_importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
_importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Throws(new CryptographicException("authentication tag mismatch"));
|
||||
|
||||
var cut = Render<TransportImportPage>();
|
||||
@@ -161,7 +171,7 @@ public class TransportImportPageTests : BunitContext
|
||||
[Fact]
|
||||
public async Task Three_failed_unlocks_force_reupload()
|
||||
{
|
||||
_importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
_importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Throws(new CryptographicException("authentication tag mismatch"));
|
||||
|
||||
var cut = Render<TransportImportPage>();
|
||||
@@ -688,6 +698,192 @@ public class TransportImportPageTests : BunitContext
|
||||
Assert.Empty(connChoices);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Register row 43: an abandoned wizard must not strand its server-side
|
||||
// BundleSession. These tests swap the NSubstitute importer for one backed
|
||||
// by a REAL BundleSessionStore so the assertion is "the slot is actually
|
||||
// released", not "a mock was called".
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Minimal <see cref="IBundleImporter"/> over a real <see cref="BundleSessionStore"/>.
|
||||
/// <see cref="Abandon"/> and <see cref="ApplyAsync"/> mirror the production
|
||||
/// importer's zero-then-remove release; every other member is unused by these
|
||||
/// tests. Sessions are seeded directly with <see cref="OpenSession"/> because
|
||||
/// driving <c>InputFile</c> from bUnit is impractical (see the class remarks).
|
||||
/// </summary>
|
||||
private sealed class StoreBackedImporter : IBundleImporter
|
||||
{
|
||||
public BundleSessionStore Store { get; } = new(
|
||||
Microsoft.Extensions.Options.Options.Create(new TransportOptions()),
|
||||
TimeProvider.System);
|
||||
|
||||
public int AbandonCalls { get; private set; }
|
||||
|
||||
public BundleSession OpenSession(string sourceEnv = "prod-cluster")
|
||||
{
|
||||
var seed = BuildEncryptedSession(sourceEnv);
|
||||
var session = new BundleSession
|
||||
{
|
||||
SessionId = seed.SessionId,
|
||||
Manifest = seed.Manifest,
|
||||
// Non-zero bytes so the zeroing assertion is meaningful.
|
||||
DecryptedContent = new byte[] { 0x11, 0x22, 0x33, 0x44 },
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
|
||||
};
|
||||
return Store.Open(session);
|
||||
}
|
||||
|
||||
public Task<BundleSession> LoadAsync(
|
||||
Stream bundleStream, string? passphrase, CancellationToken ct = default, string? clientKey = null)
|
||||
=> throw new NotSupportedException("These tests seed sessions via OpenSession().");
|
||||
|
||||
public Task<ImportPreview> PreviewAsync(Guid sessionId, CancellationToken ct = default)
|
||||
=> Task.FromResult(new ImportPreview(sessionId, new List<ImportPreviewItem>()));
|
||||
|
||||
public Task<ImportResult> ApplyAsync(
|
||||
Guid sessionId,
|
||||
IReadOnlyList<ImportResolution> resolutions,
|
||||
string user,
|
||||
CancellationToken ct = default,
|
||||
BundleNameMap? nameMap = null)
|
||||
{
|
||||
Release(sessionId);
|
||||
return Task.FromResult(new ImportResult(
|
||||
BundleImportId: Guid.NewGuid(),
|
||||
Added: 1,
|
||||
Overwritten: 0,
|
||||
Skipped: 0,
|
||||
Renamed: 0,
|
||||
StaleInstanceIds: Array.Empty<int>(),
|
||||
AuditEventCorrelation: Guid.NewGuid().ToString()));
|
||||
}
|
||||
|
||||
public void Abandon(Guid sessionId)
|
||||
{
|
||||
AbandonCalls++;
|
||||
Release(sessionId);
|
||||
}
|
||||
|
||||
private void Release(Guid sessionId)
|
||||
{
|
||||
var session = Store.Get(sessionId);
|
||||
if (session is { DecryptedContent.Length: > 0 })
|
||||
{
|
||||
Array.Clear(session.DecryptedContent, 0, session.DecryptedContent.Length);
|
||||
}
|
||||
Store.Remove(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disposing_the_wizard_releases_its_bundle_session()
|
||||
{
|
||||
// Circuit teardown / navigation away. Before the fix, Dispose deleted only
|
||||
// the staged temp file, so the session (and its decrypted content) held one
|
||||
// of MaxConcurrentImportSessions for the full 30-minute TTL.
|
||||
var importer = new StoreBackedImporter();
|
||||
Services.AddSingleton<IBundleImporter>(importer);
|
||||
|
||||
var cut = Render<TransportImportPage>();
|
||||
var session = importer.OpenSession();
|
||||
var content = session.DecryptedContent;
|
||||
await cut.InvokeAsync(() => SetField(cut.Instance, "_session", session));
|
||||
|
||||
cut.Instance.Dispose();
|
||||
|
||||
Assert.Null(importer.Store.Get(session.SessionId));
|
||||
Assert.All(content, b => Assert.Equal(0, b));
|
||||
Assert.Null(GetField<object?>(cut.Instance, "_session"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackToUpload_releases_the_bundle_session()
|
||||
{
|
||||
// The wizard's only cancel affordance ("Back" on steps 2/3/5) routes through
|
||||
// BackToUpload → ResetSessionState, which used to null the local field only.
|
||||
var importer = new StoreBackedImporter();
|
||||
Services.AddSingleton<IBundleImporter>(importer);
|
||||
|
||||
var cut = Render<TransportImportPage>();
|
||||
var session = importer.OpenSession();
|
||||
var content = session.DecryptedContent;
|
||||
await cut.InvokeAsync(() =>
|
||||
{
|
||||
SetField(cut.Instance, "_session", session);
|
||||
SetField(cut.Instance, "_step", TransportImportPage.ImportWizardStep.Diff);
|
||||
});
|
||||
|
||||
await cut.InvokeAsync(() =>
|
||||
{
|
||||
var method = cut.Instance.GetType().GetMethod(
|
||||
"BackToUpload",
|
||||
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
|
||||
method.Invoke(cut.Instance, Array.Empty<object?>());
|
||||
});
|
||||
|
||||
Assert.Null(importer.Store.Get(session.SessionId));
|
||||
Assert.All(content, b => Assert.Equal(0, b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Apply_then_dispose_releases_the_session_once_without_throwing()
|
||||
{
|
||||
// ApplyAsync already removes the session; the wizard's own release must be
|
||||
// idempotent so circuit teardown after a completed import is a silent no-op.
|
||||
var importer = new StoreBackedImporter();
|
||||
Services.AddSingleton<IBundleImporter>(importer);
|
||||
|
||||
var cut = Render<TransportImportPage>();
|
||||
var session = importer.OpenSession(sourceEnv: "prod-cluster");
|
||||
await cut.InvokeAsync(() =>
|
||||
{
|
||||
SetField(cut.Instance, "_session", session);
|
||||
SetField(cut.Instance, "_resolutions",
|
||||
new Dictionary<(string EntityType, string Name), ImportResolution>
|
||||
{
|
||||
[("Template", "Pump")] = new("Template", "Pump", ResolutionAction.Add, null),
|
||||
});
|
||||
SetField(cut.Instance, "_step", TransportImportPage.ImportWizardStep.Confirm);
|
||||
SetField(cut.Instance, "_confirmEnvironmentText", "prod-cluster");
|
||||
});
|
||||
|
||||
await cut.InvokeAsync(() => InvokeAsyncMethod(cut.Instance, "ApplyAsync"));
|
||||
Assert.Equal(
|
||||
TransportImportPage.ImportWizardStep.Result,
|
||||
GetField<TransportImportPage.ImportWizardStep>(cut.Instance, "_step"));
|
||||
Assert.Null(importer.Store.Get(session.SessionId));
|
||||
|
||||
// Must not throw, and must not resurrect or double-release anything.
|
||||
cut.Instance.Dispose();
|
||||
cut.Instance.Dispose();
|
||||
|
||||
Assert.Null(importer.Store.Get(session.SessionId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disposing_one_wizard_leaves_a_concurrent_circuits_session_open()
|
||||
{
|
||||
// Sessions are keyed by a GUID held only by the circuit that opened them,
|
||||
// so one operator abandoning their import must not evict another's.
|
||||
var importer = new StoreBackedImporter();
|
||||
Services.AddSingleton<IBundleImporter>(importer);
|
||||
|
||||
var first = Render<TransportImportPage>();
|
||||
var second = Render<TransportImportPage>();
|
||||
var mine = importer.OpenSession();
|
||||
var theirs = importer.OpenSession(sourceEnv: "other-cluster");
|
||||
await first.InvokeAsync(() => SetField(first.Instance, "_session", mine));
|
||||
await second.InvokeAsync(() => SetField(second.Instance, "_session", theirs));
|
||||
|
||||
first.Instance.Dispose();
|
||||
|
||||
Assert.Null(importer.Store.Get(mine.SessionId));
|
||||
var survivor = importer.Store.Get(theirs.SessionId);
|
||||
Assert.NotNull(survivor);
|
||||
Assert.Contains(survivor!.DecryptedContent, b => b != 0);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Reflection helpers — the wizard's per-instance state is private (the
|
||||
// razor partial pattern). We poke at it via reflection rather than
|
||||
|
||||
@@ -234,6 +234,10 @@ public sealed class QueryStringDrillInTests
|
||||
Services.AddSingleton(Substitute.For<IAuditService>());
|
||||
// M8 E2: TransportImport's Map step injects ISiteRepository.
|
||||
Services.AddSingleton(Substitute.For<ISiteRepository>());
|
||||
// Register row 44: the wizard reads the circuit's client IP for the
|
||||
// per-IP unlock throttle. No HTTP context under bUnit — use the seam.
|
||||
Services.AddSingleton(new ZB.MOM.WW.ScadaBridge.CentralUI.Auth.CircuitClientAddressProvider(
|
||||
clientAddress: null));
|
||||
Services.AddSingleton<IOptions<TransportOptions>>(
|
||||
Microsoft.Extensions.Options.Options.Create(new TransportOptions
|
||||
{
|
||||
|
||||
@@ -2734,7 +2734,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
var (_, importer) = AddBundleSubstitutes();
|
||||
|
||||
var sessionId = Guid.NewGuid();
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns(new Commons.Types.Transport.BundleSession
|
||||
{
|
||||
SessionId = sessionId,
|
||||
@@ -2787,7 +2787,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
var (_, importer) = AddBundleSubstitutes();
|
||||
|
||||
var sessionId = Guid.NewGuid();
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns(new Commons.Types.Transport.BundleSession
|
||||
{
|
||||
SessionId = sessionId,
|
||||
@@ -2865,7 +2865,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
IReadOnlyList<Commons.Types.Transport.RequiredConnectionMapping> connectionMappings)
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns(new Commons.Types.Transport.BundleSession
|
||||
{
|
||||
SessionId = sessionId,
|
||||
@@ -3089,7 +3089,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
var (_, importer) = AddBundleSubstitutes();
|
||||
|
||||
var sessionId = Guid.NewGuid();
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns(new Commons.Types.Transport.BundleSession
|
||||
{
|
||||
SessionId = sessionId,
|
||||
@@ -3126,6 +3126,99 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
Assert.Contains("OpcA", success.JsonData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreviewBundleCommand_ReleasesTheSession_AndForwardsTheCallersIp()
|
||||
{
|
||||
// Register row 43: a preview never applies, so nothing else releases the
|
||||
// session — it used to hold one of MaxConcurrentImportSessions for the full
|
||||
// TTL. Register row 44: the envelope's client IP must reach LoadAsync so the
|
||||
// per-IP-per-hour unlock throttle can attribute the attempt.
|
||||
var (_, importer) = AddBundleSubstitutes();
|
||||
|
||||
var sessionId = Guid.NewGuid();
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns(new Commons.Types.Transport.BundleSession
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Manifest = null!,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
|
||||
});
|
||||
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
|
||||
.Returns(new Commons.Types.Transport.ImportPreview(
|
||||
sessionId,
|
||||
Array.Empty<Commons.Types.Transport.ImportPreviewItem>()));
|
||||
|
||||
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new PreviewBundleCommand(payload, null), "Administrator")
|
||||
with { ClientIp = "198.51.100.9" };
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
importer.Received(1).LoadAsync(
|
||||
Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), "198.51.100.9");
|
||||
importer.Received(1).Abandon(sessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportBundleCommand_WithBlockerRow_StillReleasesTheSession()
|
||||
{
|
||||
// Register row 43: ApplyAsync releases the session, but this import aborts
|
||||
// BEFORE apply — the handler must release it anyway.
|
||||
var (_, importer) = AddBundleSubstitutes();
|
||||
|
||||
var sessionId = Guid.NewGuid();
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns(new Commons.Types.Transport.BundleSession
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Manifest = null!,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
|
||||
});
|
||||
importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
|
||||
.Returns(new Commons.Types.Transport.ImportPreview(
|
||||
sessionId,
|
||||
new[]
|
||||
{
|
||||
new Commons.Types.Transport.ImportPreviewItem(
|
||||
EntityType: "Template",
|
||||
Name: "BlockedTemplate",
|
||||
ExistingVersion: null,
|
||||
IncomingVersion: 1,
|
||||
Kind: Commons.Types.Transport.ConflictKind.Blocker,
|
||||
FieldDiffJson: null,
|
||||
BlockerReason: "FK to missing site"),
|
||||
}));
|
||||
|
||||
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new ImportBundleCommand(payload, null, "skip"), "Administrator"));
|
||||
|
||||
ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
importer.Received(1).Abandon(sessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportBundleCommand_ThrottledUnlock_SurfacesTheCuratedReason()
|
||||
{
|
||||
// Register row 44: a per-IP throttle denial must fail closed with a clear
|
||||
// message, not MapFault's generic "an internal error occurred".
|
||||
var (_, importer) = AddBundleSubstitutes();
|
||||
|
||||
importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns<Commons.Types.Transport.BundleSession>(_ =>
|
||||
throw new Commons.Types.Transport.BundleUnlockThrottledException("198.51.100.9", 10));
|
||||
|
||||
var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new ImportBundleCommand(payload, "pass", "skip"), "Administrator"));
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("COMMAND_FAILED", response.ErrorCode);
|
||||
Assert.Contains("Too many bundle unlock attempts", response.Error);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Native alarm source CRUD (Task 21)
|
||||
// ========================================================================
|
||||
|
||||
@@ -53,7 +53,7 @@ public class TransportGateTests : TestKit, IDisposable
|
||||
var loadCalls = 0;
|
||||
|
||||
var session = new BundleSession { SessionId = Guid.NewGuid() };
|
||||
_importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
_importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>(), Arg.Any<string?>())
|
||||
.Returns(_ =>
|
||||
{
|
||||
Interlocked.Increment(ref loadCalls);
|
||||
|
||||
@@ -502,6 +502,269 @@ public sealed class BundleImporterLoadTests
|
||||
() => rig.Importer.LoadAsync(stream, passphrase: null));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Register row 44 — the per-IP-per-hour unlock throttle is actually enforced.
|
||||
// BundleUnlockRateLimiter was registered, injected and unit-tested but never
|
||||
// invoked, so these all passed-by-doing-nothing before the wiring.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_rejects_unlock_attempts_past_the_per_ip_hourly_cap()
|
||||
{
|
||||
// Per-bundle strikes are effectively disabled here (100) so the throttle is
|
||||
// unambiguously what stops the run.
|
||||
var rig = BuildRig(opts =>
|
||||
{
|
||||
opts.MaxUnlockAttemptsPerIpPerHour = 3;
|
||||
opts.MaxUnlockAttemptsPerSession = 100;
|
||||
});
|
||||
using var packed = PackEncryptedBundle(
|
||||
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
|
||||
var bundleBytes = ((MemoryStream)packed).ToArray();
|
||||
|
||||
for (var attempt = 1; attempt <= 3; attempt++)
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<CryptographicException>(
|
||||
() => rig.Importer.LoadAsync(
|
||||
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.5"));
|
||||
}
|
||||
|
||||
var throttled = await Assert.ThrowsAsync<BundleUnlockThrottledException>(
|
||||
() => rig.Importer.LoadAsync(
|
||||
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.5"));
|
||||
Assert.Equal(3, throttled.MaxAttemptsPerWindow);
|
||||
Assert.Equal("10.1.1.5", throttled.ClientKey);
|
||||
|
||||
// Fail closed: even the CORRECT passphrase is refused while the client is
|
||||
// over budget.
|
||||
await Assert.ThrowsAsync<BundleUnlockThrottledException>(
|
||||
() => rig.Importer.LoadAsync(
|
||||
new MemoryStream(bundleBytes), passphrase: "correct", clientKey: "10.1.1.5"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Throttled_unlock_is_not_a_passphrase_failure_and_burns_no_strike()
|
||||
{
|
||||
// A throttled attempt never reaches the decryptor, so it must not consume one
|
||||
// of the bundle's three strikes — otherwise the per-IP throttle would become a
|
||||
// way to lock a bundle that an operator is legitimately importing.
|
||||
var rig = BuildRig(opts =>
|
||||
{
|
||||
opts.MaxUnlockAttemptsPerIpPerHour = 1;
|
||||
opts.MaxUnlockAttemptsPerSession = 3;
|
||||
});
|
||||
using var packed = PackEncryptedBundle(
|
||||
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
|
||||
var bundleBytes = ((MemoryStream)packed).ToArray();
|
||||
var contentHash = rig.Serializer.ReadManifest(new MemoryStream(bundleBytes)).ContentHash;
|
||||
|
||||
await Assert.ThrowsAnyAsync<CryptographicException>(
|
||||
() => rig.Importer.LoadAsync(
|
||||
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.6"));
|
||||
Assert.Equal(1, rig.SessionStore.GetUnlockFailureCount(contentHash));
|
||||
|
||||
await Assert.ThrowsAsync<BundleUnlockThrottledException>(
|
||||
() => rig.Importer.LoadAsync(
|
||||
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.6"));
|
||||
|
||||
// Still 1 — the denial burned no strike, so the bundle is not locked.
|
||||
Assert.Equal(1, rig.SessionStore.GetUnlockFailureCount(contentHash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unlock_throttle_is_isolated_per_client()
|
||||
{
|
||||
// One noisy client must not spend another operator's budget.
|
||||
var rig = BuildRig(opts =>
|
||||
{
|
||||
opts.MaxUnlockAttemptsPerIpPerHour = 1;
|
||||
opts.MaxUnlockAttemptsPerSession = 100;
|
||||
});
|
||||
using var packed = PackEncryptedBundle(
|
||||
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
|
||||
var bundleBytes = ((MemoryStream)packed).ToArray();
|
||||
|
||||
await Assert.ThrowsAnyAsync<CryptographicException>(
|
||||
() => rig.Importer.LoadAsync(
|
||||
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.7"));
|
||||
await Assert.ThrowsAsync<BundleUnlockThrottledException>(
|
||||
() => rig.Importer.LoadAsync(
|
||||
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.7"));
|
||||
|
||||
// A different client still has its full budget — reaches the decryptor and
|
||||
// fails on the passphrase, not on the throttle.
|
||||
await Assert.ThrowsAnyAsync<CryptographicException>(
|
||||
() => rig.Importer.LoadAsync(
|
||||
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.8"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unattributed_callers_share_one_throttle_bucket_rather_than_bypassing_it()
|
||||
{
|
||||
// Conservative fallback: a caller with no IP is counted against a single
|
||||
// shared bucket, so "unknown client" is a stricter budget, never none.
|
||||
var rig = BuildRig(opts =>
|
||||
{
|
||||
opts.MaxUnlockAttemptsPerIpPerHour = 1;
|
||||
opts.MaxUnlockAttemptsPerSession = 100;
|
||||
});
|
||||
using var packed = PackEncryptedBundle(
|
||||
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
|
||||
var bundleBytes = ((MemoryStream)packed).ToArray();
|
||||
|
||||
await Assert.ThrowsAnyAsync<CryptographicException>(
|
||||
() => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "wrong"));
|
||||
|
||||
// Blank is treated the same as null — both land in the shared bucket, which
|
||||
// the first attempt already emptied.
|
||||
var throttled = await Assert.ThrowsAsync<BundleUnlockThrottledException>(
|
||||
() => rig.Importer.LoadAsync(
|
||||
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: " "));
|
||||
Assert.Equal(BundleImporter.UnattributedClientKey, throttled.ClientKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unencrypted_bundles_do_not_consume_the_unlock_budget()
|
||||
{
|
||||
// The throttle guards passphrase brute-forcing; a plain bundle has no unlock
|
||||
// step, so repeated loads must not be able to exhaust an operator's budget.
|
||||
var rig = BuildRig(opts => opts.MaxUnlockAttemptsPerIpPerHour = 1);
|
||||
using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, SmallContent());
|
||||
var bundleBytes = ((MemoryStream)packed).ToArray();
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
var session = await rig.Importer.LoadAsync(
|
||||
new MemoryStream(bundleBytes), passphrase: null, clientKey: "10.1.1.9");
|
||||
Assert.NotNull(session);
|
||||
rig.Importer.Abandon(session.SessionId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_already_locked_bundle_does_not_spend_the_clients_hourly_budget()
|
||||
{
|
||||
// Ordering pin: the per-bundle lockout is checked BEFORE the throttle, so
|
||||
// hammering a locked bundle cannot burn through the client's hourly budget
|
||||
// and spill over into denying its next legitimate import.
|
||||
var rig = BuildRig(opts =>
|
||||
{
|
||||
opts.MaxUnlockAttemptsPerSession = 1;
|
||||
opts.MaxUnlockAttemptsPerIpPerHour = 2;
|
||||
});
|
||||
using var packed = PackEncryptedBundle(
|
||||
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
|
||||
var bundleBytes = ((MemoryStream)packed).ToArray();
|
||||
|
||||
// One wrong attempt locks the bundle (cap 1) and spends 1 of 2 IP budget.
|
||||
await Assert.ThrowsAsync<BundleLockedException>(
|
||||
() => rig.Importer.LoadAsync(
|
||||
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.10"));
|
||||
|
||||
// Three more attempts on the locked bundle stay BundleLocked — none of them
|
||||
// register against the rate limiter.
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
await Assert.ThrowsAsync<BundleLockedException>(
|
||||
() => rig.Importer.LoadAsync(
|
||||
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.10"));
|
||||
}
|
||||
|
||||
// A DIFFERENT bundle from the same client still has budget left.
|
||||
using var other = PackEncryptedBundle(
|
||||
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, SmallContent(), "correct2");
|
||||
var otherSession = await rig.Importer.LoadAsync(
|
||||
new MemoryStream(((MemoryStream)other).ToArray()),
|
||||
passphrase: "correct2", clientKey: "10.1.1.10");
|
||||
Assert.NotNull(otherSession);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Register row 43 — abandoned-session release.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Abandon_releases_the_session_slot_and_zeroes_its_decrypted_content()
|
||||
{
|
||||
// Row 43: only ApplyAsync used to release a session, so any caller that
|
||||
// loaded a bundle and walked away held one of MaxConcurrentImportSessions
|
||||
// (plus the decrypted plaintext) for the full TTL.
|
||||
var rig = BuildRig();
|
||||
using var packed = PackEncryptedBundle(
|
||||
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, SmallContent(), "correct");
|
||||
var session = await rig.Importer.LoadAsync(
|
||||
new MemoryStream(((MemoryStream)packed).ToArray()), passphrase: "correct");
|
||||
|
||||
var decrypted = session.DecryptedContent;
|
||||
Assert.NotEmpty(decrypted);
|
||||
Assert.Contains(decrypted, b => b != 0);
|
||||
Assert.NotNull(rig.SessionStore.Get(session.SessionId));
|
||||
|
||||
rig.Importer.Abandon(session.SessionId);
|
||||
|
||||
Assert.Null(rig.SessionStore.Get(session.SessionId));
|
||||
// Zeroed in place, so even a caller still holding the reference (the Razor
|
||||
// page keeps one for the Result step) can no longer read the plaintext.
|
||||
Assert.All(decrypted, b => Assert.Equal(0, b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Abandon_is_idempotent_and_tolerates_unknown_ids()
|
||||
{
|
||||
// The Razor page calls Abandon from Dispose even after a successful Apply
|
||||
// already released the same id; a double release must be a silent no-op.
|
||||
var rig = BuildRig();
|
||||
using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, SmallContent());
|
||||
var session = await rig.Importer.LoadAsync(packed, passphrase: null);
|
||||
|
||||
rig.Importer.Abandon(session.SessionId);
|
||||
rig.Importer.Abandon(session.SessionId);
|
||||
rig.Importer.Abandon(Guid.NewGuid());
|
||||
|
||||
Assert.Null(rig.SessionStore.Get(session.SessionId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Abandon_does_not_release_another_callers_session()
|
||||
{
|
||||
// Sessions are keyed by a random GUID handed only to their opener, so one
|
||||
// operator abandoning their wizard must not evict a concurrent import.
|
||||
var rig = BuildRig();
|
||||
using var packedA = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, SmallContent());
|
||||
using var packedB = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, EmptyContent());
|
||||
var a = await rig.Importer.LoadAsync(packedA, passphrase: null);
|
||||
var b = await rig.Importer.LoadAsync(packedB, passphrase: null);
|
||||
|
||||
rig.Importer.Abandon(a.SessionId);
|
||||
|
||||
Assert.Null(rig.SessionStore.Get(a.SessionId));
|
||||
var survivor = rig.SessionStore.Get(b.SessionId);
|
||||
Assert.NotNull(survivor);
|
||||
Assert.NotEmpty(survivor!.DecryptedContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Abandon_frees_a_concurrent_import_session_slot()
|
||||
{
|
||||
// The availability half of row 43: abandoned sessions used to exhaust the
|
||||
// cap and lock out every import on the node until the TTL expired.
|
||||
var rig = BuildRig(opts => opts.MaxConcurrentImportSessions = 2);
|
||||
using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, SmallContent());
|
||||
var bundleBytes = ((MemoryStream)packed).ToArray();
|
||||
|
||||
var first = await rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: null);
|
||||
await rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: null);
|
||||
|
||||
var full = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: null));
|
||||
Assert.Contains("Too many concurrent import sessions", full.Message);
|
||||
|
||||
rig.Importer.Abandon(first.SessionId);
|
||||
|
||||
var third = await rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: null);
|
||||
Assert.NotNull(rig.SessionStore.Get(third.SessionId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a zip directly so the test can write a manifest whose
|
||||
/// <c>ContentHash</c> or <c>BundleFormatVersion</c> intentionally
|
||||
|
||||
Reference in New Issue
Block a user