diff --git a/tests/Orbit.Application.Tests/Services/AuthSessionServiceTests.cs b/tests/Orbit.Application.Tests/Services/AuthSessionServiceTests.cs index f37fe7de..7e786d18 100644 --- a/tests/Orbit.Application.Tests/Services/AuthSessionServiceTests.cs +++ b/tests/Orbit.Application.Tests/Services/AuthSessionServiceTests.cs @@ -1,5 +1,8 @@ using System.Linq.Expressions; +using System.Security.Cryptography; +using System.Text; using FluentAssertions; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using NSubstitute; using Orbit.Application.Common; @@ -99,10 +102,46 @@ public async Task RefreshSessionAsync_WithNonExpiringSession_RotatesRefreshToken refreshResult.Value.RefreshToken.Should().NotBe(createResult.Value.RefreshToken); _storedSession.ExpiresAtUtc.Should().BeNull(); _storedSession.TokenHash.Should().NotBe(originalTokenHash); + _storedSession.TokenHash.Should().Be(Hash(refreshResult.Value.RefreshToken)); _storedSession.LastUsedAtUtc.Should().BeAfter(originalLastUsedAtUtc); await _unitOfWork.Received(2).SaveChangesAsync(Arg.Any()); } + [Fact] + public async Task RefreshSessionAsync_LoserReplaysRotatedOutToken_IsRejectedAndSessionRotatesExactlyOnce() + { + var createResult = await _service.CreateSessionAsync(_user.Id, _user.Email, CancellationToken.None); + var consumedToken = createResult.Value.RefreshToken; + var originalTokenHash = _storedSession!.TokenHash; + + var winner = await _service.RefreshSessionAsync(consumedToken, CancellationToken.None); + winner.IsSuccess.Should().BeTrue(); + var winnerToken = winner.Value.RefreshToken; + + var loser = await _service.RefreshSessionAsync(consumedToken, CancellationToken.None); + + loser.IsFailure.Should().BeTrue(); + loser.ErrorCode.Should().Be(ErrorCodes.InvalidSession); + _storedSession.TokenHash.Should().Be(Hash(winnerToken)); + _storedSession.TokenHash.Should().NotBe(originalTokenHash); + } + + [Fact] + public async Task RefreshSessionAsync_ConcurrencyConflictOnSave_ReturnsInvalidSessionAndDiscardsChanges() + { + var createResult = await _service.CreateSessionAsync(_user.Id, _user.Email, CancellationToken.None); + _unitOfWork + .SaveChangesAsync(Arg.Any()) + .Returns>(_ => throw new DbUpdateConcurrencyException("simulated stale xmin token")); + + var act = async () => await _service.RefreshSessionAsync(createResult.Value.RefreshToken, CancellationToken.None); + var loser = await act.Should().NotThrowAsync(); + + loser.Which.IsFailure.Should().BeTrue(); + loser.Which.ErrorCode.Should().Be(ErrorCodes.InvalidSession); + _unitOfWork.Received(1).DiscardChanges(); + } + [Fact] public async Task RevokeSessionAsync_RevokesStoredSession() { @@ -138,4 +177,7 @@ public void UserSession_CanUse_AllowsNullExpiryUntilRevoked() session.CanUse(DateTime.UtcNow.AddYears(10)).Should().BeFalse(); } + + private static string Hash(string token) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))); } diff --git a/tests/Orbit.Infrastructure.Tests/Services/AuthSessionServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/AuthSessionServiceTests.cs index c85c060c..ee921864 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/AuthSessionServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/AuthSessionServiceTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using NSubstitute; using Orbit.Domain.Common; @@ -85,9 +86,36 @@ public async Task RefreshSessionAsync_RotatesExistingSession() result.IsSuccess.Should().BeTrue(); result.Value.RefreshToken.Should().NotBe(existingToken); session.TokenHash.Should().NotBe(Hash(existingToken)); + session.TokenHash.Should().Be(Hash(result.Value.RefreshToken)); await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); } + [Fact] + public async Task RefreshSessionAsync_LosesConcurrencyRace_ReturnsInvalidSessionDiscardsChangesAndIssuesNoToken() + { + var user = User.Create("Thomas", "thomas@test.com").Value; + var existingToken = "refresh-token"; + var session = UserSession.Create(user.Id, Hash(existingToken), DateTime.UtcNow.AddDays(7)).Value; + + _userSessionRepository.FindOneTrackedAsync( + Arg.Any>>(), + Arg.Any, IQueryable>?>(), + Arg.Any()) + .Returns(session); + _userRepository.GetByIdAsync(user.Id, Arg.Any()).Returns(user); + _unitOfWork.SaveChangesAsync(Arg.Any()) + .Returns>(_ => throw new DbUpdateConcurrencyException("simulated stale xmin token")); + + _tokenService.ClearReceivedCalls(); + var act = async () => await _sut.RefreshSessionAsync(existingToken, CancellationToken.None); + var result = await act.Should().NotThrowAsync(); + + result.Which.IsFailure.Should().BeTrue(); + result.Which.ErrorCode.Should().Be("INVALID_SESSION"); + _unitOfWork.Received(1).DiscardChanges(); + _tokenService.DidNotReceive().GenerateToken(Arg.Any(), Arg.Any()); + } + [Fact] public async Task RefreshSessionAsync_ExpiredSession_ReturnsFailure() {