Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions tests/Orbit.Application.Tests/Services/AuthSessionServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<CancellationToken>());
}

[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<CancellationToken>())
.Returns<Task<int>>(_ => 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()
{
Expand Down Expand Up @@ -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)));
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using NSubstitute;
using Orbit.Domain.Common;
Expand Down Expand Up @@ -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<CancellationToken>());
}

[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<System.Linq.Expressions.Expression<Func<UserSession, bool>>>(),
Arg.Any<Func<IQueryable<UserSession>, IQueryable<UserSession>>?>(),
Arg.Any<CancellationToken>())
.Returns(session);
_userRepository.GetByIdAsync(user.Id, Arg.Any<CancellationToken>()).Returns(user);
_unitOfWork.SaveChangesAsync(Arg.Any<CancellationToken>())
.Returns<Task<int>>(_ => 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<Guid>(), Arg.Any<string>());
}

[Fact]
public async Task RefreshSessionAsync_ExpiredSession_ReturnsFailure()
{
Expand Down
Loading