mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-06-01 01:54:00 +00:00
Move dotnet api into new directory
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.Repository.Auth;
|
||||
using Moq;
|
||||
using Service.Emails;
|
||||
|
||||
namespace Service.Auth.Tests;
|
||||
|
||||
public class ConfirmationServiceTest
|
||||
{
|
||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||
private readonly Mock<IEmailService> _emailServiceMock;
|
||||
private readonly ConfirmationService _confirmationService;
|
||||
|
||||
public ConfirmationServiceTest()
|
||||
{
|
||||
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||
_tokenServiceMock = new Mock<ITokenService>();
|
||||
_emailServiceMock = new Mock<IEmailService>();
|
||||
|
||||
_confirmationService = new ConfirmationService(
|
||||
_authRepositoryMock.Object,
|
||||
_tokenServiceMock.Object,
|
||||
_emailServiceMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfirmUserAsync_WithValidConfirmationToken_ConfirmsUser()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
const string username = "testuser";
|
||||
const string confirmationToken = "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);
|
||||
|
||||
var validatedToken = new ValidatedToken(userId, username, principal);
|
||||
var userAccount = new UserAccount
|
||||
{
|
||||
UserAccountId = userId,
|
||||
Username = username,
|
||||
FirstName = "Test",
|
||||
LastName = "User",
|
||||
Email = "test@example.com",
|
||||
DateOfBirth = new DateTime(1990, 1, 1),
|
||||
};
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
||||
.ReturnsAsync(validatedToken);
|
||||
|
||||
_authRepositoryMock
|
||||
.Setup(x => x.ConfirmUserAccountAsync(userId))
|
||||
.ReturnsAsync(userAccount);
|
||||
|
||||
// Act
|
||||
var result =
|
||||
await _confirmationService.ConfirmUserAsync(confirmationToken);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.UserId.Should().Be(userId);
|
||||
result.ConfirmedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
||||
|
||||
_tokenServiceMock.Verify(
|
||||
x => x.ValidateConfirmationTokenAsync(confirmationToken),
|
||||
Times.Once
|
||||
);
|
||||
|
||||
_authRepositoryMock.Verify(
|
||||
x => x.ConfirmUserAccountAsync(userId),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfirmUserAsync_WithInvalidConfirmationToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string invalidToken = "invalid-confirmation-token";
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(invalidToken))
|
||||
.ThrowsAsync(new UnauthorizedException(
|
||||
"Invalid confirmation token"
|
||||
));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _confirmationService.ConfirmUserAsync(invalidToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfirmUserAsync_WithExpiredConfirmationToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string expiredToken = "expired-confirmation-token";
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(expiredToken))
|
||||
.ThrowsAsync(new UnauthorizedException(
|
||||
"Confirmation token has expired"
|
||||
));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _confirmationService.ConfirmUserAsync(expiredToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfirmUserAsync_WithNonExistentUser_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
const string username = "nonexistent";
|
||||
const string confirmationToken = "valid-token-for-nonexistent-user";
|
||||
|
||||
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);
|
||||
|
||||
var validatedToken = new ValidatedToken(userId, username, principal);
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
||||
.ReturnsAsync(validatedToken);
|
||||
|
||||
_authRepositoryMock
|
||||
.Setup(x => x.ConfirmUserAccountAsync(userId))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _confirmationService.ConfirmUserAsync(confirmationToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*User account not found*");
|
||||
}
|
||||
}
|
||||
21
web/backend/Service/Service.Auth.Tests/Dockerfile
Normal file
21
web/backend/Service/Service.Auth.Tests/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
|
||||
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
|
||||
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
||||
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
||||
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
|
||||
COPY ["Service/Service.Auth/Service.Auth.csproj", "Service/Service.Auth/"]
|
||||
COPY ["Service/Service.Auth.Tests/Service.Auth.Tests.csproj", "Service/Service.Auth.Tests/"]
|
||||
RUN dotnet restore "Service/Service.Auth.Tests/Service.Auth.Tests.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/Service/Service.Auth.Tests"
|
||||
RUN dotnet build "./Service.Auth.Tests.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS final
|
||||
RUN mkdir -p /app/test-results/service-auth-tests
|
||||
WORKDIR /src/Service/Service.Auth.Tests
|
||||
ENTRYPOINT ["dotnet", "test", "./Service.Auth.Tests.csproj", "-c", "Release", "--logger", "trx;LogFileName=/app/test-results/service-auth-tests/results.trx"]
|
||||
254
web/backend/Service/Service.Auth.Tests/LoginService.test.cs
Normal file
254
web/backend/Service/Service.Auth.Tests/LoginService.test.cs
Normal file
@@ -0,0 +1,254 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.PasswordHashing;
|
||||
using Infrastructure.Repository.Auth;
|
||||
using Moq;
|
||||
|
||||
namespace Service.Auth.Tests;
|
||||
|
||||
public class LoginServiceTest
|
||||
{
|
||||
private readonly Mock<IAuthRepository> _authRepoMock;
|
||||
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
|
||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||
private readonly LoginService _loginService;
|
||||
|
||||
public LoginServiceTest()
|
||||
{
|
||||
_authRepoMock = new Mock<IAuthRepository>();
|
||||
_passwordInfraMock = new Mock<IPasswordInfrastructure>();
|
||||
_tokenServiceMock = new Mock<ITokenService>();
|
||||
_loginService = new LoginService(
|
||||
_authRepoMock.Object,
|
||||
_passwordInfraMock.Object,
|
||||
_tokenServiceMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
// Happy path: login returns the user account with the same username -- successful login
|
||||
[Fact]
|
||||
public async Task LoginAsync_WithValidData_ReturnsUserAccountWithMatchingUsername()
|
||||
{
|
||||
// Arrange
|
||||
const string username = "CogitoErgoSum";
|
||||
var userAccountId = Guid.NewGuid();
|
||||
|
||||
UserAccount userAccount = new()
|
||||
{
|
||||
UserAccountId = userAccountId,
|
||||
Username = username,
|
||||
FirstName = "René",
|
||||
LastName = "Descartes",
|
||||
Email = "r.descartes@example.com",
|
||||
DateOfBirth = new DateTime(1596, 03, 31),
|
||||
};
|
||||
|
||||
UserCredential userCredential = new()
|
||||
{
|
||||
UserCredentialId = Guid.NewGuid(),
|
||||
UserAccountId = userAccountId,
|
||||
Hash = "some-hash",
|
||||
Expiry = DateTime.MaxValue,
|
||||
};
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(username))
|
||||
.ReturnsAsync(userAccount);
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x =>
|
||||
x.GetActiveCredentialByUserAccountIdAsync(userAccountId)
|
||||
)
|
||||
.ReturnsAsync(userCredential);
|
||||
|
||||
_passwordInfraMock
|
||||
.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns(true);
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
|
||||
.Returns("access-token");
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
|
||||
.Returns("refresh-token");
|
||||
|
||||
// Act
|
||||
var result = await _loginService.LoginAsync(
|
||||
username,
|
||||
It.IsAny<string>()
|
||||
);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.UserAccount.UserAccountId.Should().Be(userAccountId);
|
||||
result.UserAccount.Username.Should().Be(username);
|
||||
result.AccessToken.Should().Be("access-token");
|
||||
result.RefreshToken.Should().Be("refresh-token");
|
||||
|
||||
_authRepoMock.Verify(
|
||||
x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId),
|
||||
Times.Once
|
||||
);
|
||||
|
||||
_authRepoMock.Verify(
|
||||
x => x.GetUserByUsernameAsync(username),
|
||||
Times.Once
|
||||
);
|
||||
|
||||
_passwordInfraMock.Verify(
|
||||
x => x.Verify(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoginAsync_WithUnregisteredUsername_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string username = "de_beauvoir";
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(username))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
|
||||
// Act
|
||||
var act = async () =>
|
||||
await _loginService.LoginAsync(username, It.IsAny<string>());
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<UnauthorizedException>();
|
||||
|
||||
_authRepoMock.Verify(
|
||||
x => x.GetUserByUsernameAsync(username),
|
||||
Times.Once
|
||||
);
|
||||
|
||||
_authRepoMock.Verify(
|
||||
x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny<Guid>()),
|
||||
Times.Never
|
||||
);
|
||||
|
||||
_passwordInfraMock.Verify(
|
||||
x => x.Verify(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoginAsync_WithNoActiveCredential_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string username = "BRussell";
|
||||
var userAccountId = Guid.NewGuid();
|
||||
|
||||
UserAccount userAccount = new()
|
||||
{
|
||||
UserAccountId = userAccountId,
|
||||
Username = username,
|
||||
FirstName = "Bertrand",
|
||||
LastName = "Russell",
|
||||
Email = "b.russell@example.co.uk",
|
||||
DateOfBirth = new DateTime(1872, 05, 18),
|
||||
};
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(username))
|
||||
.ReturnsAsync(userAccount);
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x =>
|
||||
x.GetActiveCredentialByUserAccountIdAsync(userAccountId)
|
||||
)
|
||||
.ReturnsAsync((UserCredential?)null);
|
||||
|
||||
// Act
|
||||
var act = async () =>
|
||||
await _loginService.LoginAsync(username, It.IsAny<string>());
|
||||
|
||||
// Assert
|
||||
await act.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("Invalid username or password.");
|
||||
|
||||
_authRepoMock.Verify(
|
||||
x => x.GetUserByUsernameAsync(username),
|
||||
Times.Once
|
||||
);
|
||||
|
||||
_authRepoMock.Verify(
|
||||
x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId),
|
||||
Times.Once
|
||||
);
|
||||
|
||||
_passwordInfraMock.Verify(
|
||||
x => x.Verify(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoginAsync_WithIncorrectPassword_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string username = "RCarnap";
|
||||
var userAccountId = Guid.NewGuid();
|
||||
|
||||
UserAccount userAccount = new()
|
||||
{
|
||||
UserAccountId = userAccountId,
|
||||
Username = username,
|
||||
FirstName = "Rudolf",
|
||||
LastName = "Carnap",
|
||||
Email = "r.carnap@example.de",
|
||||
DateOfBirth = new DateTime(1891, 05, 18),
|
||||
};
|
||||
|
||||
UserCredential userCredential = new()
|
||||
{
|
||||
UserCredentialId = Guid.NewGuid(),
|
||||
UserAccountId = userAccountId,
|
||||
Hash = "hashed-password",
|
||||
Expiry = DateTime.MaxValue,
|
||||
};
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(username))
|
||||
.ReturnsAsync(userAccount);
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x =>
|
||||
x.GetActiveCredentialByUserAccountIdAsync(userAccountId)
|
||||
)
|
||||
.ReturnsAsync(userCredential);
|
||||
|
||||
_passwordInfraMock
|
||||
.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
var act = async () =>
|
||||
await _loginService.LoginAsync(username, It.IsAny<string>());
|
||||
|
||||
// Assert
|
||||
await act.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("Invalid username or password.");
|
||||
|
||||
_authRepoMock.Verify(
|
||||
x => x.GetUserByUsernameAsync(username),
|
||||
Times.Once
|
||||
);
|
||||
|
||||
_authRepoMock.Verify(
|
||||
x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId),
|
||||
Times.Once
|
||||
);
|
||||
|
||||
_passwordInfraMock.Verify(
|
||||
x => x.Verify(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
}
|
||||
322
web/backend/Service/Service.Auth.Tests/RegisterService.test.cs
Normal file
322
web/backend/Service/Service.Auth.Tests/RegisterService.test.cs
Normal file
@@ -0,0 +1,322 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.PasswordHashing;
|
||||
using Infrastructure.Repository.Auth;
|
||||
using Moq;
|
||||
using Service.Emails;
|
||||
|
||||
namespace Service.Auth.Tests;
|
||||
|
||||
public class RegisterServiceTest
|
||||
{
|
||||
private readonly Mock<IAuthRepository> _authRepoMock;
|
||||
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
|
||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||
private readonly Mock<IEmailService> _emailServiceMock; // todo handle email related test cases here
|
||||
private readonly RegisterService _registerService;
|
||||
|
||||
public RegisterServiceTest()
|
||||
{
|
||||
_authRepoMock = new Mock<IAuthRepository>();
|
||||
_passwordInfraMock = new Mock<IPasswordInfrastructure>();
|
||||
_tokenServiceMock = new Mock<ITokenService>();
|
||||
_emailServiceMock = new Mock<IEmailService>();
|
||||
|
||||
_registerService = new RegisterService(
|
||||
_authRepoMock.Object,
|
||||
_passwordInfraMock.Object,
|
||||
_tokenServiceMock.Object,
|
||||
_emailServiceMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterAsync_WithValidData_CreatesUserAndReturnsAuthServiceReturn()
|
||||
{
|
||||
// Arrange
|
||||
var userAccount = new UserAccount
|
||||
{
|
||||
Username = "newuser",
|
||||
FirstName = "John",
|
||||
LastName = "Doe",
|
||||
Email = "john.doe@example.com",
|
||||
DateOfBirth = new DateTime(1990, 1, 1),
|
||||
};
|
||||
|
||||
const string password = "SecurePassword123!";
|
||||
const string hashedPassword = "hashed_password_value";
|
||||
var expectedUserId = Guid.NewGuid();
|
||||
|
||||
// Mock: No existing user
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(userAccount.Username))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByEmailAsync(userAccount.Email))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
|
||||
// Mock: Password hashing
|
||||
_passwordInfraMock
|
||||
.Setup(x => x.Hash(password))
|
||||
.Returns(hashedPassword);
|
||||
|
||||
// Mock: User registration
|
||||
_authRepoMock
|
||||
.Setup(x =>
|
||||
x.RegisterUserAsync(
|
||||
userAccount.Username,
|
||||
userAccount.FirstName,
|
||||
userAccount.LastName,
|
||||
userAccount.Email,
|
||||
userAccount.DateOfBirth,
|
||||
hashedPassword
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(
|
||||
new UserAccount
|
||||
{
|
||||
UserAccountId = expectedUserId,
|
||||
Username = userAccount.Username,
|
||||
FirstName = userAccount.FirstName,
|
||||
LastName = userAccount.LastName,
|
||||
Email = userAccount.Email,
|
||||
DateOfBirth = userAccount.DateOfBirth,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
}
|
||||
);
|
||||
|
||||
// Mock: Token generation
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
|
||||
.Returns("access-token");
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
|
||||
.Returns("refresh-token");
|
||||
|
||||
// Act
|
||||
var result = await _registerService.RegisterAsync(
|
||||
userAccount,
|
||||
password
|
||||
);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.UserAccount.UserAccountId.Should().Be(expectedUserId);
|
||||
result.UserAccount.Username.Should().Be(userAccount.Username);
|
||||
result.UserAccount.Email.Should().Be(userAccount.Email);
|
||||
result.AccessToken.Should().Be("access-token");
|
||||
result.RefreshToken.Should().Be("refresh-token");
|
||||
|
||||
// Verify all mocks were called as expected
|
||||
_authRepoMock.Verify(
|
||||
x => x.GetUserByUsernameAsync(userAccount.Username),
|
||||
Times.Once
|
||||
);
|
||||
_authRepoMock.Verify(
|
||||
x => x.GetUserByEmailAsync(userAccount.Email),
|
||||
Times.Once
|
||||
);
|
||||
_passwordInfraMock.Verify(x => x.Hash(password), Times.Once);
|
||||
_authRepoMock.Verify(
|
||||
x =>
|
||||
x.RegisterUserAsync(
|
||||
userAccount.Username,
|
||||
userAccount.FirstName,
|
||||
userAccount.LastName,
|
||||
userAccount.Email,
|
||||
userAccount.DateOfBirth,
|
||||
hashedPassword
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
_emailServiceMock.Verify(
|
||||
x =>
|
||||
x.SendRegistrationEmailAsync(
|
||||
It.IsAny<UserAccount>(),
|
||||
It.IsAny<string>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterAsync_WithExistingUsername_ThrowsConflictException()
|
||||
{
|
||||
// Arrange
|
||||
var userAccount = new UserAccount
|
||||
{
|
||||
Username = "existinguser",
|
||||
FirstName = "Jane",
|
||||
LastName = "Smith",
|
||||
Email = "jane.smith@example.com",
|
||||
DateOfBirth = new DateTime(1995, 5, 15),
|
||||
};
|
||||
var password = "Password123!";
|
||||
|
||||
var existingUser = new UserAccount
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = "existinguser",
|
||||
FirstName = "Existing",
|
||||
LastName = "User",
|
||||
Email = "existing@example.com",
|
||||
DateOfBirth = new DateTime(1990, 1, 1),
|
||||
};
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(userAccount.Username))
|
||||
.ReturnsAsync(existingUser);
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByEmailAsync(userAccount.Email))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
|
||||
// Act
|
||||
var act = async () =>
|
||||
await _registerService.RegisterAsync(userAccount, password);
|
||||
|
||||
// Assert
|
||||
await act.Should()
|
||||
.ThrowAsync<ConflictException>()
|
||||
.WithMessage("Username or email already exists");
|
||||
|
||||
// Verify that registration was never called
|
||||
_authRepoMock.Verify(
|
||||
x =>
|
||||
x.RegisterUserAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<DateTime>(),
|
||||
It.IsAny<string>()
|
||||
),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterAsync_WithExistingEmail_ThrowsConflictException()
|
||||
{
|
||||
// Arrange
|
||||
var userAccount = new UserAccount
|
||||
{
|
||||
Username = "newuser",
|
||||
FirstName = "Jane",
|
||||
LastName = "Smith",
|
||||
Email = "existing@example.com",
|
||||
DateOfBirth = new DateTime(1995, 5, 15),
|
||||
};
|
||||
var password = "Password123!";
|
||||
|
||||
var existingUser = new UserAccount
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = "otheruser",
|
||||
FirstName = "Existing",
|
||||
LastName = "User",
|
||||
Email = "existing@example.com",
|
||||
DateOfBirth = new DateTime(1990, 1, 1),
|
||||
};
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(userAccount.Username))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByEmailAsync(userAccount.Email))
|
||||
.ReturnsAsync(existingUser);
|
||||
|
||||
// Act
|
||||
var act = async () =>
|
||||
await _registerService.RegisterAsync(userAccount, password);
|
||||
|
||||
// Assert
|
||||
await act.Should()
|
||||
.ThrowAsync<ConflictException>()
|
||||
.WithMessage("Username or email already exists");
|
||||
|
||||
_authRepoMock.Verify(
|
||||
x =>
|
||||
x.RegisterUserAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<DateTime>(),
|
||||
It.IsAny<string>()
|
||||
),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterAsync_PasswordIsHashed_BeforeStoringInDatabase()
|
||||
{
|
||||
// Arrange
|
||||
var userAccount = new UserAccount
|
||||
{
|
||||
Username = "secureuser",
|
||||
FirstName = "Secure",
|
||||
LastName = "User",
|
||||
Email = "secure@example.com",
|
||||
DateOfBirth = new DateTime(1990, 1, 1),
|
||||
};
|
||||
var plainPassword = "PlainPassword123!";
|
||||
var hashedPassword = "hashed_secure_password";
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
|
||||
_passwordInfraMock
|
||||
.Setup(x => x.Hash(plainPassword))
|
||||
.Returns(hashedPassword);
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x =>
|
||||
x.RegisterUserAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<DateTime>(),
|
||||
hashedPassword
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
|
||||
.Returns("access-token");
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
|
||||
.Returns("refresh-token");
|
||||
|
||||
// Act
|
||||
await _registerService.RegisterAsync(userAccount, plainPassword);
|
||||
|
||||
// Assert
|
||||
_passwordInfraMock.Verify(x => x.Hash(plainPassword), Times.Once);
|
||||
_authRepoMock.Verify(
|
||||
x =>
|
||||
x.RegisterUserAsync(
|
||||
userAccount.Username,
|
||||
userAccount.FirstName,
|
||||
userAccount.LastName,
|
||||
userAccount.Email,
|
||||
userAccount.DateOfBirth,
|
||||
hashedPassword
|
||||
), // Verify hashed password is used
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>Service.Auth.Tests</RootNamespace>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference Include="..\Service.Auth\Service.Auth.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.Jwt;
|
||||
using Infrastructure.Repository.Auth;
|
||||
using Moq;
|
||||
|
||||
namespace Service.Auth.Tests;
|
||||
|
||||
public class TokenServiceRefreshTest
|
||||
{
|
||||
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||
private readonly TokenService _tokenService;
|
||||
|
||||
public TokenServiceRefreshTest()
|
||||
{
|
||||
_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 RefreshTokenAsync_WithValidRefreshToken_ReturnsNewTokens()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
const string username = "testuser";
|
||||
const string refreshToken = "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);
|
||||
|
||||
var userAccount = new UserAccount
|
||||
{
|
||||
UserAccountId = userId,
|
||||
Username = username,
|
||||
FirstName = "Test",
|
||||
LastName = "User",
|
||||
Email = "test@example.com",
|
||||
DateOfBirth = new DateTime(1990, 1, 1),
|
||||
};
|
||||
|
||||
// Mock the validation of refresh token
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
// Mock the generation of new tokens
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.GenerateJwt(userId, username, It.IsAny<DateTime>(), It.IsAny<string>()))
|
||||
.Returns((Guid _, string _, DateTime _, string _) => $"generated-token-{Guid.NewGuid()}");
|
||||
|
||||
_authRepositoryMock
|
||||
.Setup(x => x.GetUserByIdAsync(userId))
|
||||
.ReturnsAsync(userAccount);
|
||||
|
||||
// Act
|
||||
var result = await _tokenService.RefreshTokenAsync(refreshToken);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.UserAccount.UserAccountId.Should().Be(userId);
|
||||
result.UserAccount.Username.Should().Be(username);
|
||||
result.AccessToken.Should().NotBeEmpty();
|
||||
result.RefreshToken.Should().NotBeEmpty();
|
||||
|
||||
_authRepositoryMock.Verify(
|
||||
x => x.GetUserByIdAsync(userId),
|
||||
Times.Once
|
||||
);
|
||||
|
||||
// Verify tokens were generated (called twice - once for access, once for refresh)
|
||||
_tokenInfraMock.Verify(
|
||||
x => x.GenerateJwt(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()),
|
||||
Times.Exactly(2)
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshTokenAsync_WithInvalidRefreshToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string invalidToken = "invalid-refresh-token";
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(invalidToken, It.IsAny<string>()))
|
||||
.ThrowsAsync(new UnauthorizedException("Invalid refresh token"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.RefreshTokenAsync(invalidToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshTokenAsync_WithExpiredRefreshToken_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
const string expiredToken = "expired-refresh-token";
|
||||
|
||||
_tokenInfraMock
|
||||
.Setup(x => x.ValidateJwtAsync(expiredToken, It.IsAny<string>()))
|
||||
.ThrowsAsync(new UnauthorizedException("Refresh token has expired"));
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.RefreshTokenAsync(expiredToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshTokenAsync_WithNonExistentUser_ThrowsUnauthorizedException()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
const string username = "testuser";
|
||||
const string refreshToken = "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(refreshToken, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
_authRepositoryMock
|
||||
.Setup(x => x.GetUserByIdAsync(userId))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions.Invoking(async () =>
|
||||
await _tokenService.RefreshTokenAsync(refreshToken)
|
||||
).Should().ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("*User account not found*");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.Jwt;
|
||||
using Infrastructure.Repository.Auth;
|
||||
using Moq;
|
||||
|
||||
namespace Service.Auth.Tests;
|
||||
|
||||
public class TokenServiceValidationTest
|
||||
{
|
||||
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||
private readonly TokenService _tokenService;
|
||||
|
||||
public TokenServiceValidationTest()
|
||||
{
|
||||
_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>();
|
||||
}
|
||||
}
|
||||
53
web/backend/Service/Service.Auth/ConfirmationService.cs
Normal file
53
web/backend/Service/Service.Auth/ConfirmationService.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
|
||||
using Domain.Exceptions;
|
||||
using Infrastructure.Repository.Auth;
|
||||
using Service.Emails;
|
||||
|
||||
namespace Service.Auth;
|
||||
|
||||
public class ConfirmationService(
|
||||
IAuthRepository authRepository,
|
||||
ITokenService tokenService,
|
||||
IEmailService emailService
|
||||
) : IConfirmationService
|
||||
{
|
||||
public async Task<ConfirmationServiceReturn> ConfirmUserAsync(
|
||||
string confirmationToken
|
||||
)
|
||||
{
|
||||
var validatedToken = await tokenService.ValidateConfirmationTokenAsync(
|
||||
confirmationToken
|
||||
);
|
||||
|
||||
var user = await authRepository.ConfirmUserAccountAsync(
|
||||
validatedToken.UserId
|
||||
);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
throw new UnauthorizedException("User account not found");
|
||||
}
|
||||
|
||||
return new ConfirmationServiceReturn(
|
||||
DateTime.UtcNow,
|
||||
user.UserAccountId
|
||||
);
|
||||
}
|
||||
|
||||
public async Task ResendConfirmationEmailAsync(Guid userId)
|
||||
{
|
||||
var user = await authRepository.GetUserByIdAsync(userId);
|
||||
if (user == null)
|
||||
{
|
||||
return; // Silent return to prevent user enumeration
|
||||
}
|
||||
|
||||
if (await authRepository.IsUserVerifiedAsync(userId))
|
||||
{
|
||||
return; // Already confirmed, no-op
|
||||
}
|
||||
|
||||
var confirmationToken = tokenService.GenerateConfirmationToken(user);
|
||||
await emailService.SendResendConfirmationEmailAsync(user, confirmationToken);
|
||||
}
|
||||
}
|
||||
13
web/backend/Service/Service.Auth/IConfirmationService.cs
Normal file
13
web/backend/Service/Service.Auth/IConfirmationService.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using Domain.Exceptions;
|
||||
using Infrastructure.Repository.Auth;
|
||||
|
||||
namespace Service.Auth;
|
||||
|
||||
public record ConfirmationServiceReturn(DateTime ConfirmedAt, Guid UserId);
|
||||
|
||||
public interface IConfirmationService
|
||||
{
|
||||
Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken);
|
||||
Task ResendConfirmationEmailAsync(Guid userId);
|
||||
|
||||
}
|
||||
13
web/backend/Service/Service.Auth/ILoginService.cs
Normal file
13
web/backend/Service/Service.Auth/ILoginService.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Service.Auth;
|
||||
|
||||
public record LoginServiceReturn(
|
||||
UserAccount UserAccount,
|
||||
string RefreshToken,
|
||||
string AccessToken
|
||||
);
|
||||
public interface ILoginService
|
||||
{
|
||||
Task<LoginServiceReturn> LoginAsync(string username, string password);
|
||||
}
|
||||
39
web/backend/Service/Service.Auth/IRegisterService.cs
Normal file
39
web/backend/Service/Service.Auth/IRegisterService.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Service.Auth;
|
||||
|
||||
public record RegisterServiceReturn
|
||||
{
|
||||
public bool IsAuthenticated { get; init; } = false;
|
||||
public bool EmailSent { get; init; } = false;
|
||||
public UserAccount UserAccount { get; init; }
|
||||
public string AccessToken { get; init; } = string.Empty;
|
||||
public string RefreshToken { get; init; } = string.Empty;
|
||||
|
||||
public RegisterServiceReturn(
|
||||
UserAccount userAccount,
|
||||
string accessToken,
|
||||
string refreshToken,
|
||||
bool emailSent
|
||||
)
|
||||
{
|
||||
IsAuthenticated = true;
|
||||
EmailSent = emailSent;
|
||||
UserAccount = userAccount;
|
||||
AccessToken = accessToken;
|
||||
RefreshToken = refreshToken;
|
||||
}
|
||||
|
||||
public RegisterServiceReturn(UserAccount userAccount)
|
||||
{
|
||||
UserAccount = userAccount;
|
||||
}
|
||||
}
|
||||
|
||||
public interface IRegisterService
|
||||
{
|
||||
Task<RegisterServiceReturn> RegisterAsync(
|
||||
UserAccount userAccount,
|
||||
string password
|
||||
);
|
||||
}
|
||||
156
web/backend/Service/Service.Auth/ITokenService.cs
Normal file
156
web/backend/Service/Service.Auth/ITokenService.cs
Normal file
@@ -0,0 +1,156 @@
|
||||
using System.Security.Claims;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Infrastructure.Jwt;
|
||||
using Infrastructure.Repository.Auth;
|
||||
|
||||
namespace Service.Auth;
|
||||
|
||||
public enum TokenType
|
||||
{
|
||||
AccessToken,
|
||||
RefreshToken,
|
||||
ConfirmationToken,
|
||||
}
|
||||
|
||||
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
|
||||
|
||||
public record RefreshTokenResult(
|
||||
UserAccount UserAccount,
|
||||
string RefreshToken,
|
||||
string AccessToken
|
||||
);
|
||||
|
||||
public static class TokenServiceExpirationHours
|
||||
{
|
||||
public const double AccessTokenHours = 1;
|
||||
public const double RefreshTokenHours = 504; // 21 days
|
||||
public const double ConfirmationTokenHours = 0.5; // 30 minutes
|
||||
}
|
||||
|
||||
public interface ITokenService
|
||||
{
|
||||
string GenerateAccessToken(UserAccount user);
|
||||
string GenerateRefreshToken(UserAccount user);
|
||||
string GenerateConfirmationToken(UserAccount user);
|
||||
string GenerateToken<T>(UserAccount user) where T : struct, Enum;
|
||||
Task<ValidatedToken> ValidateAccessTokenAsync(string token);
|
||||
Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
|
||||
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
|
||||
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
|
||||
}
|
||||
|
||||
public class TokenService : ITokenService
|
||||
{
|
||||
private readonly ITokenInfrastructure _tokenInfrastructure;
|
||||
private readonly IAuthRepository _authRepository;
|
||||
|
||||
private readonly string _accessTokenSecret;
|
||||
private readonly string _refreshTokenSecret;
|
||||
private readonly string _confirmationTokenSecret;
|
||||
|
||||
public TokenService(
|
||||
ITokenInfrastructure tokenInfrastructure,
|
||||
IAuthRepository authRepository
|
||||
)
|
||||
{
|
||||
_tokenInfrastructure = tokenInfrastructure;
|
||||
_authRepository = authRepository;
|
||||
|
||||
_accessTokenSecret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
|
||||
?? throw new InvalidOperationException("ACCESS_TOKEN_SECRET environment variable is not set");
|
||||
|
||||
_refreshTokenSecret = Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
|
||||
?? throw new InvalidOperationException("REFRESH_TOKEN_SECRET environment variable is not set");
|
||||
|
||||
_confirmationTokenSecret = Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
|
||||
?? throw new InvalidOperationException("CONFIRMATION_TOKEN_SECRET environment variable is not set");
|
||||
}
|
||||
|
||||
public string GenerateAccessToken(UserAccount user)
|
||||
{
|
||||
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
||||
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _accessTokenSecret);
|
||||
}
|
||||
|
||||
public string GenerateRefreshToken(UserAccount user)
|
||||
{
|
||||
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours);
|
||||
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _refreshTokenSecret);
|
||||
}
|
||||
|
||||
public string GenerateConfirmationToken(UserAccount user)
|
||||
{
|
||||
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours);
|
||||
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _confirmationTokenSecret);
|
||||
}
|
||||
|
||||
public string GenerateToken<T>(UserAccount user) where T : struct, Enum
|
||||
{
|
||||
if (typeof(T) != typeof(TokenType))
|
||||
throw new InvalidOperationException("Invalid token type");
|
||||
|
||||
var tokenTypeName = typeof(T).Name;
|
||||
if (!Enum.TryParse(typeof(TokenType), tokenTypeName, out var parsed))
|
||||
throw new InvalidOperationException("Invalid token type");
|
||||
|
||||
var tokenType = (TokenType)parsed;
|
||||
return tokenType switch
|
||||
{
|
||||
TokenType.AccessToken => GenerateAccessToken(user),
|
||||
TokenType.RefreshToken => GenerateRefreshToken(user),
|
||||
TokenType.ConfirmationToken => GenerateConfirmationToken(user),
|
||||
_ => throw new InvalidOperationException("Invalid token type"),
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
|
||||
=> await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
|
||||
|
||||
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
|
||||
=> await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
||||
|
||||
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
|
||||
=> await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
||||
|
||||
private async Task<ValidatedToken> ValidateTokenInternalAsync(string token, string secret, string tokenType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var principal = await _tokenInfrastructure.ValidateJwtAsync(token, secret);
|
||||
|
||||
var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
||||
var usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(userIdClaim) || string.IsNullOrEmpty(usernameClaim))
|
||||
throw new UnauthorizedException($"Invalid {tokenType} token: missing required claims");
|
||||
|
||||
if (!Guid.TryParse(userIdClaim, out var userId))
|
||||
throw new UnauthorizedException($"Invalid {tokenType} token: malformed user ID");
|
||||
|
||||
return new ValidatedToken(userId, usernameClaim, principal);
|
||||
}
|
||||
catch (UnauthorizedException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
throw new UnauthorizedException($"Failed to validate {tokenType} token: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
||||
{
|
||||
var validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
||||
var user = await _authRepository.GetUserByIdAsync(validated.UserId);
|
||||
if (user == null)
|
||||
throw new UnauthorizedException("User account not found");
|
||||
|
||||
var newAccess = GenerateAccessToken(user);
|
||||
var newRefresh = GenerateRefreshToken(user);
|
||||
|
||||
return new RefreshTokenResult(user, newRefresh, newAccess);
|
||||
}
|
||||
}
|
||||
41
web/backend/Service/Service.Auth/LoginService.cs
Normal file
41
web/backend/Service/Service.Auth/LoginService.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Infrastructure.PasswordHashing;
|
||||
using Infrastructure.Repository.Auth;
|
||||
|
||||
namespace Service.Auth;
|
||||
|
||||
|
||||
public class LoginService(
|
||||
IAuthRepository authRepo,
|
||||
IPasswordInfrastructure passwordInfrastructure,
|
||||
ITokenService tokenService
|
||||
) : ILoginService
|
||||
{
|
||||
public async Task<LoginServiceReturn> LoginAsync(
|
||||
string username,
|
||||
string password
|
||||
)
|
||||
{
|
||||
// Attempt lookup by username
|
||||
// the user was not found
|
||||
var user =
|
||||
await authRepo.GetUserByUsernameAsync(username)
|
||||
?? throw new UnauthorizedException("Invalid username or password.");
|
||||
|
||||
// @todo handle expired passwords
|
||||
var activeCred =
|
||||
await authRepo.GetActiveCredentialByUserAccountIdAsync(
|
||||
user.UserAccountId
|
||||
)
|
||||
?? throw new UnauthorizedException("Invalid username or password.");
|
||||
|
||||
if (!passwordInfrastructure.Verify(password, activeCred.Hash))
|
||||
throw new UnauthorizedException("Invalid username or password.");
|
||||
|
||||
string accessToken = tokenService.GenerateAccessToken(user);
|
||||
string refreshToken = tokenService.GenerateRefreshToken(user);
|
||||
|
||||
return new LoginServiceReturn(user, refreshToken, accessToken);
|
||||
}
|
||||
}
|
||||
90
web/backend/Service/Service.Auth/RegisterService.cs
Normal file
90
web/backend/Service/Service.Auth/RegisterService.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Infrastructure.Email;
|
||||
using Infrastructure.Email.Templates.Rendering;
|
||||
using Infrastructure.PasswordHashing;
|
||||
using Infrastructure.Repository.Auth;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Service.Emails;
|
||||
|
||||
namespace Service.Auth;
|
||||
|
||||
public class RegisterService(
|
||||
IAuthRepository authRepo,
|
||||
IPasswordInfrastructure passwordInfrastructure,
|
||||
ITokenService tokenService,
|
||||
IEmailService emailService
|
||||
) : IRegisterService
|
||||
{
|
||||
private async Task ValidateUserDoesNotExist(UserAccount userAccount)
|
||||
{
|
||||
// Check if user already exists
|
||||
var existingUsername = await authRepo.GetUserByUsernameAsync(
|
||||
userAccount.Username
|
||||
);
|
||||
var existingEmail = await authRepo.GetUserByEmailAsync(
|
||||
userAccount.Email
|
||||
);
|
||||
|
||||
if (existingUsername != null || existingEmail != null)
|
||||
{
|
||||
throw new ConflictException("Username or email already exists");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RegisterServiceReturn> RegisterAsync(
|
||||
UserAccount userAccount,
|
||||
string password
|
||||
)
|
||||
{
|
||||
await ValidateUserDoesNotExist(userAccount);
|
||||
// password hashing
|
||||
var hashed = passwordInfrastructure.Hash(password);
|
||||
|
||||
// Register user with hashed password and get the created user with generated ID
|
||||
var createdUser = await authRepo.RegisterUserAsync(
|
||||
userAccount.Username,
|
||||
userAccount.FirstName,
|
||||
userAccount.LastName,
|
||||
userAccount.Email,
|
||||
userAccount.DateOfBirth,
|
||||
hashed
|
||||
);
|
||||
|
||||
var accessToken = tokenService.GenerateAccessToken(createdUser);
|
||||
var refreshToken = tokenService.GenerateRefreshToken(createdUser);
|
||||
var confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
|
||||
|
||||
if (
|
||||
string.IsNullOrEmpty(accessToken)
|
||||
|| string.IsNullOrEmpty(refreshToken)
|
||||
)
|
||||
{
|
||||
return new RegisterServiceReturn(createdUser);
|
||||
}
|
||||
|
||||
bool emailSent = false;
|
||||
try
|
||||
{
|
||||
// send confirmation email
|
||||
await emailService.SendRegistrationEmailAsync(
|
||||
createdUser, confirmationToken
|
||||
);
|
||||
|
||||
emailSent = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await Console.Error.WriteLineAsync(ex.Message);
|
||||
Console.WriteLine("Could not send email.");
|
||||
// ignored
|
||||
}
|
||||
|
||||
return new RegisterServiceReturn(
|
||||
createdUser,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
emailSent
|
||||
);
|
||||
}
|
||||
}
|
||||
22
web/backend/Service/Service.Auth/Service.Auth.csproj
Normal file
22
web/backend/Service/Service.Auth/Service.Auth.csproj
Normal file
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
|
||||
<ProjectReference Include="..\Service.Emails\Service.Emails.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,69 @@
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using Service.Breweries;
|
||||
using API.Core.Contracts.Breweries;
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Service.Breweries.Tests;
|
||||
|
||||
public class BreweryServiceTests
|
||||
{
|
||||
private class FakeRepo : IBreweryRepository
|
||||
{
|
||||
public BreweryPost? Created;
|
||||
|
||||
public Task<BreweryPost?> GetByIdAsync(Guid id) => Task.FromResult<BreweryPost?>(null);
|
||||
public Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset) => Task.FromResult<IEnumerable<BreweryPost>>(Array.Empty<BreweryPost>());
|
||||
public Task UpdateAsync(BreweryPost brewery) { Created = brewery; return Task.CompletedTask; }
|
||||
public Task DeleteAsync(Guid id) => Task.CompletedTask;
|
||||
public Task CreateAsync(BreweryPost brewery) { Created = brewery; return Task.CompletedTask; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_ReturnsFailure_WhenLocationMissing()
|
||||
{
|
||||
var repo = new FakeRepo();
|
||||
var svc = new BreweryService(repo);
|
||||
|
||||
var dto = new BreweryCreateDto
|
||||
{
|
||||
PostedById = Guid.NewGuid(),
|
||||
BreweryName = "X",
|
||||
Description = "Y",
|
||||
Location = null!
|
||||
};
|
||||
|
||||
var result = await svc.CreateAsync(dto);
|
||||
result.Success.Should().BeFalse();
|
||||
result.Message.Should().Contain("Location");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_ReturnsSuccess_AndPersistsEntity()
|
||||
{
|
||||
var repo = new FakeRepo();
|
||||
var svc = new BreweryService(repo);
|
||||
|
||||
var loc = new BreweryLocationCreateDto
|
||||
{
|
||||
CityId = Guid.NewGuid(),
|
||||
AddressLine1 = "123 Main",
|
||||
PostalCode = "12345"
|
||||
};
|
||||
|
||||
var dto = new BreweryCreateDto
|
||||
{
|
||||
PostedById = Guid.NewGuid(),
|
||||
BreweryName = "MyBrew",
|
||||
Description = "Desc",
|
||||
Location = loc
|
||||
};
|
||||
|
||||
var result = await svc.CreateAsync(dto);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
repo.Created.Should().NotBeNull();
|
||||
repo.Created!.BreweryName.Should().Be("MyBrew");
|
||||
result.Brewery.BreweryName.Should().Be("MyBrew");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>Service.Breweries.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Service.Breweries\Service.Breweries.csproj" />
|
||||
<ProjectReference Include="..\Service.Auth\Service.Auth.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
<ProjectReference Include="..\..\API\API.Core\API.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
65
web/backend/Service/Service.Breweries/BreweryService.cs
Normal file
65
web/backend/Service/Service.Breweries/BreweryService.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using Domain.Entities;
|
||||
using Infrastructure.Repository.Breweries;
|
||||
|
||||
namespace Service.Breweries;
|
||||
|
||||
public class BreweryService(IBreweryRepository repository) : IBreweryService
|
||||
{
|
||||
public Task<BreweryPost?> GetByIdAsync(Guid id) =>
|
||||
repository.GetByIdAsync(id);
|
||||
|
||||
public Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit = null, int? offset = null) =>
|
||||
repository.GetAllAsync(limit, offset);
|
||||
|
||||
public async Task<BreweryServiceReturn> CreateAsync(BreweryCreateRequest request)
|
||||
{
|
||||
var entity = new BreweryPost
|
||||
{
|
||||
BreweryPostId = Guid.NewGuid(),
|
||||
PostedById = request.PostedById,
|
||||
BreweryName = request.BreweryName,
|
||||
Description = request.Description,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Location = new BreweryPostLocation
|
||||
{
|
||||
BreweryPostLocationId = Guid.NewGuid(),
|
||||
CityId = request.Location.CityId,
|
||||
AddressLine1 = request.Location.AddressLine1,
|
||||
AddressLine2 = request.Location.AddressLine2,
|
||||
PostalCode = request.Location.PostalCode,
|
||||
Coordinates = request.Location.Coordinates,
|
||||
},
|
||||
};
|
||||
|
||||
await repository.CreateAsync(entity);
|
||||
return new BreweryServiceReturn(entity);
|
||||
}
|
||||
|
||||
public async Task<BreweryServiceReturn> UpdateAsync(BreweryUpdateRequest request)
|
||||
{
|
||||
var entity = new BreweryPost
|
||||
{
|
||||
BreweryPostId = request.BreweryPostId,
|
||||
PostedById = request.PostedById,
|
||||
BreweryName = request.BreweryName,
|
||||
Description = request.Description,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
Location = request.Location is null ? null : new BreweryPostLocation
|
||||
{
|
||||
BreweryPostLocationId = request.Location.BreweryPostLocationId,
|
||||
BreweryPostId = request.BreweryPostId,
|
||||
CityId = request.Location.CityId,
|
||||
AddressLine1 = request.Location.AddressLine1,
|
||||
AddressLine2 = request.Location.AddressLine2,
|
||||
PostalCode = request.Location.PostalCode,
|
||||
Coordinates = request.Location.Coordinates,
|
||||
},
|
||||
};
|
||||
|
||||
await repository.UpdateAsync(entity);
|
||||
return new BreweryServiceReturn(entity);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(Guid id) =>
|
||||
repository.DeleteAsync(id);
|
||||
}
|
||||
64
web/backend/Service/Service.Breweries/IBreweryService.cs
Normal file
64
web/backend/Service/Service.Breweries/IBreweryService.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Service.Breweries;
|
||||
|
||||
public record BreweryCreateRequest(
|
||||
Guid PostedById,
|
||||
string BreweryName,
|
||||
string Description,
|
||||
BreweryLocationCreateRequest Location
|
||||
);
|
||||
|
||||
public record BreweryLocationCreateRequest(
|
||||
Guid CityId,
|
||||
string AddressLine1,
|
||||
string? AddressLine2,
|
||||
string PostalCode,
|
||||
byte[]? Coordinates
|
||||
);
|
||||
|
||||
public record BreweryUpdateRequest(
|
||||
Guid BreweryPostId,
|
||||
Guid PostedById,
|
||||
string BreweryName,
|
||||
string Description,
|
||||
BreweryLocationUpdateRequest? Location
|
||||
);
|
||||
|
||||
public record BreweryLocationUpdateRequest(
|
||||
Guid BreweryPostLocationId,
|
||||
Guid CityId,
|
||||
string AddressLine1,
|
||||
string? AddressLine2,
|
||||
string PostalCode,
|
||||
byte[]? Coordinates
|
||||
);
|
||||
|
||||
public record BreweryServiceReturn
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public BreweryPost Brewery { get; init; }
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
public BreweryServiceReturn(BreweryPost brewery)
|
||||
{
|
||||
Success = true;
|
||||
Brewery = brewery;
|
||||
}
|
||||
|
||||
public BreweryServiceReturn(string message)
|
||||
{
|
||||
Success = false;
|
||||
Brewery = default!;
|
||||
Message = message;
|
||||
}
|
||||
}
|
||||
|
||||
public interface IBreweryService
|
||||
{
|
||||
Task<BreweryPost?> GetByIdAsync(Guid id);
|
||||
Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit = null, int? offset = null);
|
||||
Task<BreweryServiceReturn> CreateAsync(BreweryCreateRequest request);
|
||||
Task<BreweryServiceReturn> UpdateAsync(BreweryUpdateRequest request);
|
||||
Task DeleteAsync(Guid id);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
72
web/backend/Service/Service.Emails/EmailService.cs
Normal file
72
web/backend/Service/Service.Emails/EmailService.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using Domain.Entities;
|
||||
using Infrastructure.Email;
|
||||
using Infrastructure.Email.Templates.Rendering;
|
||||
|
||||
namespace Service.Emails;
|
||||
|
||||
public interface IEmailService
|
||||
{
|
||||
public Task SendRegistrationEmailAsync(
|
||||
UserAccount createdUser,
|
||||
string confirmationToken
|
||||
);
|
||||
|
||||
public Task SendResendConfirmationEmailAsync(
|
||||
UserAccount user,
|
||||
string confirmationToken
|
||||
);
|
||||
}
|
||||
|
||||
public class EmailService(
|
||||
IEmailProvider emailProvider,
|
||||
IEmailTemplateProvider emailTemplateProvider
|
||||
) : IEmailService
|
||||
{
|
||||
private static readonly string WebsiteBaseUrl =
|
||||
Environment.GetEnvironmentVariable("WEBSITE_BASE_URL")
|
||||
?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set");
|
||||
|
||||
public async Task SendRegistrationEmailAsync(
|
||||
UserAccount createdUser,
|
||||
string confirmationToken
|
||||
)
|
||||
{
|
||||
var confirmationLink =
|
||||
$"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
|
||||
|
||||
var emailHtml =
|
||||
await emailTemplateProvider.RenderUserRegisteredEmailAsync(
|
||||
createdUser.FirstName,
|
||||
confirmationLink
|
||||
);
|
||||
|
||||
await emailProvider.SendAsync(
|
||||
createdUser.Email,
|
||||
"Welcome to The Biergarten App!",
|
||||
emailHtml,
|
||||
isHtml: true
|
||||
);
|
||||
}
|
||||
|
||||
public async Task SendResendConfirmationEmailAsync(
|
||||
UserAccount user,
|
||||
string confirmationToken
|
||||
)
|
||||
{
|
||||
var confirmationLink =
|
||||
$"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
|
||||
|
||||
var emailHtml =
|
||||
await emailTemplateProvider.RenderResendConfirmationEmailAsync(
|
||||
user.FirstName,
|
||||
confirmationLink
|
||||
);
|
||||
|
||||
await emailProvider.SendAsync(
|
||||
user.Email,
|
||||
"Confirm Your Email - The Biergarten App",
|
||||
emailHtml,
|
||||
isHtml: true
|
||||
);
|
||||
}
|
||||
}
|
||||
15
web/backend/Service/Service.Emails/Service.Emails.csproj
Normal file
15
web/backend/Service/Service.Emails/Service.Emails.csproj
Normal file
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference
|
||||
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,14 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Service.UserManagement.User;
|
||||
|
||||
public interface IUserService
|
||||
{
|
||||
Task<IEnumerable<UserAccount>> GetAllAsync(
|
||||
int? limit = null,
|
||||
int? offset = null
|
||||
);
|
||||
Task<UserAccount> GetByIdAsync(Guid id);
|
||||
|
||||
Task UpdateAsync(UserAccount userAccount);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Infrastructure.Repository.UserAccount;
|
||||
|
||||
namespace Service.UserManagement.User;
|
||||
|
||||
public class UserService(IUserAccountRepository repository) : IUserService
|
||||
{
|
||||
public async Task<IEnumerable<UserAccount>> GetAllAsync(
|
||||
int? limit = null,
|
||||
int? offset = null
|
||||
)
|
||||
{
|
||||
return await repository.GetAllAsync(limit, offset);
|
||||
}
|
||||
|
||||
public async Task<UserAccount> GetByIdAsync(Guid id)
|
||||
{
|
||||
var user = await repository.GetByIdAsync(id);
|
||||
if (user is null)
|
||||
throw new NotFoundException($"User with ID {id} not found");
|
||||
return user;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(UserAccount userAccount)
|
||||
{
|
||||
await repository.UpdateAsync(userAccount);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user