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 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")); } /// /// 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); } }