diff --git a/Server/ReasnAPI/ReasnAPI.Tests/ReasnAPI.Tests.csproj b/Server/ReasnAPI/ReasnAPI.Tests/ReasnAPI.Tests.csproj index 1b46a3ed..852b4685 100644 --- a/Server/ReasnAPI/ReasnAPI.Tests/ReasnAPI.Tests.csproj +++ b/Server/ReasnAPI/ReasnAPI.Tests/ReasnAPI.Tests.csproj @@ -13,10 +13,15 @@ + + + + + diff --git a/Server/ReasnAPI/ReasnAPI.Tests/UnitTest1.cs b/Server/ReasnAPI/ReasnAPI.Tests/UnitTest1.cs deleted file mode 100644 index 46dac6f2..00000000 --- a/Server/ReasnAPI/ReasnAPI.Tests/UnitTest1.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace ReasnAPI.Tests -{ - [TestClass] - public class UnitTest1 - { - [TestMethod] - public void TestAdd() - { - Assert.AreEqual(17, 17); - } - } -} diff --git a/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Exceptions/ServiceExceptionHandlerTests.cs b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Exceptions/ServiceExceptionHandlerTests.cs new file mode 100644 index 00000000..15a71958 --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Exceptions/ServiceExceptionHandlerTests.cs @@ -0,0 +1,93 @@ +using System.Net; +using Microsoft.AspNetCore.Http; +using Moq; +using ReasnAPI.Exceptions; +using ReasnAPI.Services.Exceptions; + +namespace ReasnAPI.Tests.UnitTests.Exceptions; + +[TestClass] +public class ServiceExceptionHandlerTests +{ + private Mock _mockProblemDetailsService = null!; + private ServiceExceptionHandler _handler = null!; + + [TestInitialize] + public void Setup() + { + _mockProblemDetailsService = new Mock(); + _handler = new ServiceExceptionHandler(_mockProblemDetailsService.Object); + } + + [TestMethod] + public async Task HandleException_WhenBadRequestException_ShouldReturnProblemDetails() + { + var httpContext = new DefaultHttpContext(); + var exception = new BadRequestException("Bad request"); + + ProblemDetailsContext? problemDetailsContext = null; + _mockProblemDetailsService.Setup(x => + x.TryWriteAsync(It.IsAny())) + .Callback(context => problemDetailsContext = context) + .ReturnsAsync(true); + + await _handler.TryHandleAsync(httpContext, exception, CancellationToken.None); + + Assert.AreEqual((int)HttpStatusCode.BadRequest, httpContext.Response.StatusCode); + Assert.IsNotNull(problemDetailsContext); + Assert.AreEqual("https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1", + problemDetailsContext.ProblemDetails.Type); + Assert.AreEqual("A bad request was made", + problemDetailsContext.ProblemDetails.Title); + Assert.AreEqual(exception, problemDetailsContext.Exception); + Assert.AreEqual(exception.Message, problemDetailsContext.ProblemDetails.Detail); + } + + [TestMethod] + public async Task HandleException_WhenNotFoundException_ShouldReturnProblemDetails() + { + var httpContext = new DefaultHttpContext(); + var exception = new NotFoundException("Resource not found"); + + ProblemDetailsContext? problemDetailsContext = null; + _mockProblemDetailsService.Setup(x => + x.TryWriteAsync(It.IsAny())) + .Callback(context => problemDetailsContext = context) + .ReturnsAsync(true); + + await _handler.TryHandleAsync(httpContext, exception, CancellationToken.None); + + Assert.AreEqual((int)HttpStatusCode.NotFound, httpContext.Response.StatusCode); + Assert.IsNotNull(problemDetailsContext); + Assert.AreEqual("https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.4", + problemDetailsContext.ProblemDetails.Type); + Assert.AreEqual("A resource was not found", + problemDetailsContext.ProblemDetails.Title); + Assert.AreEqual(exception, problemDetailsContext.Exception); + Assert.AreEqual(exception.Message, problemDetailsContext.ProblemDetails.Detail); + } + + [TestMethod] + public async Task HandleException_WhenVerificationException_ShouldReturnProblemDetails() + { + var httpContext = new DefaultHttpContext(); + var exception = new VerificationException("Verification error"); + + ProblemDetailsContext? problemDetailsContext = null; + _mockProblemDetailsService.Setup(x => + x.TryWriteAsync(It.IsAny())) + .Callback(context => problemDetailsContext = context) + .ReturnsAsync(true); + + await _handler.TryHandleAsync(httpContext, exception, CancellationToken.None); + + Assert.AreEqual((int)HttpStatusCode.BadRequest, httpContext.Response.StatusCode); + Assert.IsNotNull(problemDetailsContext); + Assert.AreEqual("https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1", + problemDetailsContext.ProblemDetails.Type); + Assert.AreEqual("A verification error occurred", + problemDetailsContext.ProblemDetails.Title); + Assert.AreEqual(exception, problemDetailsContext.Exception); + Assert.AreEqual(exception.Message, problemDetailsContext.ProblemDetails.Detail); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Exceptions/ValidationExceptionHandlerTests.cs b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Exceptions/ValidationExceptionHandlerTests.cs new file mode 100644 index 00000000..9a087bb6 --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Exceptions/ValidationExceptionHandlerTests.cs @@ -0,0 +1,54 @@ +using System.Net; +using FluentValidation; +using FluentValidation.Results; +using Microsoft.AspNetCore.Http; +using Moq; +using ReasnAPI.Exceptions; + +namespace ReasnAPI.Tests.UnitTests.Exceptions; + +[TestClass] +public class ValidationExceptionHandlerTests +{ + private Mock _mockProblemDetailsService = null!; + private ValidationExceptionHandler _handler = null!; + + [TestInitialize] + public void Setup() + { + _mockProblemDetailsService = new Mock(); + _handler = new ValidationExceptionHandler(_mockProblemDetailsService.Object); + } + + [TestMethod] + public async Task HandleException_WhenValidationException_ShouldReturnProblemDetails() + { + var httpContext = new DefaultHttpContext(); + var exception = new ValidationException(new List + { + new ("Email", "'Email' must not be empty."), + new ("Password", "'Password' must not be empty.") + }); + + ProblemDetailsContext? problemDetailsContext = null; + _mockProblemDetailsService.Setup(x => + x.TryWriteAsync(It.IsAny())) + .Callback(context => problemDetailsContext = context) + .ReturnsAsync(true); + + await _handler.TryHandleAsync(httpContext, exception, CancellationToken.None); + + Assert.AreEqual((int)HttpStatusCode.BadRequest, httpContext.Response.StatusCode); + Assert.IsNotNull(problemDetailsContext); + Assert.AreEqual("https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1", + problemDetailsContext.ProblemDetails.Type); + Assert.AreEqual("A validation error occurred", + problemDetailsContext.ProblemDetails.Title); + Assert.AreEqual(exception, problemDetailsContext.Exception); + Assert.AreEqual("One or more validation errors occurred", + problemDetailsContext.ProblemDetails.Detail); + + Assert.IsNotNull(problemDetailsContext.ProblemDetails.Extensions); + Assert.IsTrue(problemDetailsContext.ProblemDetails.Extensions.ContainsKey("errors")); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Services/Authentication/AuthServiceTests.cs b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Services/Authentication/AuthServiceTests.cs new file mode 100644 index 00000000..631dbbde --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Services/Authentication/AuthServiceTests.cs @@ -0,0 +1,125 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.WebUtilities; +using Moq; +using Moq.EntityFrameworkCore; +using ReasnAPI.Models.Authentication; +using ReasnAPI.Models.Database; +using ReasnAPI.Models.DTOs; +using ReasnAPI.Services.Authentication; +using ReasnAPI.Services.Exceptions; + +namespace ReasnAPI.Tests.UnitTests.Services.Authentication; + +[TestClass] +public class AuthServiceTests +{ + private Mock _mockContext = null!; + private PasswordHasher _hasher = null!; + private AuthService _service = null!; + + [TestInitialize] + public void Setup() + { + _mockContext = new Mock(); + _hasher = new PasswordHasher(); + _service = new AuthService(_mockContext.Object); + + var user = new User + { + Email = "jon.snow@castleblack.com", + Username = "jsnow", + Password = _hasher.HashPassword(null!, "password") + }; + + _mockContext.Setup(c => c.Users) + .ReturnsDbSet(new List { user }); + } + + [TestMethod] + public void Login_WhenUserExistsAndPasswordIsCorrect_ShouldReturnUser() + { + var request = new LoginRequest + { + Email = "jon.snow@castleblack.com", + Password = "password" + }; + + var result = _service.Login(request); + + Assert.IsNotNull(result); + Assert.IsInstanceOfType(result, typeof(User)); + } + + [TestMethod] + public void Login_WhenUserDoesNotExist_ShouldThrowNotFoundException() + { + var request = new LoginRequest + { + Email = "jon.notsnow@castleblack.com" + }; + + Assert.ThrowsException(() => _service.Login(request)); + } + + [TestMethod] + public void Login_WhenPasswordIsIncorrect_ShouldThrowVerificationException() + { + var request = new LoginRequest + { + Email = "jon.snow@castleblack.com", + Password = "wrong-password" + }; + + Assert.ThrowsException(() => _service.Login(request)); + } + + [TestMethod] + public void Register_WhenUserWithEmailAlreadyExists_ShouldThrowBadRequestException() + { + var request = new RegisterRequest + { + Email = "jon.snow@castleblack.com" + }; + + Assert.ThrowsException(() => _service.Register(request)); + } + + [TestMethod] + public void Register_WhenUserWithUsernameAlreadyExists_ShouldThrowBadRequestException() + { + var request = new RegisterRequest + { + Email = "jon.stark@castleblack.com", + Username = "jsnow" + }; + + Assert.ThrowsException(() => _service.Register(request)); + } + + [TestMethod] + public void Register_WhenUserDoesNotExist_ShouldReturnRegisteredUser() + { + var request = new RegisterRequest + { + Name = "Jon", + Surname = "Stark", + Email = "jon.stark@castleblack.com", + Username = "jstark", + Password = "S3cureP@ssword!", + Phone = "+123 456789", + Address = new AddressDto + { + Street = "The Wall", + City = "Castle Black", + Country = "Westeros", + State = "The North" + }, + Role = "User" + }; + + var result = _service.Register(request); + + Assert.IsNotNull(result); + Assert.IsInstanceOfType(result, typeof(User)); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Services/Authentication/TokenServiceTests.cs b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Services/Authentication/TokenServiceTests.cs new file mode 100644 index 00000000..99e576fa --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Services/Authentication/TokenServiceTests.cs @@ -0,0 +1,83 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Cryptography; +using Microsoft.Extensions.Configuration; +using Moq; +using ReasnAPI.Models.Database; +using ReasnAPI.Models.Enums; +using ReasnAPI.Services.Authentication; + +namespace ReasnAPI.Tests.UnitTests.Services.Authentication; + +[TestClass] +public class TokenServiceTests +{ + private const int DurationInHours = 8; + private const string IssAudValue = "http://localhost:5272"; + private TokenService _service = null!; + private Mock _mockConfiguration = null!; + private User _validUser = null!; + + [TestInitialize] + public void Setup() + { + _mockConfiguration = new Mock(); + + var bytes = new byte[32]; + RandomNumberGenerator.Fill(bytes); + _mockConfiguration.Setup(x => + x["JwtSettings:Key"]).Returns(Convert.ToBase64String(bytes)); + + _mockConfiguration.Setup(x => + x["JwtSettings:Issuer"]).Returns(IssAudValue); + + var mockSection = new Mock(); + var mockAudienceValue = new Mock(); + mockAudienceValue.Setup(x => x.Value).Returns(IssAudValue); + mockSection.Setup(x => + x.GetChildren()).Returns(new List { mockAudienceValue.Object }); + _mockConfiguration.Setup(x => + x.GetSection("JwtSettings:Audiences")).Returns(mockSection.Object); + + var mockDurationValue = new Mock(); + mockDurationValue.SetupGet(x => x.Value).Returns(DurationInHours.ToString()); + _mockConfiguration.Setup(x => + x.GetSection("JwtSettings:DurationInHours")).Returns(mockDurationValue.Object); + + _service = new TokenService(_mockConfiguration.Object); + + _validUser = new User + { + Id = 1, + Name = "Jon", + Surname = "Snow", + Email = "jon.snow@castleblack.com", + Role = UserRole.User + }; + } + + [TestMethod] + public void GenerateToken_WhenValidUser_ShouldReturnTokenPayload() + { + var result = _service.GenerateToken(_validUser); + + Assert.IsNotNull(result); + Assert.AreEqual("Bearer", result.TokenType); + Assert.IsNotNull(result.AccessToken); + Assert.AreEqual(DurationInHours * 60 * 60, result.ExpiresIn); + } + + [TestMethod] + public void GenerateToken_WhenValidUser_ShouldReturnValidToken() + { + var result = _service.GenerateToken(_validUser); + + var tokenHandler = new JwtSecurityTokenHandler(); + var token = tokenHandler.ReadToken(result.AccessToken) as JwtSecurityToken; + + Assert.IsNotNull(token); + Assert.AreEqual(IssAudValue, token.Issuer); + Assert.AreEqual(IssAudValue, token.Audiences.First()); + Assert.AreEqual(_validUser.Email, token.Subject); + Assert.AreEqual(DurationInHours * 60 * 60, (token.ValidTo - token.ValidFrom).TotalSeconds); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/AddressValidatorTests.cs b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/AddressValidatorTests.cs new file mode 100644 index 00000000..c81d582c --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/AddressValidatorTests.cs @@ -0,0 +1,261 @@ +using ReasnAPI.Models.DTOs; +using ReasnAPI.Validators; + +namespace ReasnAPI.Tests.UnitTests.Validators; + +[TestClass] +public class AddressValidatorTests +{ + private AddressValidator _validator = null!; + + [TestInitialize] + public void Setup() + { + _validator = new AddressValidator(); + } + + [TestMethod] + public void Validate_WhenValidRequest_ShouldReturnTrue() + { + var request = new AddressDto + { + Street = "The Wall", + City = "Castle Black", + Country = "Westeros", + State = "The North", + ZipCode = "12345" + }; + var result = _validator.Validate(request); + + Assert.IsTrue(result.IsValid); + } + + [TestMethod] + public void Validate_WhenEmptyStreet_ShouldReturnFalse() + { + var request = new AddressDto + { + Street = "" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Street' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenStreetTooLong_ShouldReturnFalse() + { + var request = new AddressDto + { + Street = new string('a', 65) + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == + "The length of 'Street' must be 64 characters or fewer. You entered 65 characters." + )); + } + + [TestMethod] + public void Validate_WhenInvalidStreet_ShouldReturnFalse() + { + var request = new AddressDto + { + Street = "The Wall!" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Street' is not in the correct format." + )); + } + + [TestMethod] + public void Validate_WhenEmptyCity_ShouldReturnFalse() + { + var request = new AddressDto + { + City = "" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'City' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenCityTooLong_ShouldReturnFalse() + { + var request = new AddressDto + { + City = new string('a', 65) + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == + "The length of 'City' must be 64 characters or fewer. You entered 65 characters." + )); + } + + [TestMethod] + public void Validate_WhenInvalidCity_ShouldReturnFalse() + { + var request = new AddressDto + { + City = "Castle Black!" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'City' is not in the correct format." + )); + } + + [TestMethod] + public void Validate_WhenEmptyCountry_ShouldReturnFalse() + { + var request = new AddressDto + { + Country = "" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Country' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenCountryTooLong_ShouldReturnFalse() + { + var request = new AddressDto + { + Country = new string('a', 65) + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "" + + "The length of 'Country' must be 64 characters or fewer. You entered 65 characters." + )); + } + + [TestMethod] + public void Validate_WhenInvalidCountry_ShouldReturnFalse() + { + var request = new AddressDto + { + Country = "Westeros!" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Country' is not in the correct format." + )); + } + + [TestMethod] + public void Validate_WhenEmptyState_ShouldReturnFalse() + { + var request = new AddressDto + { + State = "" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'State' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenStateTooLong_ShouldReturnFalse() + { + var request = new AddressDto + { + State = new string('a', 65) + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "" + + "The length of 'State' must be 64 characters or fewer. You entered 65 characters." + )); + } + + [TestMethod] + public void Validate_WhenInvalidState_ShouldReturnFalse() + { + var request = new AddressDto + { + State = "The North!" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'State' is not in the correct format." + )); + } + + [TestMethod] + public void Validate_WhenZipCodeTooLong_ShouldReturnFalse() + { + var request = new AddressDto + { + ZipCode = new string('a', 9) + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == + "The length of 'Zip Code' must be 8 characters or fewer. You entered 9 characters." + )); + } + + [TestMethod] + public void Validate_WhenInvalidZipCode_ShouldReturnFalse() + { + var request = new AddressDto + { + ZipCode = "12345!" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Zip Code' is not in the correct format." + )); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/Authentication/LoginRequestValidatorTests.cs b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/Authentication/LoginRequestValidatorTests.cs new file mode 100644 index 00000000..971de2cc --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/Authentication/LoginRequestValidatorTests.cs @@ -0,0 +1,79 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ReasnAPI.Models.Authentication; +using ReasnAPI.Validators.Authentication; + +namespace ReasnAPI.Tests.UnitTests.Validators.Authentication; + +[TestClass] +public class LoginRequestValidatorTests +{ + private LoginRequestValidator _validator = null!; + + [TestInitialize] + public void Setup() + { + _validator = new LoginRequestValidator(); + } + + [TestMethod] + public void Validate_WhenValidRequest_ShouldReturnTrue() + { + var request = new LoginRequest + { + Email = "test@example.com", + Password = "password" + }; + + var result = _validator.Validate(request); + + Assert.IsTrue(result.IsValid); + } + + [TestMethod] + public void Validate_WhenEmptyEmail_ShouldReturnFalse() + { + var request = new LoginRequest + { + Email = "", + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Email' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenInvalidEmail_ShouldReturnFalse() + { + var request = new LoginRequest + { + Email = "invalid email" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Email' is not a valid email address." + )); + } + + [TestMethod] + public void Validate_WhenEmptyPassword_ShouldReturnFalse() + { + var request = new LoginRequest + { + Password = "" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Password' must not be empty." + )); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/Authentication/RegisterRequestValidatorTests.cs b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/Authentication/RegisterRequestValidatorTests.cs new file mode 100644 index 00000000..c866983a --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/Authentication/RegisterRequestValidatorTests.cs @@ -0,0 +1,339 @@ +using ReasnAPI.Validators.Authentication; +using ReasnAPI.Models.Authentication; +using ReasnAPI.Models.DTOs; + +namespace ReasnAPI.Tests.UnitTests.Validators.Authentication; + +[TestClass] +public class RegisterRequestValidatorTests +{ + private RegisterRequestValidator _validator = null!; + + [TestInitialize] + public void Setup() + { + _validator = new RegisterRequestValidator(); + } + + [TestMethod] + public void Validate_WhenValidRequest_ShouldReturnTrue() + { + var request = new RegisterRequest + { + Name = "Jon", + Surname = "Snow", + Email = "jon.snow@castleblack.com", + Username = "jonSnow", + Password = "S3cureP@ssword!", + Phone = "+123 456789", + Address = new AddressDto + { + Street = "The Wall", + City = "Castle Black", + Country = "Westeros", + State = "The North" + }, + Role = "User" + }; + var result = _validator.Validate(request); + + Assert.IsTrue(result.IsValid); + } + + [TestMethod] + public void Validate_WhenEmptyName_ShouldReturnFalse() + { + var request = new RegisterRequest + { + Name = "" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Name' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenNameTooLong_ShouldReturnFalse() + { + var request = new RegisterRequest + { + Name = new string('a', 65) + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Console.WriteLine(result.Errors[0]); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == + "The length of 'Name' must be 64 characters or fewer. You entered 65 characters." + )); + } + + [TestMethod] + public void Validate_WhenInvalidName_ShouldReturnFalse() + { + var request = new RegisterRequest + { + Name = "123" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Console.WriteLine(result.Errors[0]); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Name' is not in the correct format." + )); + } + + [TestMethod] + public void Validate_WhenEmptySurname_ShouldReturnFalse() + { + var request = new RegisterRequest + { + Surname = "" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Surname' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenSurnameTooLong_ShouldReturnFalse() + { + var request = new RegisterRequest + { + Surname = new string('a', 65) + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == + "The length of 'Surname' must be 64 characters or fewer. You entered 65 characters." + )); + } + + [TestMethod] + public void Validate_WhenInvalidSurname_ShouldReturnFalse() + { + var request = new RegisterRequest + { + Surname = "123" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Surname' is not in the correct format." + )); + } + + [TestMethod] + public void Validate_WhenEmptyUsername_ShouldReturnFalse() + { + var request = new RegisterRequest + { + Username = "" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Username' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenUsernameTooLong_ShouldReturnFalse() + { + var request = new RegisterRequest + { + Username = new string('a', 65) + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == + "The length of 'Username' must be 64 characters or fewer. You entered 65 characters." + )); + } + + [TestMethod] + public void Validate_WhenInvalidUsername_ShouldReturnFalse() + { + var request = new RegisterRequest + { + Username = "user name" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Username' is not in the correct format." + )); + } + + [TestMethod] + public void Validate_WhenEmptyEmail_ShouldReturnFalse() + { + var request = new RegisterRequest + { + Email = "" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Email' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenEmailTooLong_ShouldReturnFalse() + { + var request = new RegisterRequest + { + Email = new string('a', 256) + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == + "The length of 'Email' must be 255 characters or fewer. You entered 256 characters." + )); + } + + [TestMethod] + public void Validate_WhenInvalidEmail_ShouldReturnFalse() + { + var request = new RegisterRequest + { + Email = "invalid email" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Email' is not a valid email address." + )); + } + + [TestMethod] + public void Validate_WhenEmptyPassword_ShouldReturnFalse() + { + var request = new RegisterRequest + { + Password = "" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Password' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenInvalidPassword_ShouldReturnFalse() + { + var request = new RegisterRequest + { + Password = "password" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == + "Password must contain at least one uppercase letter, " + + "one lowercase letter, one number, and be at least 6 characters long." + )); + } + + [TestMethod] + public void Validate_WhenInvalidPhone_ShouldReturnFalse() + { + var request = new RegisterRequest + { + Phone = "invalid phone" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Console.WriteLine(result.Errors[5]); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Phone' is not in the correct format." + )); + } + + [TestMethod] + public void Validate_WhenEmptyAddress_ShouldReturnFalse() + { + var request = new RegisterRequest + { + Address = null! + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Address' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenEmptyRole_ShouldReturnFalse() + { + var request = new RegisterRequest + { + Role = "" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Role' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenInvalidRole_ShouldReturnFalse() + { + var request = new RegisterRequest + { + Role = "invalid role" + }; + + var result = _validator.Validate(request); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "Role must be either 'User' or 'Organizer'." + )); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/CommentValidatorTests.cs b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/CommentValidatorTests.cs new file mode 100644 index 00000000..b1134ebf --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/CommentValidatorTests.cs @@ -0,0 +1,63 @@ +using ReasnAPI.Models.DTOs; +using ReasnAPI.Validators; + +namespace ReasnAPI.Tests.UnitTests.Validators; + +[TestClass] +public class CommentValidatorTests +{ + private CommentValidator _validator = null!; + + [TestInitialize] + public void Setup() + { + _validator = new CommentValidator(); + } + + [TestMethod] + public void Validate_WhenCommentIsValid_ShouldReturnTrue() + { + var comment = new CommentDto + { + Content = "Content", + CreatedAt = DateTime.UtcNow + }; + + var result = _validator.Validate(comment); + + Assert.IsTrue(result.IsValid); + } + + [TestMethod] + public void Validate_WhenContentIsEmpty_ShouldReturnFalse() + { + var comment = new CommentDto + { + Content = "" + }; + + var result = _validator.Validate(comment); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Content' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenContentIsTooLong_ShouldReturnFalse() + { + var comment = new CommentDto + { + Content = new string('a', 1025) + }; + + var result = _validator.Validate(comment); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == + "The length of 'Content' must be 1024 characters or fewer. You entered 1025 characters." + )); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/EventValidatorTests.cs b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/EventValidatorTests.cs new file mode 100644 index 00000000..e7533a31 --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/EventValidatorTests.cs @@ -0,0 +1,208 @@ +using ReasnAPI.Models.DTOs; +using ReasnAPI.Models.Enums; +using ReasnAPI.Validators; + +namespace ReasnAPI.Tests.UnitTests.Validators; + +[TestClass] +public class EventValidatorTests +{ + private EventValidator _validator = null!; + + [TestInitialize] + public void Setup() + { + _validator = new EventValidator(); + } + + [TestMethod] + public void Validate_WhenEventIsValid_ShouldReturnTrue() + { + var eventDto = new EventDto + { + Name = "Event", + Description = "Description", + StartAt = DateTime.UtcNow, + EndAt = DateTime.UtcNow.AddDays(1), + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + Slug = "the-slug", + Status = EventStatus.Approved + }; + + var result = _validator.Validate(eventDto); + + Assert.IsTrue(result.IsValid); + } + + [TestMethod] + public void Validate_WhenNameIsEmpty_ShouldReturnFalse() + { + var eventDto = new EventDto + { + Name = "" + }; + + var result = _validator.Validate(eventDto); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Name' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenNameIsTooLong_ShouldReturnFalse() + { + var eventDto = new EventDto + { + Name = new string('a', 65) + }; + + var result = _validator.Validate(eventDto); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == + "The length of 'Name' must be 64 characters or fewer. You entered 65 characters." + )); + } + + [TestMethod] + public void Validate_WhenDescriptionIsEmpty_ShouldReturnFalse() + { + var eventDto = new EventDto + { + Description = "" + }; + + var result = _validator.Validate(eventDto); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Description' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenDescriptionIsTooLong_ShouldReturnFalse() + { + var eventDto = new EventDto + { + Description = new string('a', 4049) + }; + + var result = _validator.Validate(eventDto); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == + "The length of 'Description' must be 4048 characters or fewer. You entered 4049 characters." + )); + } + + [TestMethod] + public void Validate_WhenStartAtIsAfterEndAt_ShouldReturnFalse() + { + var eventDto = new EventDto + { + StartAt = DateTime.UtcNow, + EndAt = DateTime.UtcNow.AddDays(-1) + }; + + var result = _validator.Validate(eventDto); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'StartAt' must be before 'EndAt'." + )); + } + + [TestMethod] + public void Validate_WhenSlugIsEmpty_ShouldReturnFalse() + { + var eventDto = new EventDto + { + Slug = "" + }; + + var result = _validator.Validate(eventDto); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Slug' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenSlugIsTooLong_ShouldReturnFalse() + { + var eventDto = new EventDto + { + Slug = new string('a', 129) + }; + + var result = _validator.Validate(eventDto); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == + "The length of 'Slug' must be 128 characters or fewer. You entered 129 characters." + )); + } + + [TestMethod] + public void Validate_WhenInvalidSlug_ShouldReturnFalse() + { + var eventDto = new EventDto + { + Slug = "the-slug!" + }; + + var result = _validator.Validate(eventDto); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Slug' is not in the correct format." + )); + } + + [TestMethod] + public void Validate_WhenTagsAreInvalid_ShouldReturnFalse() + { + var eventDto = new EventDto + { + Tags = new List + { + new() { Name = "" } + } + }; + + var result = _validator.Validate(eventDto); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Name' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenParametersAreInvalid_ShouldReturnFalse() + { + var eventDto = new EventDto + { + Parameters = new List + { + new() { Key = "", Value = "" } + } + }; + + var result = _validator.Validate(eventDto); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Key' must not be empty." + )); + } + +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/InterestValidatorTests.cs b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/InterestValidatorTests.cs new file mode 100644 index 00000000..8c7a2ba9 --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/InterestValidatorTests.cs @@ -0,0 +1,78 @@ +using ReasnAPI.Models.DTOs; +using ReasnAPI.Validators; + +namespace ReasnAPI.Tests.UnitTests.Validators; + +[TestClass] +public class InterestValidatorTests +{ + private InterestValidator _validator = null!; + + [TestInitialize] + public void Setup() + { + _validator = new InterestValidator(); + } + + [TestMethod] + public void Validate_WhenInterestIsValid_ShouldReturnTrue() + { + var interest = new InterestDto + { + Name = "Interest" + }; + + var result = _validator.Validate(interest); + + Assert.IsTrue(result.IsValid); + } + + [TestMethod] + public void Validate_WhenNameIsEmpty_ShouldReturnFalse() + { + var interest = new InterestDto + { + Name = "" + }; + + var result = _validator.Validate(interest); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Name' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenNameIsTooLong_ShouldReturnFalse() + { + var interest = new InterestDto + { + Name = new string('a', 33) + }; + + var result = _validator.Validate(interest); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == + "The length of 'Name' must be 32 characters or fewer. You entered 33 characters." + )); + } + + [TestMethod] + public void Validate_WhenInvalidName_ShouldReturnFalse() + { + var interest = new InterestDto + { + Name = "123" + }; + + var result = _validator.Validate(interest); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Name' is not in the correct format." + )); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/ParameterValidatorTests.cs b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/ParameterValidatorTests.cs new file mode 100644 index 00000000..4f77c778 --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/ParameterValidatorTests.cs @@ -0,0 +1,128 @@ +using ReasnAPI.Models.DTOs; +using ReasnAPI.Validators; + +namespace ReasnAPI.Tests.UnitTests.Validators; + +[TestClass] +public class ParameterValidatorTests +{ + private ParameterValidator _validator = null!; + + [TestInitialize] + public void Setup() + { + _validator = new ParameterValidator(); + } + + [TestMethod] + public void Validate_WhenParameterIsValid_ShouldReturnTrue() + { + var parameter = new ParameterDto + { + Key = "Key", + Value = "Value" + }; + + var result = _validator.Validate(parameter); + + Assert.IsTrue(result.IsValid); + } + + [TestMethod] + public void Validate_WhenKeyIsEmpty_ShouldReturnFalse() + { + var parameter = new ParameterDto + { + Key = "" + }; + + var result = _validator.Validate(parameter); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Key' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenKeyIsTooLong_ShouldReturnFalse() + { + var parameter = new ParameterDto + { + Key = new string('a', 33) + }; + + var result = _validator.Validate(parameter); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == + "The length of 'Key' must be 32 characters or fewer. You entered 33 characters." + )); + } + + [TestMethod] + public void Validate_WhenInvalidKey_ShouldReturnFalse() + { + var parameter = new ParameterDto + { + Key = "123" + }; + + var result = _validator.Validate(parameter); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Key' is not in the correct format." + )); + } + + [TestMethod] + public void Validate_WhenValueIsEmpty_ShouldReturnFalse() + { + var parameter = new ParameterDto + { + Value = "" + }; + + var result = _validator.Validate(parameter); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Value' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenValueIsTooLong_ShouldReturnFalse() + { + var parameter = new ParameterDto + { + Value = new string('a', 65) + }; + + var result = _validator.Validate(parameter); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == + "The length of 'Value' must be 64 characters or fewer. You entered 65 characters." + )); + } + + [TestMethod] + public void Validate_WhenInvalidValue_ShouldReturnFalse() + { + var parameter = new ParameterDto + { + Value = "Not a valid value!" + }; + + var result = _validator.Validate(parameter); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Value' is not in the correct format." + )); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/TagValidatorTests.cs b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/TagValidatorTests.cs new file mode 100644 index 00000000..8f0097eb --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/TagValidatorTests.cs @@ -0,0 +1,78 @@ +using ReasnAPI.Models.DTOs; +using ReasnAPI.Validators; + +namespace ReasnAPI.Tests.UnitTests.Validators; + +[TestClass] +public class TagValidatorTests +{ + private TagValidator _validator = null!; + + [TestInitialize] + public void Setup() + { + _validator = new TagValidator(); + } + + [TestMethod] + public void Validate_WhenTagIsValid_ShouldReturnTrue() + { + var tag = new TagDto + { + Name = "Tag" + }; + + var result = _validator.Validate(tag); + + Assert.IsTrue(result.IsValid); + } + + [TestMethod] + public void Validate_WhenNameIsEmpty_ShouldReturnFalse() + { + var tag = new TagDto + { + Name = "" + }; + + var result = _validator.Validate(tag); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Name' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenNameIsTooLong_ShouldReturnFalse() + { + var tag = new TagDto + { + Name = new string('a', 65) + }; + + var result = _validator.Validate(tag); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == + "The length of 'Name' must be 64 characters or fewer. You entered 65 characters." + )); + } + + [TestMethod] + public void Validate_WhenNameContainsInvalidCharacters_ShouldReturnFalse() + { + var tag = new TagDto + { + Name = "123" + }; + + var result = _validator.Validate(tag); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Name' is not in the correct format." + )); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/UserValidatorTests.cs b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/UserValidatorTests.cs new file mode 100644 index 00000000..508ea1ef --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI.Tests/UnitTests/Validators/UserValidatorTests.cs @@ -0,0 +1,247 @@ +using ReasnAPI.Models.DTOs; +using ReasnAPI.Models.Enums; +using ReasnAPI.Validators; + +namespace ReasnAPI.Tests.UnitTests.Validators; + +[TestClass] +public class UserValidatorTests +{ + private UserValidator _validator = null!; + + [TestInitialize] + public void Setup() + { + _validator = new UserValidator(); + } + + [TestMethod] + public void Validate_WhenValidRequest_ShouldReturnTrue() + { + var user = new UserDto + { + Name = "Jon", + Surname = "Snow", + Email = "jon.snow@castleblack.com", + Username = "jonSnow", + Phone = "+123 456789", + Role = UserRole.User + }; + + var result = _validator.Validate(user); + + Assert.IsTrue(result.IsValid); + } + + [TestMethod] + public void Validate_WhenEmptyName_ShouldReturnFalse() + { + var user = new UserDto + { + Name = "" + }; + + var result = _validator.Validate(user); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Name' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenNameTooLong_ShouldReturnFalse() + { + var user = new UserDto + { + Name = new string('a', 65) + }; + + var result = _validator.Validate(user); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == + "The length of 'Name' must be 64 characters or fewer. You entered 65 characters." + )); + } + + [TestMethod] + public void Validate_WhenInvalidName_ShouldReturnFalse() + { + var user = new UserDto + { + Name = "123" + }; + + var result = _validator.Validate(user); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Name' is not in the correct format." + )); + } + + [TestMethod] + public void Validate_WhenEmptySurname_ShouldReturnFalse() + { + var user = new UserDto + { + Surname = "" + }; + + var result = _validator.Validate(user); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Surname' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenSurnameTooLong_ShouldReturnFalse() + { + var user = new UserDto + { + Surname = new string('a', 65) + }; + + var result = _validator.Validate(user); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == + "The length of 'Surname' must be 64 characters or fewer. You entered 65 characters." + )); + } + + [TestMethod] + public void Validate_WhenInvalidSurname_ShouldReturnFalse() + { + var user = new UserDto + { + Surname = "123" + }; + + var result = _validator.Validate(user); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Surname' is not in the correct format." + )); + } + + [TestMethod] + public void Validate_WhenEmptyEmail_ShouldReturnFalse() + { + var user = new UserDto + { + Email = "" + }; + + var result = _validator.Validate(user); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Email' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenEmailTooLong_ShouldReturnFalse() + { + var user = new UserDto + { + Email = new string('a', 256) + }; + + var result = _validator.Validate(user); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == + "The length of 'Email' must be 255 characters or fewer. You entered 256 characters." + )); + } + + [TestMethod] + public void Validate_WhenInvalidEmail_ShouldReturnFalse() + { + var user = new UserDto + { + Email = "jon.snow" + }; + + var result = _validator.Validate(user); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Email' is not a valid email address." + )); + } + + [TestMethod] + public void Validate_WhenEmptyUsername_ShouldReturnFalse() + { + var user = new UserDto + { + Username = "" + }; + + var result = _validator.Validate(user); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Username' must not be empty." + )); + } + + [TestMethod] + public void Validate_WhenUsernameTooLong_ShouldReturnFalse() + { + var user = new UserDto + { + Username = new string('a', 65) + }; + + var result = _validator.Validate(user); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == + "The length of 'Username' must be 64 characters or fewer. You entered 65 characters." + )); + } + + [TestMethod] + public void Validate_WhenInvalidUsername_ShouldReturnFalse() + { + var user = new UserDto + { + Username = "user name" + }; + + var result = _validator.Validate(user); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Username' is not in the correct format." + )); + } + + [TestMethod] + public void Validate_WhenInvalidPhone_ShouldReturnFalse() + { + var user = new UserDto + { + Phone = "123" + }; + + var result = _validator.Validate(user); + + Assert.IsFalse(result.IsValid); + Assert.IsTrue(result.Errors.Exists( + e => e.ErrorMessage == "'Phone' is not in the correct format." + )); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Common/IAssemblyMarker.cs b/Server/ReasnAPI/ReasnAPI/Common/IAssemblyMarker.cs new file mode 100644 index 00000000..916dc1d9 --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Common/IAssemblyMarker.cs @@ -0,0 +1,6 @@ +namespace ReasnAPI.Validators; + +public interface IAssemblyMarker +{ + +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Controllers/.gitkeep b/Server/ReasnAPI/ReasnAPI/Controllers/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Server/ReasnAPI/ReasnAPI/Controllers/AuthController.cs b/Server/ReasnAPI/ReasnAPI/Controllers/AuthController.cs new file mode 100644 index 00000000..9a73f067 --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Controllers/AuthController.cs @@ -0,0 +1,49 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using ReasnAPI.Mappers; +using ReasnAPI.Models.Authentication; +using ReasnAPI.Services.Authentication; + +namespace ReasnAPI.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + private readonly AuthService _authService; + private readonly TokenService _tokenService; + + public AuthController(AuthService authService, TokenService tokenService) + { + _authService = authService; + _tokenService = tokenService; + } + + [HttpPost("login")] + public IActionResult Login( + [FromBody] LoginRequest request, + [FromServices] IValidator validator) + { + validator.ValidateAndThrow(request); + var user = _authService.Login(request); + + var tokenPayload = _tokenService.GenerateToken(user); + return Ok(tokenPayload); + } + + [HttpPost("register")] + public IActionResult Register( + [FromBody] RegisterRequest request, + [FromServices] IValidator validator) + { + validator.ValidateAndThrow(request); + var user = _authService.Register(request); + + var location = Url.Action( + action: nameof(UsersController.GetUserByUsername), + controller: "Users", + values: new { username = user.Username }); + + return Created(location, user.ToDto()); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Controllers/UsersController.cs b/Server/ReasnAPI/ReasnAPI/Controllers/UsersController.cs new file mode 100644 index 00000000..0904a8bf --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Controllers/UsersController.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Mvc; + +namespace ReasnAPI.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class UsersController : ControllerBase +{ + [HttpGet] + [Route("{username}")] + public IActionResult GetUserByUsername(string username) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Exceptions/ServiceExceptionHandler.cs b/Server/ReasnAPI/ReasnAPI/Exceptions/ServiceExceptionHandler.cs new file mode 100644 index 00000000..fd76c213 --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Exceptions/ServiceExceptionHandler.cs @@ -0,0 +1,58 @@ +using System.Net; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; +using ReasnAPI.Services.Exceptions; + +namespace ReasnAPI.Exceptions; + +public class ServiceExceptionHandler : IExceptionHandler +{ + private readonly IProblemDetailsService _problemDetailsService; + + public ServiceExceptionHandler(IProblemDetailsService problemDetailsService) + { + _problemDetailsService = problemDetailsService; + } + + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + var problemDetails = new ProblemDetails + { + Detail = exception.Message + }; + + switch (exception) + { + case BadRequestException: + httpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest; + problemDetails.Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1"; + problemDetails.Title = "A bad request was made"; + break; + + case NotFoundException: + httpContext.Response.StatusCode = (int)HttpStatusCode.NotFound; + problemDetails.Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.4"; + problemDetails.Title = "A resource was not found"; + break; + + case VerificationException: + httpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest; + problemDetails.Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1"; + problemDetails.Title = "A verification error occurred"; + break; + + default: + return false; + } + + return await _problemDetailsService.TryWriteAsync(new ProblemDetailsContext + { + HttpContext = httpContext, + ProblemDetails = problemDetails, + Exception = exception + }); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Exceptions/ValidationExceptionHandler.cs b/Server/ReasnAPI/ReasnAPI/Exceptions/ValidationExceptionHandler.cs new file mode 100644 index 00000000..8cbc68c9 --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Exceptions/ValidationExceptionHandler.cs @@ -0,0 +1,49 @@ +using System.Net; +using FluentValidation; +using Microsoft.AspNetCore.Diagnostics; + +namespace ReasnAPI.Exceptions; + +public class ValidationExceptionHandler : IExceptionHandler +{ + private readonly IProblemDetailsService _problemDetailsService; + + public ValidationExceptionHandler(IProblemDetailsService problemDetailsService) + { + _problemDetailsService = problemDetailsService; + } + + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + if (exception is not ValidationException validationException) + { + return false; + } + + var errors = validationException.Errors.Select(e => new + { + field = e.PropertyName, + message = e.ErrorMessage + }).ToList(); + + httpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest; + return await _problemDetailsService.TryWriteAsync(new ProblemDetailsContext + { + HttpContext = httpContext, + ProblemDetails = + { + Title = "A validation error occurred", + Detail = "One or more validation errors occurred", + Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1", + Extensions = + { + ["errors"] = errors + } + }, + Exception = validationException + }); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Mappers/AddressMapper.cs b/Server/ReasnAPI/ReasnAPI/Mappers/AddressMapper.cs new file mode 100644 index 00000000..f07e575d --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Mappers/AddressMapper.cs @@ -0,0 +1,23 @@ +using ReasnAPI.Models.Database; +using ReasnAPI.Models.DTOs; + +namespace ReasnAPI.Mappers; + +public static class AddressMapper +{ + public static AddressDto ToDto(this Address address) + { + return new AddressDto + { + Street = address.Street, + City = address.City, + State = address.State, + ZipCode = address.ZipCode + }; + } + + public static List ToDtoList(this IEnumerable
addresses) + { + return addresses.Select(ToDto).ToList(); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Mappers/InterestMapper.cs b/Server/ReasnAPI/ReasnAPI/Mappers/InterestMapper.cs new file mode 100644 index 00000000..c8e2fe3f --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Mappers/InterestMapper.cs @@ -0,0 +1,20 @@ +using ReasnAPI.Models.Database; +using ReasnAPI.Models.DTOs; + +namespace ReasnAPI.Mappers; + +public static class InterestMapper +{ + public static InterestDto ToDto(this Interest interest) + { + return new InterestDto + { + Name = interest.Name + }; + } + + public static List ToDtoList(this IEnumerable interests) + { + return interests.Select(ToDto).ToList(); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Mappers/UserInterestMapper.cs b/Server/ReasnAPI/ReasnAPI/Mappers/UserInterestMapper.cs new file mode 100644 index 00000000..ae9bdf0a --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Mappers/UserInterestMapper.cs @@ -0,0 +1,21 @@ +using ReasnAPI.Models.Database; +using ReasnAPI.Models.DTOs; + +namespace ReasnAPI.Mappers; + +public static class UserInterestMapper +{ + public static UserInterestDto ToDto(this UserInterest userInterest) + { + return new UserInterestDto + { + Interest = userInterest.Interest.ToDto(), + Level = userInterest.Level + }; + } + + public static List ToDtoList(this IEnumerable userInterests) + { + return userInterests.Select(ToDto).ToList(); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Mappers/UserMapper.cs b/Server/ReasnAPI/ReasnAPI/Mappers/UserMapper.cs new file mode 100644 index 00000000..512756b8 --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Mappers/UserMapper.cs @@ -0,0 +1,27 @@ +using ReasnAPI.Models.Database; +using ReasnAPI.Models.DTOs; + +namespace ReasnAPI.Mappers; + +public static class UserMapper +{ + public static UserDto ToDto(this User user) + { + return new UserDto + { + Username = user.Username, + Name = user.Name, + Surname = user.Surname, + Email = user.Email, + Phone = user.Phone, + Role = user.Role, + AddressId = user.AddressId, + Interests = user.UserInterests.ToDtoList() + }; + } + + public static List ToDtoList(this IEnumerable users) + { + return users.Select(ToDto).ToList(); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Models/Authentication/LoginRequest.cs b/Server/ReasnAPI/ReasnAPI/Models/Authentication/LoginRequest.cs new file mode 100644 index 00000000..addc4f34 --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Models/Authentication/LoginRequest.cs @@ -0,0 +1,7 @@ +namespace ReasnAPI.Models.Authentication; + +public class LoginRequest +{ + public string Email { get; set; } = null!; + public string Password { get; set; } = null!; +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Models/Authentication/RegisterRequest.cs b/Server/ReasnAPI/ReasnAPI/Models/Authentication/RegisterRequest.cs new file mode 100644 index 00000000..cbcbb81a --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Models/Authentication/RegisterRequest.cs @@ -0,0 +1,15 @@ +using ReasnAPI.Models.DTOs; + +namespace ReasnAPI.Models.Authentication; + +public class RegisterRequest +{ + public string Name { get; set; } = null!; + public string Surname { get; set; } = null!; + public string Email { get; set; } = null!; + public string Username { get; set; } = null!; + public string Password { get; set; } = null!; + public string? Phone { get; set; } + public AddressDto Address { get; set; } = null!; + public string Role { get; set; } = null!; +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Models/Authentication/TokenPayload.cs b/Server/ReasnAPI/ReasnAPI/Models/Authentication/TokenPayload.cs new file mode 100644 index 00000000..118726d6 --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Models/Authentication/TokenPayload.cs @@ -0,0 +1,8 @@ +namespace ReasnAPI.Models.Authentication; + +public class TokenPayload +{ + public string TokenType { get; set; } + public string AccessToken { get; set; } + public int ExpiresIn { get; set; } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Models/Database/ReasnContext.cs b/Server/ReasnAPI/ReasnAPI/Models/Database/ReasnContext.cs index 98c62d12..1656e6b0 100644 --- a/Server/ReasnAPI/ReasnAPI/Models/Database/ReasnContext.cs +++ b/Server/ReasnAPI/ReasnAPI/Models/Database/ReasnContext.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using ReasnAPI.Models.Enums; namespace ReasnAPI.Models.Database; @@ -37,7 +38,12 @@ public ReasnContext(DbContextOptions options) public virtual DbSet UserInterests { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - => optionsBuilder.UseNpgsql("name=ConnectionStrings:DefaultValue"); + { + if (!optionsBuilder.IsConfigured) + { + optionsBuilder.UseNpgsql("name=ConnectionStrings:DefaultValue"); + } + } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -45,28 +51,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasPostgresEnum("common", "event_status", new[] { "Completed", "In progress", "Approved", "Waiting for approval" }) .HasPostgresEnum("common", "object_type", new[] { "Event", "User" }) .HasPostgresEnum("common", "participant_status", new[] { "Interested", "Participating" }) - .HasPostgresEnum("users", "role", new[] { "User", "Organizer", "Admin" }); - - modelBuilder - .Entity() - .Property(u => u.Role) - .HasConversion(); - - modelBuilder - .Entity() - .Property(u => u.Status) - .HasConversion(); - - modelBuilder - .Entity() - .Property(u => u.ObjectType) - .HasConversion(); - - modelBuilder - .Entity() - .Property(u => u.Status) - .HasConversion(); - + .HasPostgresEnum("users", "role", new[] { "User", "Organizer", "Admin" }); + modelBuilder.Entity
(entity => { entity.HasKey(e => e.Id).HasName("address_pkey"); diff --git a/Server/ReasnAPI/ReasnAPI/Models/Enums/UserRole.cs b/Server/ReasnAPI/ReasnAPI/Models/Enums/UserRole.cs index 4a35be42..9b22fe02 100644 --- a/Server/ReasnAPI/ReasnAPI/Models/Enums/UserRole.cs +++ b/Server/ReasnAPI/ReasnAPI/Models/Enums/UserRole.cs @@ -1,8 +1,15 @@ -namespace ReasnAPI.Models.Enums; +using NpgsqlTypes; + +namespace ReasnAPI.Models.Enums; public enum UserRole { - User, - Organizer, + [PgName("User")] + User, + + [PgName("Organizer")] + Organizer, + + [PgName("Admin")] Admin } \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Program.cs b/Server/ReasnAPI/ReasnAPI/Program.cs index 67d181aa..4bb3484c 100644 --- a/Server/ReasnAPI/ReasnAPI/Program.cs +++ b/Server/ReasnAPI/ReasnAPI/Program.cs @@ -1,26 +1,69 @@ using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; using Microsoft.OpenApi.Models; using Npgsql; using ReasnAPI.Models.Enums; using Serilog; -using System; +using System.Text; using System.Text.Json.Serialization; +using FluentValidation; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using ReasnAPI.Exceptions; using ReasnAPI.Models.Database; +using ReasnAPI.Services.Authentication; +using ReasnAPI.Validators; var builder = WebApplication.CreateSlimBuilder(args); +var config = builder.Configuration; -builder.Services.AddControllers(); -var dataSourceBuilder = new NpgsqlDataSourceBuilder(builder.Configuration.GetConnectionString("DefaultValue")); -dataSourceBuilder.MapEnum("events.participant.status"); -dataSourceBuilder.MapEnum("events.event.status"); -dataSourceBuilder.MapEnum("common.image.object_type"); -dataSourceBuilder.MapEnum("users.user.role"); +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; +}).AddJwtBearer(jwt => +{ + jwt.TokenValidationParameters = new TokenValidationParameters + { + ValidIssuer = config["JwtSettings:Issuer"], + ValidAudiences = config.GetSection("JwtSettings:Audiences").Get>(), + IssuerSigningKey = new SymmetricSecurityKey + (Encoding.UTF8.GetBytes(config["JwtSettings:Key"]!)), + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true + }; +}); + +builder.Services.AddExceptionHandler(); +builder.Services.AddExceptionHandler(); +builder.Services.AddProblemDetails(options => + options.CustomizeProblemDetails = ctx => + { + ctx.ProblemDetails.Instance = $"{ctx.HttpContext.Request.Method} {ctx.HttpContext.Request.Path}"; + }); + +builder.Services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + }); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddValidatorsFromAssemblyContaining(); + +var dataSourceBuilder = new NpgsqlDataSourceBuilder(config.GetConnectionString("DefaultValue")); +dataSourceBuilder.MapEnum("common.participant_status"); +dataSourceBuilder.MapEnum("common.event_status"); +dataSourceBuilder.MapEnum("common.object_type"); +dataSourceBuilder.MapEnum("users.role"); var dataSource = dataSourceBuilder.Build(); -// todo: uncomment after creating DbContext and change context name and if needed - connection string localized in appsettings.json builder.Services.AddDbContext(options => - options.UseNpgsql(dataSource)); + options.UseNpgsql(dataSource) + .EnableDetailedErrors()); builder.Services.AddSwaggerGen(options => { @@ -29,10 +72,32 @@ Version = "v1", Title = "Reasn API" }); + options.AddSecurityDefinition("JWT", new OpenApiSecurityScheme + { + Description = "JWT Authorization header using the Bearer scheme.", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey, + Scheme = "Bearer" + }); + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "JWT" + } + }, + Array.Empty() + } + }); }); Log.Logger = new LoggerConfiguration() - .ReadFrom.Configuration(builder.Configuration).CreateLogger(); + .ReadFrom.Configuration(config).CreateLogger(); builder.Host.UseSerilog(); @@ -47,10 +112,16 @@ }); } +app.UseExceptionHandler(); +app.UseStatusCodePages(); + app.UseSerilogRequestLogging(); app.UseHttpsRedirection(); +app.UseAuthentication(); +app.UseAuthorization(); + app.MapSwagger(); app.MapControllers(); diff --git a/Server/ReasnAPI/ReasnAPI/ReasnAPI.csproj b/Server/ReasnAPI/ReasnAPI/ReasnAPI.csproj index 23499ae8..312bd617 100644 --- a/Server/ReasnAPI/ReasnAPI/ReasnAPI.csproj +++ b/Server/ReasnAPI/ReasnAPI/ReasnAPI.csproj @@ -9,23 +9,17 @@ + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - - - - - - - diff --git a/Server/ReasnAPI/ReasnAPI/Services/.gitkeep b/Server/ReasnAPI/ReasnAPI/Services/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Server/ReasnAPI/ReasnAPI/Services/Authentication/AuthService.cs b/Server/ReasnAPI/ReasnAPI/Services/Authentication/AuthService.cs new file mode 100644 index 00000000..1d66a426 --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Services/Authentication/AuthService.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using ReasnAPI.Models.Authentication; +using ReasnAPI.Models.Database; +using ReasnAPI.Models.Enums; +using ReasnAPI.Services.Exceptions; + +namespace ReasnAPI.Services.Authentication; + +public class AuthService +{ + private readonly ReasnContext _context; + private readonly PasswordHasher _hasher; + + public AuthService(ReasnContext context) + { + _context = context; + _hasher = new PasswordHasher(); + } + + public User Login(LoginRequest request) + { + var user = _context.Users.FirstOrDefault(u => + u.Email.ToUpper() == request.Email.ToUpper()); + + if (user is null) + { + throw new NotFoundException("Not found user related with provided email"); + } + + var result = _hasher.VerifyHashedPassword( + user, user.Password, request.Password); + + if (result != PasswordVerificationResult.Success) + { + throw new VerificationException("Provided password is incorrect"); + } + + return user; + } + + public User Register(RegisterRequest request) + { + var userAlreadyExists = _context.Users.Any(u => + u.Email.ToUpper() == request.Email.ToUpper() || + u.Username.ToUpper() == request.Username.ToUpper()); + + if (userAlreadyExists) + { + throw new BadRequestException( + "User with provided email or username already exists"); + } + + var user = new User + { + Name = request.Name, + Surname = request.Surname, + Email = request.Email, + Username = request.Username, + Role = Enum.Parse(request.Role), + IsActive = true, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + Address = new Address + { + Country = request.Address.Country, + City = request.Address.City, + Street = request.Address.Street, + State = request.Address.State, + } + }; + _context.Users.Add(user); + + user.Password = _hasher.HashPassword(user, request.Password); + _context.SaveChanges(); + + return user; + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Services/Authentication/TokenService.cs b/Server/ReasnAPI/ReasnAPI/Services/Authentication/TokenService.cs new file mode 100644 index 00000000..e6752045 --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Services/Authentication/TokenService.cs @@ -0,0 +1,53 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.IdentityModel.Tokens; +using ReasnAPI.Models.Authentication; +using ReasnAPI.Models.Database; + +namespace ReasnAPI.Services.Authentication; + +public class TokenService +{ + private readonly IConfiguration _configuration; + + public TokenService(IConfiguration configuration) => + _configuration = configuration; + + public TokenPayload GenerateToken(User user) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var tokenLifetime = TimeSpan.FromHours( + _configuration.GetValue("JwtSettings:DurationInHours")); + + var key = Encoding.UTF8.GetBytes(_configuration["JwtSettings:Key"]!); + var issuer = _configuration["JwtSettings:Issuer"]!; + var audiences = _configuration.GetSection("JwtSettings:Audiences") + .Get>()!; + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(new[] + { + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new Claim(JwtRegisteredClaimNames.Sub, user.Email), + new Claim(JwtRegisteredClaimNames.Email, user.Email), + new Claim(ClaimTypes.Role, user.Role.ToString()), + new Claim(JwtRegisteredClaimNames.Iss, issuer), + new Claim(JwtRegisteredClaimNames.Aud, string.Join(",", audiences)) + }), + Expires = DateTime.UtcNow.Add(tokenLifetime), + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256) + }; + + var token = tokenHandler.CreateToken(tokenDescriptor); + var tokenPayload = new TokenPayload + { + TokenType = "Bearer", + AccessToken = tokenHandler.WriteToken(token), + ExpiresIn = Convert.ToInt32(tokenLifetime.TotalSeconds) + }; + return tokenPayload; + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Services/Exceptions/BadRequestException.cs b/Server/ReasnAPI/ReasnAPI/Services/Exceptions/BadRequestException.cs new file mode 100644 index 00000000..7b630bda --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Services/Exceptions/BadRequestException.cs @@ -0,0 +1,10 @@ +namespace ReasnAPI.Services.Exceptions; + +public class BadRequestException : Exception +{ + public BadRequestException() { } + + public BadRequestException(string message) : base(message) { } + + public BadRequestException(string message, Exception inner) : base(message, inner) { } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Services/Exceptions/NotFoundException.cs b/Server/ReasnAPI/ReasnAPI/Services/Exceptions/NotFoundException.cs new file mode 100644 index 00000000..781b52b9 --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Services/Exceptions/NotFoundException.cs @@ -0,0 +1,10 @@ +namespace ReasnAPI.Services.Exceptions; + +public class NotFoundException : Exception +{ + public NotFoundException() { } + + public NotFoundException(string message) : base(message) { } + + public NotFoundException(string message, Exception inner) : base(message, inner) { } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Services/Exceptions/VerificationException.cs b/Server/ReasnAPI/ReasnAPI/Services/Exceptions/VerificationException.cs new file mode 100644 index 00000000..cd5a1749 --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Services/Exceptions/VerificationException.cs @@ -0,0 +1,10 @@ +namespace ReasnAPI.Services.Exceptions; + +public class VerificationException : Exception +{ + public VerificationException() { } + + public VerificationException(string message) : base(message) { } + + public VerificationException(string message, Exception inner) : base(message, inner) { } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Validators/.gitkeep b/Server/ReasnAPI/ReasnAPI/Validators/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/Server/ReasnAPI/ReasnAPI/Validators/AddressValidator.cs b/Server/ReasnAPI/ReasnAPI/Validators/AddressValidator.cs new file mode 100644 index 00000000..6f2b2efb --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Validators/AddressValidator.cs @@ -0,0 +1,47 @@ +using FluentValidation; +using ReasnAPI.Models.DTOs; + +namespace ReasnAPI.Validators; + +public class AddressValidator : AbstractValidator +{ + private const int MaxCountryLength = 64; + private const int MaxCityLength = 64; + private const int MaxStreetLength = 64; + private const int MaxStateLength = 64; + private const int MaxZipCodeLength = 8; + + private const string CountryRegex = @"^\p{Lu}[\p{L}\s'-]*(? a.Country) + .NotEmpty() + .MaximumLength(MaxCountryLength) + .Matches(CountryRegex); + + RuleFor(a => a.City) + .NotEmpty() + .MaximumLength(MaxCityLength) + .Matches(CityRegex); + + RuleFor(a => a.Street) + .NotEmpty() + .MaximumLength(MaxStreetLength) + .Matches(StreetRegex); + + RuleFor(a => a.State) + .NotEmpty() + .MaximumLength(MaxStateLength) + .Matches(StateRegex); + + RuleFor(r => r.ZipCode) + .MaximumLength(MaxZipCodeLength) + .Matches(ZipCodeRegex) + .When(r => !string.IsNullOrEmpty(r.ZipCode)); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Validators/Authentication/LoginRequestValidator.cs b/Server/ReasnAPI/ReasnAPI/Validators/Authentication/LoginRequestValidator.cs new file mode 100644 index 00000000..2fdc9c99 --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Validators/Authentication/LoginRequestValidator.cs @@ -0,0 +1,17 @@ +using FluentValidation; +using ReasnAPI.Models.Authentication; + +namespace ReasnAPI.Validators.Authentication; + +public class LoginRequestValidator : AbstractValidator +{ + public LoginRequestValidator() + { + RuleFor(lr => lr.Email) + .NotEmpty() + .EmailAddress(); + + RuleFor(lr => lr.Password) + .NotEmpty(); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Validators/Authentication/RegisterRequestValidator.cs b/Server/ReasnAPI/ReasnAPI/Validators/Authentication/RegisterRequestValidator.cs new file mode 100644 index 00000000..255dfc93 --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Validators/Authentication/RegisterRequestValidator.cs @@ -0,0 +1,62 @@ +using FluentValidation; +using ReasnAPI.Models.Authentication; +using ReasnAPI.Models.Enums; + +namespace ReasnAPI.Validators.Authentication; + +public class RegisterRequestValidator : AbstractValidator +{ + private const int MaxNameLength = 64; + private const int MaxSurnameLength = 64; + private const int MaxUsernameLength = 64; + private const int MaxEmailLength = 255; + + private const string NameRegex = @"^\p{Lu}[\p{Ll}\s'-]+$"; + private const string SurnameRegex = @"^\p{L}+(?:[\s'-]\p{L}+)*$"; + private const string UsernameRegex = @"^[\p{L}\d._%+-]{4,}$"; + private const string PasswordRegex = @"^((?=\S*?[A-Z])(?=\S*?[a-z])(?=\S*?[0-9]).{6,})\S$"; + private const string PhoneRegex = @"^\+\d{1,3}\s\d{1,15}$"; + + public RegisterRequestValidator() + { + RuleFor(r => r.Name) + .NotEmpty() + .MaximumLength(MaxNameLength) + .Matches(NameRegex); + + RuleFor(r => r.Surname) + .NotEmpty() + .MaximumLength(MaxSurnameLength) + .Matches(SurnameRegex); + + RuleFor(r => r.Username) + .NotEmpty() + .MaximumLength(MaxUsernameLength) + .Matches(UsernameRegex); + + RuleFor(r => r.Email) + .NotEmpty() + .MaximumLength(MaxEmailLength) + .EmailAddress(); + + RuleFor(r => r.Password) + .NotEmpty() + .Matches(PasswordRegex) + .WithMessage( + "Password must contain at least one uppercase letter, " + + "one lowercase letter, one number, and be at least 6 characters long."); + + RuleFor(r => r.Phone) + .Matches(PhoneRegex) + .When(r => !string.IsNullOrEmpty(r.Phone)); + + RuleFor(r => r.Address) + .NotNull() + .SetValidator(new AddressValidator()!); + + RuleFor(r => r.Role) + .NotEmpty() + .Must(r => r == UserRole.User.ToString() || r == UserRole.Organizer.ToString()) + .WithMessage($"Role must be either '{UserRole.User.ToString()}' or '{UserRole.Organizer.ToString()}'."); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Validators/CommentValidator.cs b/Server/ReasnAPI/ReasnAPI/Validators/CommentValidator.cs new file mode 100644 index 00000000..9c2ce1aa --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Validators/CommentValidator.cs @@ -0,0 +1,16 @@ +using ReasnAPI.Models.DTOs; +using FluentValidation; + +namespace ReasnAPI.Validators; + +public class CommentValidator : AbstractValidator +{ + private const int MaxContentLength = 1024; + + public CommentValidator() + { + RuleFor(c => c.Content) + .NotEmpty() + .MaximumLength(MaxContentLength); + } +} diff --git a/Server/ReasnAPI/ReasnAPI/Validators/EventValidator.cs b/Server/ReasnAPI/ReasnAPI/Validators/EventValidator.cs new file mode 100644 index 00000000..bc4d4d51 --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Validators/EventValidator.cs @@ -0,0 +1,41 @@ +using ReasnAPI.Models.DTOs; +using FluentValidation; + +namespace ReasnAPI.Validators; + +public class EventValidator : AbstractValidator +{ + private const int MaxNameLength = 64; + private const int MaxDescriptionLength = 4048; + private const int MaxSlugLength = 128; + + private const string SlugRegex = @"^[\p{L}\d]+[\p{L}\d-]*$"; + + public EventValidator() + { + RuleFor(e => e.Name) + .NotEmpty() + .MaximumLength(MaxNameLength); + + RuleFor(e => e.Description) + .NotEmpty() + .MaximumLength(MaxDescriptionLength); + + RuleFor(e => e.StartAt) + .LessThan(e => e.EndAt) + .WithMessage("'StartAt' must be before 'EndAt'."); + + RuleFor(e => e.Slug) + .NotEmpty() + .MaximumLength(MaxSlugLength) + .Matches(SlugRegex); + + RuleForEach(e => e.Tags) + .SetValidator(new TagValidator()) + .When(e => e.Tags?.Count > 0); + + RuleForEach(e => e.Parameters) + .SetValidator(new ParameterValidator()) + .When(e => e.Parameters?.Count > 0); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Validators/InterestValidator.cs b/Server/ReasnAPI/ReasnAPI/Validators/InterestValidator.cs new file mode 100644 index 00000000..2b485429 --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Validators/InterestValidator.cs @@ -0,0 +1,19 @@ +using ReasnAPI.Models.DTOs; +using FluentValidation; + +namespace ReasnAPI.Validators; + +public class InterestValidator : AbstractValidator +{ + private const int MaxNameLength = 32; + + private const string NameRegex = @"^\p{Lu}\p{Ll}+(?:\s\p{L}+)*$"; + + public InterestValidator() + { + RuleFor(i => i.Name) + .NotEmpty() + .MaximumLength(MaxNameLength) + .Matches(NameRegex); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Validators/ParameterValidator.cs b/Server/ReasnAPI/ReasnAPI/Validators/ParameterValidator.cs new file mode 100644 index 00000000..54894136 --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Validators/ParameterValidator.cs @@ -0,0 +1,26 @@ +using ReasnAPI.Models.DTOs; +using FluentValidation; + +namespace ReasnAPI.Validators; + +public class ParameterValidator : AbstractValidator +{ + private const int MaxKeyLength = 32; + private const int MaxValueLength = 64; + + private const string KeyRegex = @"^\p{L}+(?:\s\p{L}+)*$"; + private const string ValueRegex = @"^[\p{L}\d]+(?:\s[\p{L}\d]+)*$"; + + public ParameterValidator() + { + RuleFor(p => p.Key) + .NotEmpty() + .MaximumLength(MaxKeyLength) + .Matches(KeyRegex); + + RuleFor(p => p.Value) + .NotEmpty() + .MaximumLength(MaxValueLength) + .Matches(ValueRegex); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Validators/TagValidator.cs b/Server/ReasnAPI/ReasnAPI/Validators/TagValidator.cs new file mode 100644 index 00000000..641ab05a --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Validators/TagValidator.cs @@ -0,0 +1,19 @@ +using ReasnAPI.Models.DTOs; +using FluentValidation; + +namespace ReasnAPI.Validators; + +public class TagValidator : AbstractValidator +{ + private const int MaxNameLength = 64; + + private const string NameRegex = @"^\p{L}+(?:\s\p{L}+)*$"; + + public TagValidator() + { + RuleFor(t => t.Name) + .NotEmpty() + .MaximumLength(MaxNameLength) + .Matches(NameRegex); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/Validators/UserValidator.cs b/Server/ReasnAPI/ReasnAPI/Validators/UserValidator.cs new file mode 100644 index 00000000..fa072029 --- /dev/null +++ b/Server/ReasnAPI/ReasnAPI/Validators/UserValidator.cs @@ -0,0 +1,44 @@ +using ReasnAPI.Models.DTOs; +using FluentValidation; + +namespace ReasnAPI.Validators; + +public class UserValidator : AbstractValidator +{ + private const int MaxNameLength = 64; + private const int MaxSurnameLength = 64; + private const int MaxUsernameLength = 64; + private const int MaxEmailLength = 255; + + private const string NameRegex = @"^\p{Lu}[\p{Ll}\s'-]+$"; + private const string SurnameRegex = @"^\p{L}+(?:[\s'-]\p{L}+)*$"; + private const string UsernameRegex = @"^[\p{L}\d._%+-]{4,}$"; + private const string PhoneRegex = @"^\+\d{1,3}\s\d{1,15}$"; + + public UserValidator() + { + RuleFor(r => r.Name) + .NotEmpty() + .MaximumLength(MaxNameLength) + .Matches(NameRegex); + + RuleFor(r => r.Surname) + .NotEmpty() + .MaximumLength(MaxSurnameLength) + .Matches(SurnameRegex); + + RuleFor(r => r.Username) + .NotEmpty() + .MaximumLength(MaxUsernameLength) + .Matches(UsernameRegex); + + RuleFor(r => r.Email) + .NotEmpty() + .MaximumLength(MaxEmailLength) + .EmailAddress(); + + RuleFor(r => r.Phone) + .Matches(PhoneRegex) + .When(r => !string.IsNullOrEmpty(r.Phone)); + } +} \ No newline at end of file diff --git a/Server/ReasnAPI/ReasnAPI/appsettings.json b/Server/ReasnAPI/ReasnAPI/appsettings.json index 826f8080..58588f6e 100644 --- a/Server/ReasnAPI/ReasnAPI/appsettings.json +++ b/Server/ReasnAPI/ReasnAPI/appsettings.json @@ -2,21 +2,21 @@ "Serilog": { "Using": [ "Serilog.Sinks.Console" - //"Serilog.Sinks.File" ], "WriteTo": [ { "Name": "Console" } - //{ - // "Name": "File", - // "Args": { - // "path": "logs/ReasnAPI-.log", - // "rollingInterval": "Day" - // } - //} ] }, "ConnectionStrings": { "DefaultValue": "Server=postgres;Port=5432;Database=reasn;User Id=dba;Password=sql;" }, + "JwtSettings": { + "Issuer": "http://localhost:5272", + "Audiences": [ + "http://localhost:5272" + ], + "Key": "YOUR_SECRET_KEY", + "DurationInHours": 8 + }, "AllowedHosts": "*" } diff --git a/compose.override.yml b/compose.override.yml index 78ce04ea..17c93175 100644 --- a/compose.override.yml +++ b/compose.override.yml @@ -47,6 +47,8 @@ services: path: ./Server/ReasnAPI/ReasnAPI postgres: + ports: + - "5432:5432" volumes: - ./Database/init-dev-data.sql:/docker-entrypoint-initdb.d/03-init-dev-data.sql - \ No newline at end of file +