From 901d9affdf989c1d78c25b1489ed4eca5f7da908 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 24 May 2026 04:20:55 -0400 Subject: [PATCH] feat(transport): in-memory BundleSessionStore with TTL + lockout --- .../Import/BundleSessionStore.cs | 74 ++++++++++ .../ServiceCollectionExtensions.cs | 5 + .../Import/BundleSessionStoreTests.cs | 136 ++++++++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 src/ScadaLink.Transport/Import/BundleSessionStore.cs create mode 100644 tests/ScadaLink.Transport.Tests/Import/BundleSessionStoreTests.cs diff --git a/src/ScadaLink.Transport/Import/BundleSessionStore.cs b/src/ScadaLink.Transport/Import/BundleSessionStore.cs new file mode 100644 index 0000000..a647623 --- /dev/null +++ b/src/ScadaLink.Transport/Import/BundleSessionStore.cs @@ -0,0 +1,74 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Options; +using ScadaLink.Commons.Interfaces.Transport; +using ScadaLink.Commons.Types.Transport; + +namespace ScadaLink.Transport.Import; + +/// +/// In-memory implementation of backed by a +/// . Sessions are evicted lazily +/// at read time () and on-demand via ; +/// there is no background timer. +/// +/// TTL is supplied by the importer via ; +/// this store does not impose its own. The injected +/// is used purely to determine now when checking ExpiresAt, which +/// keeps unit tests deterministic. +/// +/// +/// The 3-strike unlock lockout is owned by +/// (FailedUnlockAttempts / Locked); the store just hands out the +/// shared session reference so the importer can mutate the counter in place. +/// +/// +public sealed class BundleSessionStore : IBundleSessionStore +{ + private readonly ConcurrentDictionary _sessions = new(); + private readonly TimeProvider _timeProvider; + + // Options are accepted to honor the documented constructor contract and to + // be ready for future per-store knobs (e.g. max in-flight sessions). The + // current store does not read any field — TTL is on the session itself. + public BundleSessionStore(IOptions options, TimeProvider timeProvider) + { + ArgumentNullException.ThrowIfNull(options); + _ = options.Value; + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + } + + public BundleSession Open(BundleSession session) + { + ArgumentNullException.ThrowIfNull(session); + // Overwrite on collision is defensive: GUIDs are random so practical + // collisions don't happen, but if a caller reuses an id we always + // honor the latest Open call. + _sessions[session.SessionId] = session; + return session; + } + + public BundleSession? Get(Guid sessionId) + { + if (!_sessions.TryGetValue(sessionId, out var session)) return null; + if (session.ExpiresAt > _timeProvider.GetUtcNow()) return session; + _sessions.TryRemove(sessionId, out _); + return null; + } + + public void Remove(Guid sessionId) + { + _sessions.TryRemove(sessionId, out _); + } + + public void EvictExpired() + { + var now = _timeProvider.GetUtcNow(); + foreach (var kv in _sessions) + { + if (kv.Value.ExpiresAt <= now) + { + _sessions.TryRemove(kv.Key, out _); + } + } + } +} diff --git a/src/ScadaLink.Transport/ServiceCollectionExtensions.cs b/src/ScadaLink.Transport/ServiceCollectionExtensions.cs index 99700aa..bf0f7be 100644 --- a/src/ScadaLink.Transport/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.Transport/ServiceCollectionExtensions.cs @@ -1,6 +1,9 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; +using ScadaLink.Commons.Interfaces.Transport; using ScadaLink.Transport.Export; +using ScadaLink.Transport.Import; namespace ScadaLink.Transport; @@ -12,7 +15,9 @@ public static class ServiceCollectionExtensions { ArgumentNullException.ThrowIfNull(services); services.AddOptions().BindConfiguration(OptionsSection); + services.TryAddSingleton(TimeProvider.System); services.AddScoped(); + services.AddSingleton(); // Remaining concrete services added in later tasks. return services; } diff --git a/tests/ScadaLink.Transport.Tests/Import/BundleSessionStoreTests.cs b/tests/ScadaLink.Transport.Tests/Import/BundleSessionStoreTests.cs new file mode 100644 index 0000000..271de05 --- /dev/null +++ b/tests/ScadaLink.Transport.Tests/Import/BundleSessionStoreTests.cs @@ -0,0 +1,136 @@ +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 Options() => Microsoft.Extensions.Options.Options.Create(new TransportOptions()); + + private static BundleSession SessionExpiringAt(DateTimeOffset expiresAt) => new() + { + SessionId = Guid.NewGuid(), + Manifest = StubManifest(), + DecryptedContent = Array.Empty(), + 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()); + + [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 Three_failed_unlock_attempts_locks_session() + { + 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); + } + + /// + /// Minimal in-test with a mutable clock. Used in + /// place of Microsoft.Extensions.TimeProvider.Testing (not in the + /// central package list). Only is overridden; the + /// store does not use timers, so the base implementations suffice elsewhere. + /// + 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); + } +}