diff --git a/core/Extensions/DependencyInjectionExtension.cs b/core/Extensions/DependencyInjectionExtension.cs index a3e52ed..0a65e17 100644 --- a/core/Extensions/DependencyInjectionExtension.cs +++ b/core/Extensions/DependencyInjectionExtension.cs @@ -41,6 +41,7 @@ public static IServiceCollection AddDependencyInjection(this IServiceCollection services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Utils services.AddTransient(); diff --git a/core/Services/Abstractions/IIdentityProviderService.cs b/core/Services/Abstractions/IIdentityProviderService.cs new file mode 100644 index 0000000..a587baf --- /dev/null +++ b/core/Services/Abstractions/IIdentityProviderService.cs @@ -0,0 +1,26 @@ +namespace api.core.Services.Abstractions; + +/// +/// Interface to communicate with any ID Provider +/// +public interface IIdentityProviderService +{ + /// + /// Fetches User's information from the ID Provider + /// + /// Header used to call the endpoint + /// If result is null, UserInfo endpoint cannot be accessed + UserInfoDto? GetUserInfo(string accessHeader); +} + +public class UserInfoDto +{ + public string Sub { get; set; } = null!; + public string Email { get; set; } = null!; + public bool EmailVerified { get; set; } + public string Name { get; set; } = null!; + public string GivenName { get; set; } = null!; + public string PreferedUsername { get; set; } = null!; + public string Nickname { get; set; } = null!; + public List Groups { get; set; } = null!; +} \ No newline at end of file diff --git a/core/Services/Abstractions/IUserService.cs b/core/Services/Abstractions/IUserService.cs index c487b3e..f2246b9 100644 --- a/core/Services/Abstractions/IUserService.cs +++ b/core/Services/Abstractions/IUserService.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.Tracing; +using System.Diagnostics.Tracing; using api.core.Data.Enums; using api.core.Data.requests; diff --git a/core/Services/AuthentikService.cs b/core/Services/AuthentikService.cs new file mode 100644 index 0000000..2ede95f --- /dev/null +++ b/core/Services/AuthentikService.cs @@ -0,0 +1,32 @@ +using api.core.Services.Abstractions; + +using System.Text.Json; + +namespace api.core.Services; + +/// +/// Service used to communicate with Authentik ID Provider +/// +public class AuthentikService : IIdentityProviderService +{ + public UserInfoDto? GetUserInfo(string accessHeader) + { + using HttpClient client = new HttpClient() + { + BaseAddress = new Uri(Environment.GetEnvironmentVariable("OPENID_BASE_URL")) + }; + + using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, "userinfo/"); + request.Headers.Add("Authorization", accessHeader); + + // TODO : Changer pour un comportement asynchrone + var response = client.SendAsync(request).Result; + + if (!response.IsSuccessStatusCode) + { + return null; + } + + return response.Content.ReadFromJsonAsync(new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }).Result; + } +} diff --git a/core/Services/UserService.cs b/core/Services/UserService.cs index 7b56b99..50eae10 100644 --- a/core/Services/UserService.cs +++ b/core/Services/UserService.cs @@ -1,4 +1,4 @@ -using api.core.data.entities; +using api.core.data.entities; using api.core.Data.Exceptions; using api.core.Data.Enums; using api.core.Data.requests; @@ -12,6 +12,7 @@ using api.core.Misc; using api.core.Repositories.Abstractions; using api.core.Data.Entities; +using api.core.Services.Abstractions; namespace api.core.Services; @@ -21,7 +22,8 @@ public class UserService( ITagRepository tagRepository, IActivityAreaRepository activityAreaRepository, IImageService imageService, - IJwtUtils jwtUtils) : IUserService + IJwtUtils jwtUtils, + IIdentityProviderService identityProvider) : IUserService { private const string AVATAR_FILE_NAME = "avatar.webp"; @@ -57,16 +59,11 @@ public UserResponseDTO AddOrganizer(string id, UserCreateDTO organizerDto) public UserResponseDTO GetUser(string authHeader) { string userId = jwtUtils.GetUserIdFromAuthHeader(authHeader); - UserResponseDTO? userRes = null; - var organizer = userRepository.GetOrganizer(userId); - if (organizer != null) - userRes = UserResponseDTO.Map(organizer!); + var user = userRepository.Get(userId); - var moderator = userRepository.GetModerator(userId); - if (moderator != null) - userRes = UserResponseDTO.Map(moderator!); - - if (userRes == null) throw new Exception("No users associated with this ID"); + UserResponseDTO? userRes = user == null ? + AddUser(authHeader, userId) : + UserResponseDTO.Map(user); var fields = tagRepository.GetInterestFieldsForOrganizer(userId); userRes.FieldsOfInterests = fields; @@ -166,4 +163,31 @@ public string UpdateUserAvatar(string id, IFormFile avatarFile) var url = fileShareService.FileGetDownloadUri($"{userId}/{AVATAR_FILE_NAME}"); return url.ToString(); } + + /// + /// Adds a new user by calling the ID Provider endpoint for the users' information + /// + /// The header used for calling de ID Provider's endpoint + /// User to add + /// Formatted DTO of User's information + private UserResponseDTO AddUser(string authHeader, string userId) + { + UserInfoDto? userInfo = identityProvider.GetUserInfo(authHeader); + + if (userInfo == null) + { + throw new Exception("No users associated with this ID"); + } + + User addedUser = userRepository.Add(new User + { + Email = userInfo.Email, + Id = userId, + ProfileDescription = userInfo.GivenName, + Organization = string.Empty, + IsActive = true + }); + + return UserResponseDTO.Map(addedUser); + } } diff --git a/tests/Tests/Services/UserServiceTests.cs b/tests/Tests/Services/UserServiceTests.cs index 0e7ce00..79676f7 100644 --- a/tests/Tests/Services/UserServiceTests.cs +++ b/tests/Tests/Services/UserServiceTests.cs @@ -15,6 +15,7 @@ using api.core.Repositories.Abstractions; using Microsoft.IdentityModel.JsonWebTokens; using api.core.Misc; +using api.core.Services.Abstractions; namespace api.tests.Tests.Services; public class UserServiceTests @@ -25,6 +26,7 @@ public class UserServiceTests private readonly Mock _fileShareServiceMock; private readonly Mock _imageServiceMock; private readonly Mock _jwtUtilsMock; + private readonly Mock _providerServiceMock; private readonly UserService _userService; public UserServiceTests() @@ -35,6 +37,7 @@ public UserServiceTests() _fileShareServiceMock = new Mock(); _imageServiceMock = new Mock(); _jwtUtilsMock = new Mock(); + _providerServiceMock = new Mock(); _fileShareServiceMock.Setup(service => service.FileGetDownloadUri(It.IsAny())).Returns(new Uri("http://example.com/avatar.webp")); _userService = new UserService( @@ -43,7 +46,8 @@ public UserServiceTests() _tagRepositoryMock.Object, _activityAreaRepositoryMock.Object, _imageServiceMock.Object, - _jwtUtilsMock.Object); + _jwtUtilsMock.Object, + _providerServiceMock.Object); } [Fact] @@ -112,7 +116,7 @@ public void GetUser_ShouldReturnUserResponseDTO_WhenOrganizerIsFoundById() Role = UserRole.Organizer }; - _userRepositoryMock.Setup(repo => repo.GetOrganizer(organizerId)).Returns(organizer); + _userRepositoryMock.Setup(repo => repo.Get(organizerId)).Returns(organizer); _jwtUtilsMock.Setup(jwtUtils => jwtUtils.GetUserIdFromAuthHeader(organizerId)).Returns(organizerId); // Act @@ -123,7 +127,7 @@ public void GetUser_ShouldReturnUserResponseDTO_WhenOrganizerIsFoundById() result.Id.Should().Be(organizerId); result.Email.Should().Be(organizer.Email); - _userRepositoryMock.Verify(repo => repo.GetOrganizer(organizerId), Times.Once); + _userRepositoryMock.Verify(repo => repo.Get(organizerId), Times.Once); } [Fact] @@ -141,8 +145,7 @@ public void GetUser_ShouldReturnUserResponseDTO_WhenModeratorIsFoundById() }; _jwtUtilsMock.Setup(jwtUtil => jwtUtil.GetUserIdFromAuthHeader(moderatorId)).Returns(moderatorId); - _userRepositoryMock.Setup(repo => repo.GetOrganizer(moderatorId)).Returns((User?)null); // Simulate no organizer found - _userRepositoryMock.Setup(repo => repo.GetModerator(moderatorId)).Returns(moderator); // Simulate moderator found + _userRepositoryMock.Setup(repo => repo.Get(moderatorId)).Returns(moderator); // Simulate moderator found // Act var result = _userService.GetUser(moderatorId); @@ -152,29 +155,76 @@ public void GetUser_ShouldReturnUserResponseDTO_WhenModeratorIsFoundById() result.Id.Should().Be(moderatorId); result.Email.Should().Be(moderator.Email); - _userRepositoryMock.Verify(repo => repo.GetOrganizer(moderatorId), Times.Once); - _userRepositoryMock.Verify(repo => repo.GetModerator(moderatorId), Times.Once); + _userRepositoryMock.Verify(repo => repo.Get(moderatorId), Times.Once); _jwtUtilsMock.Verify(jwtUtil => jwtUtil.GetUserIdFromAuthHeader(moderatorId), Times.Once); } [Fact] - public void GetUser_ShouldThrowException_WhenNoUserIsAssociatedWithProvidedId() + public void GetUser_ShouldReturnUserResponseDTO_WhenNewUserIsFoundById() + { + // Arrange + var userId = "userId"; + var user = new User + { + Id = userId, + Email = "jane.doe@example.com", + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + var userInfo = new UserInfoDto + { + Email = "jane.doe@example.com", + EmailVerified = true, + GivenName = "Jane Doe", + Name = "Jane Doe", + Nickname = "jane.doe", + PreferedUsername = "jane.doe", + Sub = userId + }; + + _jwtUtilsMock.Setup(jwtUtils => jwtUtils.GetUserIdFromAuthHeader(userId)).Returns(userId); + _userRepositoryMock.Setup(repo => repo.Get(userId)).Returns(null as User); + _providerServiceMock.Setup(provider => provider.GetUserInfo(userId)).Returns(userInfo); + _userRepositoryMock.Setup(repo => + repo.Add(It.Is(u => u.Id == userInfo.Sub && u.Email == userInfo.Email && u.ProfileDescription == userInfo.GivenName))) + .Returns(user); + + // Act + var result = _userService.GetUser(userId); + + // Assert + result.Should().NotBeNull(); + result.Type.Should().BeEmpty(); + result.Id.Should().Be(user.Id); + result.Email.Should().Be(user.Email); + + _jwtUtilsMock.Verify(jwtUtils => jwtUtils.GetUserIdFromAuthHeader(userId), Times.Once); + _userRepositoryMock.Verify(repo => repo.Get(userId), Times.Once); + _userRepositoryMock.Verify(repo => repo.Add(It.IsAny()), Times.Once); + _providerServiceMock.Verify(provider => provider.GetUserInfo(userId), Times.Once); + } + + [Fact] + public void GetUser_ShouldThrowException_WhenNoUserIsAssociatedWithProvidedIdNorValid() { // Arrange var userId = "nobody"; // Setup both organizer and moderator repositories to return null, simulating that no user is found with the provided ID - _userRepositoryMock.Setup(repo => repo.GetOrganizer(userId)).Returns(null as User); - _userRepositoryMock.Setup(repo => repo.GetModerator(userId)).Returns(null as User); + _userRepositoryMock.Setup(repo => repo.Get(userId)).Returns(null as User); _jwtUtilsMock.Setup(jwtUtil => jwtUtil.GetUserIdFromAuthHeader(userId)).Returns(userId); + // Setup the provider call as if the call was invalid (no connection or invalid token) + _providerServiceMock.Setup(provider => provider.GetUserInfo(userId)).Returns(null as UserInfoDto); + // Act Action act = () => _userService.GetUser(userId); // Assert act.Should().Throw().WithMessage("No users associated with this ID"); - _userRepositoryMock.Verify(repo => repo.GetOrganizer(userId), Times.Once); - _userRepositoryMock.Verify(repo => repo.GetOrganizer(userId), Times.Once); + _userRepositoryMock.Verify(repo => repo.Get(userId), Times.Once); + _providerServiceMock.Verify(provider => provider.GetUserInfo(userId), Times.Once); } @@ -209,7 +259,7 @@ public void UpdateUser_ShouldReturnTrue_WhenOrganizerIsUpdatedSuccessfully() }; _activityAreaRepositoryMock.Setup(repo => repo.Get(actAreaIdModified)).Returns(activity); // Simulate activity area found - _userRepositoryMock.Setup(repo => repo.GetOrganizer(organizerId)).Returns(existingOrganizer); + _userRepositoryMock.Setup(repo => repo.Get(organizerId)).Returns(existingOrganizer); _userRepositoryMock.Setup(repo => repo.Update(organizerId, It.IsAny())).Returns(true); _jwtUtilsMock.Setup(jwtUtils => jwtUtils.GetUserIdFromAuthHeader(organizerId)).Returns(organizerId); @@ -250,7 +300,7 @@ public void UpdateUser_ShouldThrow_WhenActivityAreaIsNotFoundInTheList() UpdatedAt = DateTime.UtcNow }; _activityAreaRepositoryMock.Setup(repo => repo.Get(badActAreaIdModified)).Returns(null as ActivityArea); // Simulate activity area not found - _userRepositoryMock.Setup(repo => repo.GetOrganizer(organizerId)).Returns(existingOrganizer); + _userRepositoryMock.Setup(repo => repo.Get(organizerId)).Returns(existingOrganizer); _userRepositoryMock.Setup(repo => repo.Update(organizerId, It.IsAny())).Returns(true); _jwtUtilsMock.Setup(jwtUtils => jwtUtils.GetUserIdFromAuthHeader(organizerId)).Returns(organizerId); @@ -279,7 +329,7 @@ public void UpdateUser_ShouldReturnTrue_WhenModeratorIsUpdatedSuccessfully() Role = UserRole.Moderator }; - _userRepositoryMock.Setup(repo => repo.GetModerator(moderatorId)).Returns(existingModerator); // Simulate moderator found + _userRepositoryMock.Setup(repo => repo.Get(moderatorId)).Returns(existingModerator); // Simulate moderator found _userRepositoryMock.Setup(repo => repo.Update(moderatorId, It.IsAny())).Returns(true); _jwtUtilsMock.Setup(jwtUtils => jwtUtils.GetUserIdFromAuthHeader(moderatorId)).Returns(moderatorId);