fix(transport): release abandoned BundleSessions instead of leaking a slot (row 43)

Only BundleImporter.ApplyAsync released an import session, so every path that
opened one and did NOT apply held one of MaxConcurrentImportSessions (8) — and
the DECRYPTED bundle content that T-007 deliberately zeroes on the apply paths —
for the full BundleSessionTtlMinutes (30). Eight abandonments locked out all
imports on the active node; observed live (R6: 3 back-to-back suite runs held 6
of 8 slots until central-b was restarted).

New IBundleImporter.Abandon(sessionId): the same zero-then-remove release
ApplyAsync performs, idempotent (double release / unknown id / expired id are
no-ops) and only ever given the random-GUID id its caller opened, so it can
never evict another circuit's session.

Wired into every abandonment path:
- Central UI wizard — ReleaseSession() from new upload, TryLoadAsync (before it
  overwrites _session), Back-to-Upload / ResetSessionState, the 3-strike lockout
  reset, and Dispose (navigation away / circuit teardown).
- CLI transport preview — a preview never applies, so nothing released it at
  all; now released in a finally.
- CLI transport import — covers the pre-apply aborts (blocker rows, unresolved
  site/connection mappings).

Class doc's "an abandoned wizard does not leak" claim is true again (it only
ever covered the staged plaintext temp file). Component-Transport.md +
docs/components/Transport.md now record the explicit-release contract and that
BundleSessionEvictionService is a TTL backstop, not an abandonment path.

Tests: 4 bUnit tests over a REAL BundleSessionStore (dispose / back-to-upload /
apply-then-dispose idempotency / concurrent-circuit isolation) — 3 of the 4 fail
against the pre-fix page; 4 importer tests (release + zeroing, idempotency,
cross-session isolation, and the slot-cap regression).
This commit is contained in:
Joseph Doherty
2026-08-15 05:42:08 -04:00
parent 9cf24a4496
commit 708389829b
8 changed files with 459 additions and 57 deletions
+9 -1
View File
@@ -39,7 +39,15 @@ When a passphrase is supplied, `BundleSecretEncryptor` derives a 256-bit key via
### BundleSession and session lifecycle
After `LoadAsync` validates and decrypts a bundle, the plaintext content is stored in a `BundleSession` held by the singleton `BundleSessionStore` (a `ConcurrentDictionary<Guid, BundleSession>`). Sessions have a 30-minute TTL (`BundleSessionTtlMinutes`). `BundleSessionEvictionService` sweeps the store every minute so abandoned sessions — and the secrets they carry — are released without waiting for the next `Get` call. `ApplyAsync` explicitly zeros and removes the session on both success and failure (T-007).
After `LoadAsync` validates and decrypts a bundle, the plaintext content is stored in a `BundleSession` held by the singleton `BundleSessionStore` (a `ConcurrentDictionary<Guid, BundleSession>`). Sessions have a 30-minute TTL (`BundleSessionTtlMinutes`). `BundleSessionEvictionService` sweeps the store every minute, but that only reclaims sessions whose TTL has already **elapsed** — it is a backstop, not an abandonment path. `ApplyAsync` explicitly zeros and removes the session on both success and failure (T-007).
`IBundleImporter.Abandon(sessionId)` is the explicit release for a session that will never be applied, applying the same zero-then-remove ordering as `ApplyAsync`. It is idempotent (double release, unknown id, and already-expired id are all silent no-ops) and, because session ids are random GUIDs handed only to their opener, can never release another caller's session. Every abandonment path calls it:
- Central UI wizard — new upload, Back-to-Upload / `ResetSessionState`, the 3-strike lockout reset, and `Dispose` (navigation away or circuit teardown).
- CLI `transport preview` — a preview never applies, so the handler releases in a `finally`.
- CLI `transport import` — covers the aborts that happen before `ApplyAsync` (blocker rows, unresolved site/connection mappings).
Without this, an abandoned wizard or a plain CLI preview held one of the eight `MaxConcurrentImportSessions` slots — and the decrypted bundle content — for the full TTL; eight of them locked out every import on the active node (deferred register row 43, observed live).
### Conflict resolution and `BundleImportId` correlation
+3 -1
View File
@@ -112,7 +112,7 @@ The manifest is plaintext so the import wizard can preview bundle contents and s
flowchart TD
subgraph T["ZB.MOM.WW.ScadaBridge.Transport"]
EXPORTER["IBundleExporter<br/>ExportAsync(ExportSelection, Passphrase?, ct) → Stream"]
IMPORTER["IBundleImporter<br/>LoadAsync(stream, Passphrase?, ct) → BundleSession<br/>PreviewAsync(sessionId, ct) → ImportPreview<br/>ApplyAsync(sessionId, resolutions, ct) → ImportResult"]
IMPORTER["IBundleImporter<br/>LoadAsync(stream, Passphrase?, ct) → BundleSession<br/>PreviewAsync(sessionId, ct) → ImportPreview<br/>ApplyAsync(sessionId, resolutions, ct) → ImportResult<br/>Abandon(sessionId)"]
RESOLVER["DependencyResolver"]
SERIALIZER["BundleSerializer<br/>(manifest + content JSON; ZIP packer)"]
ENCRYPTOR["BundleSecretEncryptor<br/>(AES-256-GCM + PBKDF2)"]
@@ -136,6 +136,8 @@ flowchart TD
The component is central-hosted. It is registered in `ZB.MOM.WW.ScadaBridge.Host` for central roles only, never for site roles — even when a bundle carries site-scoped config (T18), Transport only writes the central configuration database, never the site clusters. All persistence flows through existing audited repository interfaces in `ZB.MOM.WW.ScadaBridge.ConfigurationDatabase` — the component does not call `DbContext.SaveChangesAsync` directly. `BundleSessionStore` is in-process on the active central node (matching Blazor Server circuit affinity): 30-minute TTL, eviction on expiry, 3-strike passphrase lockout per session, and a `MaxConcurrentImportSessions` cap (default 8; arch-review 05 Task 24 — startup-validated as `> 0` by `TransportOptionsValidator`, since a zero/negative cap would reject every import session forever) bounding the decrypted-content footprint since each open session pins a fully-decrypted bundle in memory.
**Session release is explicit, not TTL-only (register row 43).** `ApplyAsync` releases the session it applied (zero-then-remove, T-007), and `IBundleImporter.Abandon(sessionId)` does the same for a session that will never be applied. Every abandonment path calls it: the Central UI wizard on new upload / Back-to-Upload / `ResetSessionState` / lockout reset / `Dispose` (navigation away or circuit teardown), the CLI `transport preview` handler (a preview never applies), and the CLI `transport import` handler for the aborts that precede apply (blocker rows, unresolved mappings). `Abandon` is idempotent — double release, an unknown id, and an already-expired id are all silent no-ops — and since session ids are random GUIDs handed only to their opener, abandoning the id you hold can never evict another operator's in-progress import. Relying on the TTL alone was an availability *and* secret-lifetime defect: eight abandoned wizards exhausted `MaxConcurrentImportSessions` and locked out every import on the active node for 30 minutes, each still holding its decrypted bundle content.
**Preview→apply window (recorded decision).** The diff/preview an operator reviews is a point-in-time snapshot; there is **no optimistic concurrency** between preview and apply. If the target config changes in the window between `PreviewAsync` and `ApplyAsync` (another admin edits a template, say), the apply proceeds against the current state — the resolutions are keyed by `(EntityType, Name)`, so they still target the right rows, but the operator may not have seen the latest state in the diff. This is accepted for v1 (single-operator import is the expected workflow, and the terminal Confirm step already gates a typo-resistant environment-name entry). The bundle-format `version` fields are reserved for a future optimistic-concurrency check but are not yet enforced (Underdeveloped 5).
## Export Flow
@@ -51,6 +51,21 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design;
/// retained on the component. The file is deleted on Back-to-Upload / Reset /
/// successful Apply / component Dispose, so an abandoned wizard does not leak
/// staged bundle plaintext beyond circuit teardown.
///
/// <para>
/// Server-side session release (register row 43): the staged temp file is only
/// half of an abandoned wizard's footprint — the server-side
/// <see cref="BundleSession"/> opened by <see cref="IBundleImporter.LoadAsync"/>
/// holds the DECRYPTED bundle content and occupies one of
/// <c>TransportOptions.MaxConcurrentImportSessions</c> slots for the full
/// <c>BundleSessionTtlMinutes</c>. Only <see cref="IBundleImporter.ApplyAsync"/>
/// used to release it, so eight abandoned wizards locked out every import on the
/// active node. Every path that drops <c>_session</c> — new upload, Back-to-Upload,
/// <see cref="ResetSessionState"/>, lockout reset, circuit teardown via
/// <see cref="Dispose"/> — now goes through <see cref="ReleaseSession"/>, which
/// calls <see cref="IBundleImporter.Abandon"/> (zero + remove, idempotent and
/// safe after a successful Apply has already released the same id).
/// </para>
/// </summary>
public partial class TransportImport : ComponentBase, IDisposable
{
@@ -149,7 +164,9 @@ public partial class TransportImport : ComponentBase, IDisposable
{
_errorMessage = null;
_uploadInProgress = true;
_session = null;
// Release the previous upload's server-side session before we lose the
// only reference to its id — otherwise re-selecting a file leaks a slot.
ReleaseSession();
DeleteBundleTempFile();
try
{
@@ -207,6 +224,10 @@ public partial class TransportImport : ComponentBase, IDisposable
_errorMessage = "No bundle staged — please re-select the file.";
return;
}
// A successful LoadAsync overwrites _session; release whatever it is
// replacing first so the id of the outgoing session is never dropped on
// the floor (that would strand its slot for the full TTL).
ReleaseSession();
try
{
// Read the staged bundle straight off disk; the
@@ -808,7 +829,7 @@ public partial class TransportImport : ComponentBase, IDisposable
private void ResetSessionState()
{
_session = null;
ReleaseSession();
DeleteBundleTempFile();
_preview = null;
_resolutions = null;
@@ -854,12 +875,41 @@ public partial class TransportImport : ComponentBase, IDisposable
}
/// <summary>
/// Ensures the staged temp file does not survive circuit
/// teardown. Blazor invokes Dispose when the user navigates away or the
/// circuit ends, so an abandoned wizard cleans up automatically.
/// Releases the server-side <see cref="BundleSession"/> this wizard opened
/// (zeroing its decrypted content) and clears the local reference. Idempotent:
/// a null <c>_session</c> is a no-op, and <see cref="IBundleImporter.Abandon"/>
/// tolerates an id that <see cref="IBundleImporter.ApplyAsync"/> already
/// released. Only ever passes the id THIS circuit opened, so a concurrent
/// operator's session is never touched.
/// <para>
/// Abandon failure must not block the wizard (or, from <see cref="Dispose"/>,
/// tear down the circuit), so it is swallowed on the same best-effort footing
/// as the staged-file delete below.
/// </para>
/// </summary>
private void ReleaseSession()
{
var session = _session;
_session = null;
if (session is null) return;
try
{
BundleImporter.Abandon(session.SessionId);
}
catch
{
// Best-effort: the session's own TTL is the backstop.
}
}
/// <summary>
/// Ensures neither the staged temp file nor the server-side bundle session
/// survives circuit teardown. Blazor invokes Dispose when the user navigates
/// away or the circuit ends, so an abandoned wizard cleans up automatically.
/// </summary>
public void Dispose()
{
ReleaseSession();
DeleteBundleTempFile();
}
}
@@ -46,4 +46,25 @@ public interface IBundleImporter
string user,
CancellationToken ct = default,
BundleNameMap? nameMap = null);
/// <summary>
/// Releases a session opened by <see cref="LoadAsync"/> that will never be
/// applied — the operator navigated away, restarted the wizard, or a CLI
/// preview/import gave up before <see cref="ApplyAsync"/> ran. Zeroes the
/// session's decrypted plaintext and drops it from the session store, which
/// frees one of the <c>MaxConcurrentImportSessions</c> slots immediately
/// instead of at the end of <c>BundleSessionTtlMinutes</c>.
/// <para>
/// Idempotent and safe to call after <see cref="ApplyAsync"/> (which performs
/// the same release on both its success and failure paths) or with an unknown
/// / already-expired id — the call is then a no-op and never throws.
/// </para>
/// <para>
/// Ownership: session ids are random GUIDs handed only to the caller that
/// opened them, so abandoning the id you hold can never release a session
/// another circuit / CLI caller owns.
/// </para>
/// </summary>
/// <param name="sessionId">Session id returned by <see cref="LoadAsync"/>.</param>
void Abandon(Guid sessionId);
}
@@ -3571,16 +3571,27 @@ public class ManagementActor : ReceiveActor
throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
}
var preview = await importer.PreviewAsync(session.SessionId);
var adds = preview.Items.Count(i => i.Kind == ConflictKind.New);
var mods = preview.Items.Count(i => i.Kind == ConflictKind.Modified);
var ids = preview.Items.Count(i => i.Kind == ConflictKind.Identical);
var blocks = preview.Items.Count(i => i.Kind == ConflictKind.Blocker);
// Surface the required site/connection mappings so an operator
// (CLI or UI) sees which references need resolving before applying.
return new PreviewBundleResult(
preview.Items, adds, mods, ids, blocks,
preview.RequiredSiteMappings, preview.RequiredConnectionMappings);
// Register row 43: a preview NEVER applies, so nothing else would ever
// release this session — every `transport preview` used to burn one of
// MaxConcurrentImportSessions (and hold the decrypted bundle) for the
// full BundleSessionTtlMinutes. Release it on the way out, always.
try
{
var preview = await importer.PreviewAsync(session.SessionId);
var adds = preview.Items.Count(i => i.Kind == ConflictKind.New);
var mods = preview.Items.Count(i => i.Kind == ConflictKind.Modified);
var ids = preview.Items.Count(i => i.Kind == ConflictKind.Identical);
var blocks = preview.Items.Count(i => i.Kind == ConflictKind.Blocker);
// Surface the required site/connection mappings so an operator
// (CLI or UI) sees which references need resolving before applying.
return new PreviewBundleResult(
preview.Items, adds, mods, ids, blocks,
preview.RequiredSiteMappings, preview.RequiredConnectionMappings);
}
finally
{
importer.Abandon(session.SessionId);
}
}
finally
{
@@ -3618,50 +3629,62 @@ public class ManagementActor : ReceiveActor
throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
}
var preview = await importer.PreviewAsync(session.SessionId);
var blockers = preview.Items.Where(i => i.Kind == ConflictKind.Blocker).ToList();
if (blockers.Count > 0)
// Register row 43: ApplyAsync releases the session on both its success
// and failure paths, but every abort BEFORE it (blockers, unresolved
// mappings) used to strand the session — and its decrypted content —
// for the full TTL. Abandon is idempotent, so covering the whole block
// is safe even when ApplyAsync already released the same id.
try
{
var details = string.Join("; ",
blockers.Select(b => $"{b.Name}: {b.BlockerReason}"));
throw new ManagementCommandException(
$"Bundle has {blockers.Count} blocker(s); import aborted. {details}");
}
var preview = await importer.PreviewAsync(session.SessionId);
// Resolve every required site/connection reference into a
// BundleNameMap. Precedence: an explicit operator spec wins; otherwise
// fall back to the preview's auto-match; otherwise (no match) create-new
// ONLY if the create-missing flag is set, else fail with a clear message.
var nameMap = BuildNameMap(cmd, preview);
// Dedupe by (EntityType, Name) -- the preview can emit multiple rows per
// entity (e.g. one row per modified member of a template), but ApplyAsync
// requires a unique resolution per key. Last-write-wins matches the
// Central UI's TransportImport.BuildDefaultResolutions behavior. For
// DataConnection rows the preview item Name is already site-qualified
// ({site}/{name}), so keying generically by (EntityType, Name) is
// automatically correct — no bare-connection-name special case needed.
var renameStamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
var resolutionsMap = new Dictionary<(string, string), ImportResolution>();
foreach (var item in preview.Items)
{
var action = item.Kind switch
var blockers = preview.Items.Where(i => i.Kind == ConflictKind.Blocker).ToList();
if (blockers.Count > 0)
{
ConflictKind.New => ResolutionAction.Add,
ConflictKind.Identical => ResolutionAction.Skip,
ConflictKind.Modified => policy,
_ => ResolutionAction.Skip,
};
var renameTo = (item.Kind == ConflictKind.Modified && policy == ResolutionAction.Rename)
? $"{item.Name}-imported-{renameStamp}"
: null;
resolutionsMap[(item.EntityType, item.Name)] = new ImportResolution(
item.EntityType, item.Name, action, renameTo);
}
var details = string.Join("; ",
blockers.Select(b => $"{b.Name}: {b.BlockerReason}"));
throw new ManagementCommandException(
$"Bundle has {blockers.Count} blocker(s); import aborted. {details}");
}
return await importer.ApplyAsync(
session.SessionId, resolutionsMap.Values.ToList(), username, nameMap: nameMap);
// Resolve every required site/connection reference into a
// BundleNameMap. Precedence: an explicit operator spec wins; otherwise
// fall back to the preview's auto-match; otherwise (no match) create-new
// ONLY if the create-missing flag is set, else fail with a clear message.
var nameMap = BuildNameMap(cmd, preview);
// Dedupe by (EntityType, Name) -- the preview can emit multiple rows per
// entity (e.g. one row per modified member of a template), but ApplyAsync
// requires a unique resolution per key. Last-write-wins matches the
// Central UI's TransportImport.BuildDefaultResolutions behavior. For
// DataConnection rows the preview item Name is already site-qualified
// ({site}/{name}), so keying generically by (EntityType, Name) is
// automatically correct — no bare-connection-name special case needed.
var renameStamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
var resolutionsMap = new Dictionary<(string, string), ImportResolution>();
foreach (var item in preview.Items)
{
var action = item.Kind switch
{
ConflictKind.New => ResolutionAction.Add,
ConflictKind.Identical => ResolutionAction.Skip,
ConflictKind.Modified => policy,
_ => ResolutionAction.Skip,
};
var renameTo = (item.Kind == ConflictKind.Modified && policy == ResolutionAction.Rename)
? $"{item.Name}-imported-{renameStamp}"
: null;
resolutionsMap[(item.EntityType, item.Name)] = new ImportResolution(
item.EntityType, item.Name, action, renameTo);
}
return await importer.ApplyAsync(
session.SessionId, resolutionsMap.Values.ToList(), username, nameMap: nameMap);
}
finally
{
importer.Abandon(session.SessionId);
}
}
finally
{
@@ -1715,6 +1715,32 @@ public sealed class BundleImporter : IBundleImporter
}
}
/// <inheritdoc />
/// <remarks>
/// Register row 43: every abandonment path (Central UI wizard reset /
/// back-to-upload / circuit teardown, CLI preview, CLI import that aborts
/// before Apply) funnels here so a session that will never be applied stops
/// holding one of the <c>MaxConcurrentImportSessions</c> slots — and stops
/// holding its decrypted plaintext — for the rest of its TTL. Applies the same
/// zero-then-remove ordering as the T-007 release inside
/// <see cref="ApplyAsync"/>: zeroing first means a caller still holding the
/// session reference (the Razor page keeps one for the Result step) observes
/// the cleared buffer too.
/// </remarks>
public void Abandon(Guid sessionId)
{
// Get() returns null for an unknown or already-expired id, which makes the
// double-release after ApplyAsync (which already removed it) a no-op rather
// than a throw. Remove is called unconditionally so an expired-but-still-
// resident entry is dropped as well.
var session = _sessionStore.Get(sessionId);
if (session is not null)
{
ZeroDecryptedContent(session);
}
_sessionStore.Remove(sessionId);
}
/// <summary>
/// Enumerates the deployed instances stale-ed by this import.
/// Overwriting a template changes the flattened-config hash of every instance
@@ -17,6 +17,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;
@@ -688,6 +689,191 @@ 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)
=> 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
@@ -502,6 +502,92 @@ public sealed class BundleImporterLoadTests
() => rig.Importer.LoadAsync(stream, passphrase: null));
}
// ─────────────────────────────────────────────────────────────────────
// 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