mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-07-16 09:37:23 +00:00
Auth and Emails land together since Auth's registration/resend flows need Emails to exist first. Service.Auth's four services (Register, Login, Confirmation, Token) collapse into MediatR commands/queries in Features.Auth; TokenService stays as a slice-internal service since multiple handlers call it. Auth no longer references Emails directly — it sends SendRegistrationEmailCommand/SendResendConfirmationEmailCommand (defined in Shared.Application) which Features.Emails handles, so neither slice has a project reference on the other. Service.Emails' IEmailService becomes Features.Emails' IEmailDispatcher, simplified to take (firstName, email, token) instead of a full UserAccount since that's all it ever used. API.Specs' TestApiFactory/MockEmailService are updated to swap in the relocated interface. Also deletes Infrastructure.Repository now that Breweries, UserManagement, and Auth have all moved their repos out of it, and replaces the two now-dead docker-compose test services (repository.tests, service.auth.tests, both pointing at deleted Dockerfiles) with a single unit.tests service that runs every Features.*.Tests project via Dockerfile.tests.
284 lines
9.1 KiB
C#
284 lines
9.1 KiB
C#
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using Domain.Entities;
|
|
using Domain.Exceptions;
|
|
using FluentAssertions;
|
|
using Features.Auth.Repository;
|
|
using Features.Auth.Services;
|
|
using Infrastructure.Jwt;
|
|
using Moq;
|
|
|
|
namespace Features.Auth.Tests.Services;
|
|
|
|
public class TokenServiceValidationTests
|
|
{
|
|
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
|
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
|
private readonly TokenService _tokenService;
|
|
|
|
public TokenServiceValidationTests()
|
|
{
|
|
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
|
_authRepositoryMock = new Mock<IAuthRepository>();
|
|
|
|
// Set environment variables for tokens
|
|
Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890");
|
|
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890");
|
|
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET", "test-confirmation-secret-that-is-very-long-1234567890");
|
|
|
|
_tokenService = new TokenService(
|
|
_tokenInfraMock.Object,
|
|
_authRepositoryMock.Object
|
|
);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAccessTokenAsync_WithValidToken_ReturnsValidatedToken()
|
|
{
|
|
// Arrange
|
|
var userId = Guid.NewGuid();
|
|
const string username = "testuser";
|
|
const string token = "valid-access-token";
|
|
|
|
var claims = new List<Claim>
|
|
{
|
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
|
};
|
|
|
|
var claimsIdentity = new ClaimsIdentity(claims);
|
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
|
|
|
_tokenInfraMock
|
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
|
.ReturnsAsync(principal);
|
|
|
|
// Act
|
|
var result =
|
|
await _tokenService.ValidateAccessTokenAsync(token);
|
|
|
|
// Assert
|
|
result.Should().NotBeNull();
|
|
result.UserId.Should().Be(userId);
|
|
result.Username.Should().Be(username);
|
|
result.Principal.Should().NotBeNull();
|
|
result.Principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value.Should().Be(userId.ToString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateRefreshTokenAsync_WithValidToken_ReturnsValidatedToken()
|
|
{
|
|
// Arrange
|
|
var userId = Guid.NewGuid();
|
|
const string username = "testuser";
|
|
const string token = "valid-refresh-token";
|
|
|
|
var claims = new List<Claim>
|
|
{
|
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
|
};
|
|
|
|
var claimsIdentity = new ClaimsIdentity(claims);
|
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
|
|
|
_tokenInfraMock
|
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
|
.ReturnsAsync(principal);
|
|
|
|
// Act
|
|
var result =
|
|
await _tokenService.ValidateRefreshTokenAsync(token);
|
|
|
|
// Assert
|
|
result.Should().NotBeNull();
|
|
result.UserId.Should().Be(userId);
|
|
result.Username.Should().Be(username);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateConfirmationTokenAsync_WithValidToken_ReturnsValidatedToken()
|
|
{
|
|
// Arrange
|
|
var userId = Guid.NewGuid();
|
|
const string username = "testuser";
|
|
const string token = "valid-confirmation-token";
|
|
|
|
var claims = new List<Claim>
|
|
{
|
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
|
};
|
|
|
|
var claimsIdentity = new ClaimsIdentity(claims);
|
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
|
|
|
_tokenInfraMock
|
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
|
.ReturnsAsync(principal);
|
|
|
|
// Act
|
|
var result =
|
|
await _tokenService.ValidateConfirmationTokenAsync(token);
|
|
|
|
// Assert
|
|
result.Should().NotBeNull();
|
|
result.UserId.Should().Be(userId);
|
|
result.Username.Should().Be(username);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAccessTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
|
{
|
|
// Arrange
|
|
const string token = "invalid-token";
|
|
|
|
_tokenInfraMock
|
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
|
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
|
|
|
// Act & Assert
|
|
await FluentActions.Invoking(async () =>
|
|
await _tokenService.ValidateAccessTokenAsync(token)
|
|
).Should().ThrowAsync<UnauthorizedException>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAccessTokenAsync_WithExpiredToken_ThrowsUnauthorizedException()
|
|
{
|
|
// Arrange
|
|
const string token = "expired-token";
|
|
|
|
_tokenInfraMock
|
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
|
.ThrowsAsync(new UnauthorizedException(
|
|
"Token has expired"
|
|
));
|
|
|
|
// Act & Assert
|
|
await FluentActions.Invoking(async () =>
|
|
await _tokenService.ValidateAccessTokenAsync(token)
|
|
).Should().ThrowAsync<UnauthorizedException>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAccessTokenAsync_WithMissingUserIdClaim_ThrowsUnauthorizedException()
|
|
{
|
|
// Arrange
|
|
const string username = "testuser";
|
|
const string token = "token-without-user-id";
|
|
|
|
// Claims without Sub (user ID)
|
|
var claims = new List<Claim>
|
|
{
|
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
|
};
|
|
|
|
var claimsIdentity = new ClaimsIdentity(claims);
|
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
|
|
|
_tokenInfraMock
|
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
|
.ReturnsAsync(principal);
|
|
|
|
// Act & Assert
|
|
await FluentActions.Invoking(async () =>
|
|
await _tokenService.ValidateAccessTokenAsync(token)
|
|
).Should().ThrowAsync<UnauthorizedException>()
|
|
.WithMessage("*missing required claims*");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAccessTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException()
|
|
{
|
|
// Arrange
|
|
var userId = Guid.NewGuid();
|
|
const string token = "token-without-username";
|
|
|
|
// Claims without UniqueName (username)
|
|
var claims = new List<Claim>
|
|
{
|
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
|
};
|
|
|
|
var claimsIdentity = new ClaimsIdentity(claims);
|
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
|
|
|
_tokenInfraMock
|
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
|
.ReturnsAsync(principal);
|
|
|
|
// Act & Assert
|
|
await FluentActions.Invoking(async () =>
|
|
await _tokenService.ValidateAccessTokenAsync(token)
|
|
).Should().ThrowAsync<UnauthorizedException>()
|
|
.WithMessage("*missing required claims*");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAccessTokenAsync_WithMalformedUserId_ThrowsUnauthorizedException()
|
|
{
|
|
// Arrange
|
|
const string username = "testuser";
|
|
const string token = "token-with-malformed-user-id";
|
|
|
|
// Claims with invalid GUID format
|
|
var claims = new List<Claim>
|
|
{
|
|
new(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"),
|
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
|
};
|
|
|
|
var claimsIdentity = new ClaimsIdentity(claims);
|
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
|
|
|
_tokenInfraMock
|
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
|
.ReturnsAsync(principal);
|
|
|
|
// Act & Assert
|
|
await FluentActions.Invoking(async () =>
|
|
await _tokenService.ValidateAccessTokenAsync(token)
|
|
).Should().ThrowAsync<UnauthorizedException>()
|
|
.WithMessage("*malformed user ID*");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateRefreshTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
|
{
|
|
// Arrange
|
|
const string token = "invalid-refresh-token";
|
|
|
|
_tokenInfraMock
|
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
|
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
|
|
|
// Act & Assert
|
|
await FluentActions.Invoking(async () =>
|
|
await _tokenService.ValidateRefreshTokenAsync(token)
|
|
).Should().ThrowAsync<UnauthorizedException>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateConfirmationTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
|
{
|
|
// Arrange
|
|
const string token = "invalid-confirmation-token";
|
|
|
|
_tokenInfraMock
|
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
|
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
|
|
|
// Act & Assert
|
|
await FluentActions.Invoking(async () =>
|
|
await _tokenService.ValidateConfirmationTokenAsync(token)
|
|
).Should().ThrowAsync<UnauthorizedException>();
|
|
}
|
|
}
|