feat(transport): in-memory BundleSessionStore with TTL + lockout
This commit is contained in:
@@ -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<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 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);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user