Merge branch 'transport-session-fixes' — abandoned-session release + per-IP unlock throttle enforced (register rows 43, 44)

This commit is contained in:
Joseph Doherty
2026-08-15 06:05:20 -04:00
16 changed files with 1001 additions and 78 deletions
@@ -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