shoc-backend/Api.SeaHavenIndustries.Tests/SessionStampServiceTests.cs

139 lines
5.1 KiB
C#
Raw Normal View History

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<IUserDataService> _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<CancellationToken>())).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<CancellationToken>()), 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<CancellationToken>()))
.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<string>(), It.IsAny<CancellationToken>()), 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);
}
}