Files
ScadaBridge/tests/ScadaLink.Transport.Tests/Import/BundleSessionStoreTests.cs
T
Joseph Doherty 5d2386cc9d fix(transport): close bundle security + plaintext-retention gaps (4 findings)
T-003: move the unlock lockout server-side. The 3-strike counter used to
live in the Razor page only — a second tab / CLI caller could re-upload
the same bytes and grind PBKDF2 indefinitely. The counter now lives in
IBundleSessionStore, keyed by ContentHash, so retries against identical
bundle bytes are throttled regardless of client. BundleLockedException
surfaces the new typed error path.

T-005: bind the manifest's non-derivative fields into AES-GCM AAD. A
SHA-256 of the manifest (with ContentHash + Encryption normalised to
sentinels) is now passed to AesGcm.Encrypt / .Decrypt, so a tampered
SourceEnvironment / ExportedBy / CreatedAtUtc on a stolen bundle yields
an authentication-tag mismatch instead of slipping past the Step-4
typo-resistant confirmation gate.

T-006: cap zip entry count, decompressed length, and compression ratio
in LoadAsync's envelope validator BEFORE any payload is decompressed,
using ZipArchiveEntry.Length / .CompressedLength. New TransportOptions
fields default to 4 entries / 200 MB / 50x ratio.

T-007: clear decrypted plaintext on the ApplyAsync failure path and zero
the buffer on success before removing the session, so a 100 MB
DecryptedContent doesn't sit in memory for the 30-min TTL after a failed
apply. A BundleSessionEvictionService BackgroundService now also drives
EvictExpired periodically so abandoned sessions clear without needing a
fresh Get() call to trigger lazy eviction.

Also resolves NO-010 — the misleading "writer never throws" XML doc was
the same code+comment my prior NO-004 await-the-writer fix already
rewrote.
2026-05-28 04:14:07 -04:00

198 lines
7.4 KiB
C#

using Microsoft.Extensions.Options;
using ScadaLink.Commons.Types.Transport;
using ScadaLink.Transport;
using ScadaLink.Transport.Import;
namespace ScadaLink.Transport.Tests.Import;
public sealed class BundleSessionStoreTests
{
private static IOptions<TransportOptions> Options() => Microsoft.Extensions.Options.Options.Create(new TransportOptions());
private static BundleSession SessionExpiringAt(DateTimeOffset expiresAt) => new()
{
SessionId = Guid.NewGuid(),
Manifest = StubManifest(),
DecryptedContent = Array.Empty<byte>(),
ExpiresAt = expiresAt,
};
private static BundleManifest StubManifest() => new(
BundleFormatVersion: 1,
SchemaVersion: "1.0",
CreatedAtUtc: DateTimeOffset.UnixEpoch,
SourceEnvironment: "test",
ExportedBy: "t",
ScadaLinkVersion: "1",
ContentHash: "0",
Encryption: null,
Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0, 0),
Contents: Array.Empty<ManifestContentEntry>());
[Fact]
public void Open_then_Get_returns_session()
{
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
var sut = new BundleSessionStore(Options(), clock);
var session = SessionExpiringAt(clock.GetUtcNow().AddMinutes(30));
sut.Open(session);
var fetched = sut.Get(session.SessionId);
Assert.NotNull(fetched);
Assert.Same(session, fetched);
}
[Fact]
public void Get_after_TTL_returns_null_and_evicts()
{
var start = DateTimeOffset.UtcNow;
var clock = new TestTimeProvider(start);
var sut = new BundleSessionStore(Options(), clock);
var session = SessionExpiringAt(start.AddMinutes(1));
sut.Open(session);
clock.Advance(TimeSpan.FromMinutes(2));
Assert.Null(sut.Get(session.SessionId));
// Lookup with no time advancement now returns null again — proves the
// entry was evicted, not merely filtered.
Assert.Null(sut.Get(session.SessionId));
}
[Fact]
public void Remove_evicts_session()
{
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
var sut = new BundleSessionStore(Options(), clock);
var session = SessionExpiringAt(clock.GetUtcNow().AddHours(1));
sut.Open(session);
sut.Remove(session.SessionId);
Assert.Null(sut.Get(session.SessionId));
}
[Fact]
public void EvictExpired_removes_all_past_ttl()
{
var start = DateTimeOffset.UtcNow;
var clock = new TestTimeProvider(start);
var sut = new BundleSessionStore(Options(), clock);
var keep = SessionExpiringAt(start.AddMinutes(30));
var dropA = SessionExpiringAt(start.AddSeconds(10));
var dropB = SessionExpiringAt(start.AddMinutes(1));
sut.Open(keep);
sut.Open(dropA);
sut.Open(dropB);
clock.Advance(TimeSpan.FromMinutes(2));
sut.EvictExpired();
Assert.NotNull(sut.Get(keep.SessionId));
Assert.Null(sut.Get(dropA.SessionId));
Assert.Null(sut.Get(dropB.SessionId));
}
[Fact]
public void Legacy_FailedUnlockAttempts_field_still_round_trips_via_shared_reference()
{
// T-003 legacy guard: the per-session FailedUnlockAttempts / Locked fields on
// BundleSession are no longer the source of truth (lockout moved to the store
// keyed by ContentHash), but the fields remain as a compatibility shim for
// callers/tests that still set them directly. The store hands out the shared
// reference so mutations made on one Get() are observable from another.
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
var sut = new BundleSessionStore(Options(), clock);
var session = SessionExpiringAt(clock.GetUtcNow().AddMinutes(30));
sut.Open(session);
var live = sut.Get(session.SessionId);
Assert.NotNull(live);
live!.FailedUnlockAttempts++;
Assert.False(live.Locked);
live.FailedUnlockAttempts++;
Assert.False(live.Locked);
live.FailedUnlockAttempts++;
Assert.True(live.Locked);
// Mutations are observable via re-fetch — confirms the store hands out
// the shared reference (not a snapshot).
var refetch = sut.Get(session.SessionId);
Assert.NotNull(refetch);
Assert.True(refetch!.Locked);
}
[Fact]
public void UnlockFailureCount_TracksPerBundleAndResets()
{
// T-003: the per-bundle unlock-failure counter is keyed by ContentHash and
// shared across sessions, so a second tab / CLI caller cannot side-step the
// lockout by re-uploading the same bytes. Increment must be atomic and
// ClearUnlockFailures must drop the entry so a legitimate operator who
// eventually types the right passphrase is not stuck on a stale count.
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
var sut = new BundleSessionStore(Options(), clock);
const string contentHashA = "sha-A";
const string contentHashB = "sha-B";
Assert.Equal(0, sut.GetUnlockFailureCount(contentHashA));
Assert.Equal(1, sut.IncrementUnlockFailureCount(contentHashA));
Assert.Equal(2, sut.IncrementUnlockFailureCount(contentHashA));
// Per-bundle isolation: another bundle's counter is unaffected.
Assert.Equal(0, sut.GetUnlockFailureCount(contentHashB));
sut.ClearUnlockFailures(contentHashA);
Assert.Equal(0, sut.GetUnlockFailureCount(contentHashA));
}
[Fact]
public void UnlockFailures_ExpireOnTtlAndGetReturnsZero()
{
// T-003: failure records share the session TTL so a bundle that was locked
// hours ago clears on its own. Get must lazily expire stale entries.
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
var sut = new BundleSessionStore(Options(), clock);
const string contentHash = "sha-expiring";
sut.IncrementUnlockFailureCount(contentHash);
sut.IncrementUnlockFailureCount(contentHash);
Assert.Equal(2, sut.GetUnlockFailureCount(contentHash));
// Advance beyond the configured TTL (default 30 min).
clock.Advance(TimeSpan.FromMinutes(31));
Assert.Equal(0, sut.GetUnlockFailureCount(contentHash));
}
[Fact]
public void UnlockFailures_EvictExpired_ClearsStaleEntries()
{
// T-003: EvictExpired sweep cleans the per-bundle counters too, not just sessions.
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
var sut = new BundleSessionStore(Options(), clock);
sut.IncrementUnlockFailureCount("sha-old");
clock.Advance(TimeSpan.FromMinutes(31));
sut.EvictExpired();
Assert.Equal(0, sut.GetUnlockFailureCount("sha-old"));
}
/// <summary>
/// Minimal in-test <see cref="TimeProvider"/> with a mutable clock. Used in
/// place of <c>Microsoft.Extensions.TimeProvider.Testing</c> (not in the
/// central package list). Only <see cref="GetUtcNow"/> is overridden; the
/// store does not use timers, so the base implementations suffice elsewhere.
/// </summary>
private sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public TestTimeProvider(DateTimeOffset start) { _now = start; }
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now = _now.Add(by);
}
}