using FluentAssertions; using Moq; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Configuration; using SeaHaven.Services.Implementation; using Xunit; namespace Api.SeaHavenIndustries.Tests; public class SessionStampServiceTests { private const string UserId = "user-1"; private readonly Mock _users = new(); private readonly SteppedTimeProvider _time = new(); private readonly InMemorySessionStampCache _cache; public SessionStampServiceTests() { _cache = new InMemorySessionStampCache(_time); } private SessionStampService NewService(string secret = "0123456789abcdef0123456789abcdef0123456789abcdef") => new(_users.Object, _cache, Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = secret })); private void StoredStamp(string? stamp) => _users.Setup(users => users.GetActiveSecurityStampAsync(UserId, It.IsAny())).ReturnsAsync(stamp); [Fact] public void The_token_carries_a_keyed_hash_never_the_stamp_itself() { var value = NewService().ClaimValueFor("STAMP-ONE"); value.Should().NotContain("STAMP-ONE"); value.Should().Be(NewService().ClaimValueFor("STAMP-ONE")); value.Should().NotBe(NewService().ClaimValueFor("STAMP-TWO")); value.Should().NotBe(NewService(new string('z', 64)).ClaimValueFor("STAMP-ONE")); } [Fact] public async Task A_matching_stamp_is_read_once_per_cache_lifetime() { StoredStamp("STAMP-ONE"); var service = NewService(); var issued = service.ClaimValueFor("STAMP-ONE"); (await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue(); (await NewService().IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue(); _users.Verify(users => users.GetActiveSecurityStampAsync(UserId, It.IsAny()), Times.Once); } [Fact] public async Task A_stamp_changed_by_another_instance_is_enforced_once_the_cached_value_expires() { StoredStamp("STAMP-ONE"); var service = NewService(); var issued = service.ClaimValueFor("STAMP-ONE"); (await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue(); StoredStamp("STAMP-TWO"); _time.Advance(InMemorySessionStampCache.Lifetime - TimeSpan.FromSeconds(1)); (await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue(); _time.Advance(TimeSpan.FromSeconds(1)); (await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeFalse(); } [Fact] public async Task A_stamp_changed_by_this_instance_is_enforced_at_once() { StoredStamp("STAMP-ONE"); var service = NewService(); var issued = service.ClaimValueFor("STAMP-ONE"); (await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue(); StoredStamp("STAMP-TWO"); service.Forget(UserId); (await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeFalse(); } [Fact] public async Task A_read_that_races_a_change_is_not_cached() { var service = NewService(); var issued = service.ClaimValueFor("STAMP-ONE"); var reads = 0; _users.Setup(users => users.GetActiveSecurityStampAsync(UserId, It.IsAny())) .ReturnsAsync(() => { // The first read returns the old stamp while this instance saves a new one. if (reads++ == 0) { service.Forget(UserId); return "STAMP-ONE"; } return "STAMP-TWO"; }); (await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeTrue(); (await service.IsCurrentAsync(UserId, issued, CancellationToken.None)).Should().BeFalse(); } [Theory] [InlineData(null)] [InlineData("")] public async Task A_missing_deleted_or_stampless_account_matches_nothing(string? stored) { StoredStamp(stored); var service = NewService(); (await service.IsCurrentAsync(UserId, service.ClaimValueFor("STAMP-ONE"), CancellationToken.None)).Should().BeFalse(); (await service.IsCurrentAsync(UserId, "", CancellationToken.None)).Should().BeFalse(); } [Theory] [InlineData(null)] [InlineData("")] public async Task A_token_without_a_stamp_is_refused_without_a_lookup(string? claimValue) { StoredStamp("STAMP-ONE"); (await NewService().IsCurrentAsync(UserId, claimValue, CancellationToken.None)).Should().BeFalse(); _users.Verify(users => users.GetActiveSecurityStampAsync(It.IsAny(), It.IsAny()), Times.Never); } private sealed class SteppedTimeProvider : TimeProvider { private DateTimeOffset _now = new(2026, 9, 25, 12, 0, 0, TimeSpan.Zero); public override DateTimeOffset GetUtcNow() => _now; public void Advance(TimeSpan by) => _now = _now.Add(by); } }