513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
242 lines
9.9 KiB
C#
242 lines
9.9 KiB
C#
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.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",
|
|
ScadaBridgeVersion: "1",
|
|
ContentHash: "0",
|
|
Encryption: null,
|
|
Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0),
|
|
Contents: Array.Empty<ManifestContentEntry>());
|
|
|
|
/// <summary>Verifies that opening beyond the maximum concurrent import sessions throws, and removing one admits a new session.</summary>
|
|
[Fact]
|
|
public void Open_beyond_MaxConcurrentImportSessions_throws_and_removing_one_admits_a_new_session()
|
|
{
|
|
// The store bounds concurrently-open sessions (each pins a
|
|
// decrypted bundle in memory). The (cap+1)th Open is rejected; freeing a
|
|
// slot re-admits a new session.
|
|
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
|
|
var opts = Options();
|
|
var cap = opts.Value.MaxConcurrentImportSessions;
|
|
var sut = new BundleSessionStore(opts, clock);
|
|
var future = clock.GetUtcNow().AddMinutes(30);
|
|
|
|
var sessions = new List<BundleSession>();
|
|
for (var i = 0; i < cap; i++)
|
|
{
|
|
var s = SessionExpiringAt(future);
|
|
sut.Open(s);
|
|
sessions.Add(s);
|
|
}
|
|
|
|
var overflow = SessionExpiringAt(future);
|
|
var ex = Assert.Throws<InvalidOperationException>(() => sut.Open(overflow));
|
|
Assert.Contains("Too many concurrent import sessions", ex.Message, StringComparison.Ordinal);
|
|
|
|
// Free a slot → the previously-rejected session is admitted.
|
|
sut.Remove(sessions[0].SessionId);
|
|
sut.Open(overflow);
|
|
Assert.NotNull(sut.Get(overflow.SessionId));
|
|
}
|
|
|
|
/// <summary>Verifies that opening a session and then getting it returns the session.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that getting a session after its time-to-live returns null and evicts it.</summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Verifies that removing a session evicts it.</summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Verifies that eviction removes every session past its time-to-live.</summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Verifies that the legacy failed-unlock-attempts field still round-trips through the shared reference.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that the unlock failure count is tracked per bundle and resets.</summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Verifies that unlock failures expire on the time-to-live and get returns zero.</summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>Verifies that evicting expired entries clears stale unlock failure entries.</summary>
|
|
[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;
|
|
/// <summary>Initializes the provider with the given start time.</summary>
|
|
/// <param name="start">The initial UTC time.</param>
|
|
public TestTimeProvider(DateTimeOffset start) { _now = start; }
|
|
/// <inheritdoc />
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
/// <summary>Advances the provider's current time.</summary>
|
|
/// <param name="by">The amount of time to advance.</param>
|
|
public void Advance(TimeSpan by) => _now = _now.Add(by);
|
|
}
|
|
}
|