diff --git a/dossierfacile-api-tenant/pom.xml b/dossierfacile-api-tenant/pom.xml index ef1d3a931..fe3ba0fd5 100644 --- a/dossierfacile-api-tenant/pom.xml +++ b/dossierfacile-api-tenant/pom.xml @@ -41,6 +41,12 @@ fr.dossierfacile dossierfacile-common-library + + fr.dossierfacile + dossierfacile-common-test-library + ${revision} + test + org.springframework.boot spring-boot-starter-data-jpa diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/ResourceServerConfig.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/ResourceServerConfig.java index 4beb2c911..853780875 100644 --- a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/ResourceServerConfig.java +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/ResourceServerConfig.java @@ -3,6 +3,7 @@ import fr.dossierfacile.api.front.config.filter.ConnectionContextFilter; import fr.dossierfacile.api.front.security.PartnerAuthorizationManager; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authorization.AuthorizationManager; @@ -31,6 +32,9 @@ @RequiredArgsConstructor public class ResourceServerConfig { + @Value("{resource.server.config.csp}") + private String configCsp; + @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http @@ -41,7 +45,7 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { .xssProtection(withDefaults()) .cacheControl(withDefaults()) .httpStrictTransportSecurity(transport -> transport.maxAgeInSeconds(63072000).includeSubDomains(true)) - .contentSecurityPolicy(csp -> csp.policyDirectives("frame-ancestors 'none'; frame-src 'none'; child-src 'none'; upgrade-insecure-requests; default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; img-src 'self' data:; font-src 'self'; connect-src *.dossierfacile.fr *.dossierfacile.fr:*; base-uri 'self'; form-action 'none'; media-src 'none'; worker-src 'none'; manifest-src 'none'; prefetch-src 'none';")) + .contentSecurityPolicy(csp -> csp.policyDirectives(configCsp)) .frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin) ) .formLogin(AbstractHttpConfigurer::disable) diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/SwaggerConfig.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/SwaggerConfig.java index 49ab5e94d..29891fef4 100644 --- a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/SwaggerConfig.java +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/SwaggerConfig.java @@ -4,15 +4,19 @@ import org.springdoc.core.models.GroupedOpenApi; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; @Configuration public class SwaggerConfig { + @Bean + @Profile("!dev") public GroupedOpenApi dfcOpenApi() { return GroupedOpenApi.builder().displayName("API DFC").group("dfc").pathsToMatch("/dfc/**").build(); } @Bean + @Profile("!dev") public GroupedOpenApi partnerOpenApi() { return GroupedOpenApi.builder().displayName("API Partner").group("api-partner").pathsToMatch("/api-partner/**").build(); } diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/filter/RateLimitingFilter.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/filter/RateLimitingFilter.java index 5fb583cc7..32c5b3cd9 100644 --- a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/filter/RateLimitingFilter.java +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/filter/RateLimitingFilter.java @@ -6,7 +6,6 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; -@Component @Profile("!dev") @WebFilter("/api/register/account") public class RateLimitingFilter extends AbstractRateLimitingFilter implements Filter { diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/filter/RateLimitingSupportMailFilter.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/filter/RateLimitingSupportMailFilter.java index 97e59c0da..db1ed5998 100644 --- a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/filter/RateLimitingSupportMailFilter.java +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/config/filter/RateLimitingSupportMailFilter.java @@ -6,7 +6,6 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; -@Component @Profile("!dev") @WebFilter("/api/support/email") public class RateLimitingSupportMailFilter extends AbstractRateLimitingFilter implements Filter { diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/controller/ApartmentSharingLinkController.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/controller/ApartmentSharingLinkController.java index c7f7a37fb..375d49d90 100644 --- a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/controller/ApartmentSharingLinkController.java +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/controller/ApartmentSharingLinkController.java @@ -6,6 +6,9 @@ import fr.dossierfacile.common.entity.Tenant; import fr.dossierfacile.common.model.ApartmentSharingLinkModel; import fr.dossierfacile.common.service.ApartmentSharingLinkService; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiResponse; +import io.swagger.annotations.ApiResponses; import lombok.AllArgsConstructor; import lombok.Data; import org.springframework.http.ResponseEntity; @@ -22,6 +25,11 @@ public class ApartmentSharingLinkController { private final ApartmentSharingLinkService apartmentSharingLinkService; private final TenantService tenantService; + @ApiOperation(value = "Get apartment sharing links", notes = "Retrieves the list of apartment sharing links for the logged-in tenant.") + @ApiResponses(value = { + @ApiResponse(code = 200, message = "Links retrieved successfully", response = LinksResponse.class), + @ApiResponse(code = 403, message = "Forbidden: JWT token missing or invalid scope") + }) @GetMapping public ResponseEntity getApartmentSharingLinks() { ApartmentSharing apartmentSharing = authenticationFacade.getLoggedTenant().getApartmentSharing(); @@ -29,6 +37,13 @@ public ResponseEntity getApartmentSharingLinks() { return ResponseEntity.ok(new LinksResponse(linksByMail)); } + @ApiOperation(value = "Update apartment sharing link status", notes = "Updates the status of an apartment sharing link.") + @ApiResponses(value = { + @ApiResponse(code = 200, message = "Status updated successfully"), + @ApiResponse(code = 400, message = "Invalid request"), + @ApiResponse(code = 403, message = "Forbidden: JWT token missing or invalid scope or email unverified"), + @ApiResponse(code = 404, message = "Link not found") + }) @PutMapping("/{id}") public ResponseEntity updateApartmentSharingLinksStatus(@PathVariable Long id, @RequestParam boolean enabled) { ApartmentSharing apartmentSharing = authenticationFacade.getLoggedTenant().getApartmentSharing(); @@ -36,6 +51,12 @@ public ResponseEntity updateApartmentSharingLinksStatus(@PathVariable Long return ResponseEntity.ok().build(); } + @ApiOperation(value = "Resend apartment sharing link", notes = "Resends an apartment sharing link to the tenant.") + @ApiResponses(value = { + @ApiResponse(code = 200, message = "Link resent successfully"), + @ApiResponse(code = 403, message = "Forbidden: JWT token missing or invalid scope or email unverified or apartment sharing not in tenant"), + @ApiResponse(code = 500, message = "link is disabled or call too soon or error when sending email") + }) @PostMapping("/{id}/resend") public ResponseEntity resendApartmentSharingLink(@PathVariable Long id) { Tenant tenant = authenticationFacade.getLoggedTenant(); @@ -43,6 +64,12 @@ public ResponseEntity resendApartmentSharingLink(@PathVariable Long id) { return ResponseEntity.ok().build(); } + @ApiOperation(value = "Delete apartment sharing link", notes = "Deletes an apartment sharing link.") + @ApiResponses(value = { + @ApiResponse(code = 200, message = "Link deleted successfully"), + @ApiResponse(code = 403, message = "Forbidden: JWT token missing or invalid scope or email unverified"), + @ApiResponse(code = 500, message = "link is disabled or call too soon or error when sending email") + }) @DeleteMapping("/{id}") public ResponseEntity deleteApartmentSharingLink(@PathVariable Long id) { Tenant tenant = authenticationFacade.getLoggedTenant(); diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/controller/TenantController.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/controller/TenantController.java index 83ae22234..2421b2c0d 100644 --- a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/controller/TenantController.java +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/controller/TenantController.java @@ -14,6 +14,7 @@ import fr.dossierfacile.common.entity.Property; import fr.dossierfacile.common.entity.Tenant; import fr.dossierfacile.common.service.interfaces.ProcessingCapacityService; +import io.swagger.annotations.*; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -42,26 +43,55 @@ public class TenantController { private final UserService userService; @GetMapping(value = "/profile", produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity profile(@RequestParam MultiValueMap params) { + @ApiOperation(value = "Get tenant profile", notes = "Retrieves the profile of the logged-in tenant.") + @ApiResponses(value = { + @ApiResponse(code = 200, message = "Profile retrieved successfully", response = TenantModel.class), + @ApiResponse(code = 401, message = "Unauthorized: JWT token missing or invalid"), + @ApiResponse(code = 403, message = "Forbidden: User not verified") + }) + public ResponseEntity profile( + @ApiParam(value = "UTM campaign parameters", example = "campaign=utm_campaign&source=utm_source&medium=utm_medium") + @RequestParam MultiValueMap params + ) { Tenant tenant = authenticationFacade.getLoggedTenant(AcquisitionData.from(params)); tenantService.updateLastLoginDateAndResetWarnings(tenant); return ok(tenantMapper.toTenantModel(tenant, null)); } + @GetMapping(value = "/property/{token}", produces = MediaType.APPLICATION_JSON_VALUE) + @ApiOperation(value = "Get property and owner information", notes = "Retrieves information about a property and its owner based on the provided token.") + @ApiResponses(value = { + @ApiResponse(code = 200, message = "Property information retrieved successfully", response = PropertyOModel.class), + @ApiResponse(code = 401, message = "Unauthorized: JWT token missing or invalid"), + @ApiResponse(code = 404, message = "Property not found") + }) public ResponseEntity getInfoOfPropertyAndOwner(@PathVariable("token") String propertyToken) { Property property = propertyService.getPropertyByToken(propertyToken); return ok(propertyMapper.toPropertyModel(property)); } @DeleteMapping("/deleteCoTenant/{id}") + @ApiOperation(value = "Delete a co-tenant", notes = "Deletes a co-tenant based on the provided ID.") + @ApiResponses(value = { + @ApiResponse(code = 200, message = "Co-tenant deleted successfully"), + @ApiResponse(code = 403, message = "Forbidden: user not verified or co-tenant not found"), + @ApiResponse(code = 401, message = "Unauthorized: JWT token missing or invalid") + }) public ResponseEntity deleteCoTenant(@PathVariable Long id) { Tenant tenant = authenticationFacade.getLoggedTenant(); return (userService.deleteCoTenant(tenant, id) ? ok() : status(HttpStatus.FORBIDDEN)).build(); } @PostMapping(value = "/linkFranceConnect", produces = MediaType.APPLICATION_JSON_VALUE) + @ApiOperation(value = "Link FranceConnect", notes = "Generates a link to FranceConnect based on the provided URL.") + @ApiResponses(value = { + @ApiResponse(code = 200, message = "FranceConnect link generated successfully"), + @ApiResponse(code = 400, message = "Bad request: URL is missing or invalid"), + @ApiResponse(code = 403, message = "Forbidden: JWT token missing or invalid") + }) public ResponseEntity linkFranceConnect(@RequestBody UrlForm urlDTO) { + // Todo : Could be replaced with @Valid annotation String currentUrl = urlDTO.getUrl(); if (currentUrl == null) { return badRequest().build(); @@ -71,10 +101,19 @@ public ResponseEntity linkFranceConnect(@RequestBody UrlForm urlDTO) { } @PostMapping(value = "/sendFileByMail", produces = MediaType.APPLICATION_JSON_VALUE) + @ApiOperation(value = "Send File By Mail", notes = "Sends a file by email based on the provided email and share type.") + @ApiResponses(value = { + @ApiResponse(code = 200, message = "File sent successfully"), + @ApiResponse(code = 400, message = "Bad request: email is invalid or limit reached"), + @ApiResponse(code = 403, message = "Forbidden: JWT token missing or invalid"), + @ApiResponse(code = 500, message = "Internal error: mail cannot be sent") + }) public ResponseEntity sendFileByMail(@RequestBody ShareFileByMailForm shareFileByMailForm) { + // Todo : Should add the @Valid annotation to match ShareFileByMailForm validations Tenant tenant = authenticationFacade.getLoggedTenant(); try { tenantService.sendFileByMail(tenant, shareFileByMailForm.getEmail(), shareFileByMailForm.getShareType()); + // Todo : inside the method sendFileByMail, there is an Internal error thrown and this exception is not caught by the controller } catch (Exception e) { return badRequest().build(); } @@ -83,12 +122,28 @@ public ResponseEntity sendFileByMail(@RequestBody ShareFileByMailForm sh @PreAuthorize("hasPermissionOnTenant(#tenantId)") @GetMapping(value = "/{id}/expectedProcessingTime", produces = MediaType.APPLICATION_JSON_VALUE) + @ApiOperation(value = "Get Expected Processing Time", notes = "Retrieves the expected processing time for a tenant based on the provided ID.") + @ApiResponses(value = { + @ApiResponse( + code = 200, + message = "Expected processing time retrieved successfully", + response = LocalDateTime.class, + examples = @Example(value = {@ExampleProperty(mediaType = "application/json", value = "null")})), + @ApiResponse(code = 401, message = "Unauthorized: JWT token missing or invalid"), + @ApiResponse(code = 403, message = "Forbidden: Access denied"), + }) public ResponseEntity expectedProcessingTime(@PathVariable("id") Long tenantId) { LocalDateTime expectedProcessingTime = processingCapacityService.getExpectedProcessingTime(tenantId); return ok(expectedProcessingTime); } - @GetMapping("/doNotArchive/{token}") + @GetMapping(value = "/doNotArchive/{token}", produces = MediaType.APPLICATION_JSON_VALUE) + @ApiOperation(value = "Do Not Archive", notes = "Prevents the archiving of a tenant based on the provided token.") + @ApiResponses(value = { + @ApiResponse(code = 200, message = "Token processed successfully"), + @ApiResponse(code = 401, message = "Unauthorized: JWT token missing or invalid"), + @ApiResponse(code = 404, message = "Token not found") + }) public ResponseEntity doNotArchive(@PathVariable String token) { tenantService.doNotArchive(token); return ok().build(); diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/controller/UserController.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/controller/UserController.java index b711bdcb4..c1f4b2b63 100644 --- a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/controller/UserController.java +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/controller/UserController.java @@ -7,16 +7,13 @@ import fr.dossierfacile.api.front.service.interfaces.UserService; import fr.dossierfacile.common.entity.Tenant; import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiResponse; +import io.swagger.annotations.ApiResponses; import lombok.AllArgsConstructor; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import static org.springframework.http.ResponseEntity.ok; @@ -30,23 +27,47 @@ public class UserController { private final AuthenticationFacade authenticationFacade; @PostMapping(value = "/forgotPassword", consumes = MediaType.APPLICATION_JSON_VALUE) + @ApiOperation(value = "Request a password reset", notes = "Sends a password reset email to the user.") + @ApiResponses(value = { + @ApiResponse(code = 200, message = ""), + @ApiResponse(code = 400, message = "Invalid email format"), + @ApiResponse(code = 404, message = "User not found"), + @ApiResponse(code = 403, message = "Forbidden: JWT token missing or invalid scope") + }) public ResponseEntity forgotPassword(@Validated @RequestBody EmailResetForm email) { userService.forgotPassword(email.getEmail()); return ok().build(); } @PostMapping(value = "/createPassword", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) - @ApiOperation("Set a new Password to logged user") + @ApiOperation(value = "Set a new Password to logged user", notes = "Sets a new password for the logged-in user.") + @ApiResponses(value = { + @ApiResponse(code = 200, message = "Password set successfully", response = TenantModel.class), + @ApiResponse(code = 400, message = "Invalid password format"), + @ApiResponse(code = 403, message = "Forbidden: User not verified or JWT token missing") + }) public ResponseEntity createPassword(@Validated @RequestBody PasswordForm password) { return ok(userService.createPassword(authenticationFacade.getLoggedTenant(), password.getPassword())); } @PostMapping(value = "/createPassword/{token}", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + @ApiOperation(value = "Set a new Password using token", notes = "Sets a new password using a provided token.") + @ApiResponses(value = { + @ApiResponse(code = 200, message = "Password set successfully", response = TenantModel.class), + @ApiResponse(code = 400, message = "Invalid token or password format"), + @ApiResponse(code = 403, message = "Forbidden: JWT token missing or invalid scope") + }) public ResponseEntity createPassword(@PathVariable String token, @Validated @RequestBody PasswordForm password) { return ok(userService.createPassword(token, password.getPassword())); } + @DeleteMapping("/deleteAccount") + @ApiOperation(value = "Delete the current user account") + @ApiResponses(value = { + @ApiResponse(code = 200, message = "Ok"), + @ApiResponse(code = 403, message = "Forbidden: User not verified or JWT token missing") + }) public ResponseEntity deleteAccount() { Tenant tenant = authenticationFacade.getLoggedTenant(); userService.deleteAccount(tenant); @@ -54,7 +75,11 @@ public ResponseEntity deleteAccount() { } @DeleteMapping("/franceConnect") - @ApiOperation("Unlink account from FranceConnect") + @ApiOperation(value = "Unlink account from FranceConnect", notes = "Unlinks the user's account from FranceConnect.") + @ApiResponses(value = { + @ApiResponse(code = 200, message = "Account unlinked successfully"), + @ApiResponse(code = 403, message = "Forbidden: User not verified or JWT token missing") + }) public ResponseEntity unlinkFranceConnect() { Tenant tenant = authenticationFacade.getLoggedTenant(); userService.unlinkFranceConnect(tenant); @@ -62,6 +87,11 @@ public ResponseEntity unlinkFranceConnect() { } @PostMapping("/logout") + @ApiOperation(value = "Logout the user", notes = "Logs out the user from the system.") + @ApiResponses(value = { + @ApiResponse(code = 200, message = "User logged out successfully"), + @ApiResponse(code = 403, message = "Forbidden: JWT token missing or invalid scope") + }) public ResponseEntity logout() { userService.logout(authenticationFacade.getKeycloakUserId()); return ok().build(); diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/dfc/controller/DfcSettingsController.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/dfc/controller/DfcSettingsController.java index 0e36ee557..f1d9193e7 100644 --- a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/dfc/controller/DfcSettingsController.java +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/dfc/controller/DfcSettingsController.java @@ -7,6 +7,8 @@ import fr.dossierfacile.api.front.service.interfaces.UserApiService; import fr.dossierfacile.common.entity.UserApi; import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiResponse; +import io.swagger.annotations.ApiResponses; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; @@ -25,15 +27,25 @@ public class DfcSettingsController { private UserApiService userApiService; private PartnerSettingsMapper partnerSettingsMapper; - @ApiOperation(value = "Get current partner settings") + @ApiOperation(value = "Get current partner settings", notes = "Retrieves the current settings for the authenticated partner.") + @ApiResponses(value = { + @ApiResponse(code = 200, message = "Settings retrieved successfully", response = PartnerSettings.class), + @ApiResponse(code = 401, message = "Unauthorized: JWT token missing or invalid"), + @ApiResponse(code = 403, message = "Forbidden: Insufficient scope") + }) @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity get() { UserApi userApi = clientAuthenticationFacade.getClient(); return ok(partnerSettingsMapper.toPartnerSettings(userApi)); } - @ApiOperation(value = "Update partner settings") - @PatchMapping(produces = MediaType.APPLICATION_JSON_VALUE) + @ApiOperation(value = "Update partner settings", notes = "Updates the settings for the authenticated partner.") + @ApiResponses(value = { + @ApiResponse(code = 200, message = "Settings updated successfully", response = PartnerSettings.class), + @ApiResponse(code = 401, message = "Unauthorized: JWT token missing or invalid"), + @ApiResponse(code = 403, message = "Forbidden: Insufficient scope") + }) + // Todo : Maybe add a @Valid annotation to validate the request body public ResponseEntity update(@RequestBody PartnerSettings settings) { UserApi userApi = clientAuthenticationFacade.getClient(); UserApi result = userApiService.update(userApi, settings); diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/log/GlobalExceptionHandler.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/log/GlobalExceptionHandler.java index 6dd39131d..7c374630b 100644 --- a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/log/GlobalExceptionHandler.java +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/log/GlobalExceptionHandler.java @@ -5,14 +5,22 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; @ControllerAdvice @Slf4j public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) - public ResponseEntity handleException(Exception e) { + public ResponseEntity handleException(Exception e) throws Exception { + log.error("Unhandled exception: ", e); + + ResponseStatus responseStatus = e.getClass().getAnnotation(ResponseStatus.class); + if (responseStatus != null) { + throw e; + } + return new ResponseEntity<>("Internal Server Error", HttpStatus.INTERNAL_SERVER_ERROR); } -} \ No newline at end of file +} diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/log/LogAggregationFilter.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/log/LogAggregationFilter.java index 8ec200596..67030efac 100644 --- a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/log/LogAggregationFilter.java +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/log/LogAggregationFilter.java @@ -12,6 +12,9 @@ import org.jetbrains.annotations.NotNull; import org.slf4j.LoggerFactory; import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -22,8 +25,9 @@ import java.util.List; import java.util.stream.Collectors; +// We register this appender only when the property "logging.logstash.destination" is set +@ConditionalOnExpression("!'${logging.logstash.destination:}'.empty") @Component -@Profile("!dev") public class LogAggregationFilter extends OncePerRequestFilter { private LogstashTcpSocketAppender logstashAppender; diff --git a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/security/AuthenticationFacadeImpl.java b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/security/AuthenticationFacadeImpl.java index 5a37f7df0..75521994c 100644 --- a/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/security/AuthenticationFacadeImpl.java +++ b/dossierfacile-api-tenant/src/main/java/fr/dossierfacile/api/front/security/AuthenticationFacadeImpl.java @@ -160,6 +160,7 @@ private Tenant synchronizeTenant(Tenant tenant, KeycloakUser user) { tenant.setFranceConnectBirthPlace(user.getFranceConnectBirthPlace()); tenant.setFranceConnectBirthDate(user.getFranceConnectBirthDate()); + // Todo : I think there an issue here according to comment on matches if (user.isFranceConnect()) { if (!StringUtils.equals(tenant.getFirstName(), user.getGivenName()) || !StringUtils.equals(tenant.getLastName(), user.getFamilyName()) @@ -178,11 +179,13 @@ private Tenant synchronizeTenant(Tenant tenant, KeycloakUser user) { return tenant; } + // Todo : This method should maybe return false if user is france connected and names are different (for the moment the first name and the lastname need to be different) private boolean matches(Tenant tenant, KeycloakUser user) { return StringUtils.equals(tenant.getKeycloakId(), user.getKeycloakId()) && StringUtils.equals(tenant.getEmail(), user.getEmail()) && tenant.getFranceConnect() == user.isFranceConnect() && (!user.isFranceConnect() || + // TODO : The || should be a && (StringUtils.equalsIgnoreCase(tenant.getFirstName(), user.getGivenName()) || StringUtils.equalsIgnoreCase(tenant.getLastName(), user.getFamilyName()) )); diff --git a/dossierfacile-api-tenant/src/main/resources/application.properties b/dossierfacile-api-tenant/src/main/resources/application.properties index 0ec58c92a..45a4aaa08 100644 --- a/dossierfacile-api-tenant/src/main/resources/application.properties +++ b/dossierfacile-api-tenant/src/main/resources/application.properties @@ -15,6 +15,8 @@ rabbitmq.exchange.pdf.generator=exchange.pdf.generator rabbitmq.routing.key.pdf.generator.watermark-document=routing.key.pdf.generator.watermark-document rabbitmq.routing.key.pdf.generator.apartment-sharing=routing.key.pdf.generator.apartment-sharing +resource.server.config.csp="frame-ancestors 'none'; frame-src 'none'; child-src 'none'; upgrade-insecure-requests; default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; img-src 'self' data:; font-src 'self'; connect-src * *.dossierfacile.fr *.dossierfacile.fr:*; base-uri 'self'; form-action 'none'; media-src 'none'; worker-src 'none'; manifest-src 'none'; prefetch-src 'none';" + spring.rabbitmq.username= spring.rabbitmq.password= spring.rabbitmq.host= diff --git a/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/TestApplication.java b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/TestApplication.java new file mode 100644 index 000000000..0e9564dc7 --- /dev/null +++ b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/TestApplication.java @@ -0,0 +1,16 @@ +package fr.dossierfacile.api.front; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootApplication +public class TestApplication { + + public static void main(String[] args) { + SpringApplication.run(TestApplication.class, args); + } +} diff --git a/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/controller/ApartmentSharingLinkControllerTest.java b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/controller/ApartmentSharingLinkControllerTest.java new file mode 100644 index 000000000..9ea3497b3 --- /dev/null +++ b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/controller/ApartmentSharingLinkControllerTest.java @@ -0,0 +1,391 @@ +package fr.dossierfacile.api.front.controller; + +import fr.dossierfacile.api.front.TestApplication; +import fr.dossierfacile.api.front.security.interfaces.AuthenticationFacade; +import fr.dossierfacile.api.front.service.interfaces.TenantService; +import fr.dossierfacile.common.entity.ApartmentSharing; +import fr.dossierfacile.common.entity.Tenant; +import fr.dossierfacile.common.exceptions.NotFoundException; +import fr.dossierfacile.common.model.ApartmentSharingLinkModel; +import fr.dossierfacile.common.service.ApartmentSharingLinkService; +import fr.dossierfacile.parameterizedtest.ArgumentBuilder; +import fr.dossierfacile.parameterizedtest.ControllerParameter; +import fr.dossierfacile.parameterizedtest.ParameterizedTestHelper; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.List; + +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + +@WebMvcTest(ApartmentSharingLinkController.class) +@ActiveProfiles("test") +@ContextConfiguration(classes = {TestApplication.class}) +public class ApartmentSharingLinkControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private static AuthenticationFacade authenticationFacade; + + @MockBean + private static ApartmentSharingLinkService apartmentSharingLinkService; + + @MockBean + private static TenantService tenantService; + + @Nested + class GetApartmentSharingLinksTests { + + record GetApartmentSharingLinksTestParameter() { + } + + static List provideGetApartmentSharingLinksParameters() { + + SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtTokenWithDossier = jwt().authorities(new SimpleGrantedAuthority("SCOPE_dossier")); + + ApartmentSharing apartmentSharing = new ApartmentSharing(); + List links = Collections.emptyList(); + + return ArgumentBuilder.buildListOfArguments( + Pair.of("Should respond 401 when no jwt is passed", + new ControllerParameter<>( + new GetApartmentSharingLinksTestParameter(), + 401, + null, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 200 when jwt is passed", + new ControllerParameter<>( + new GetApartmentSharingLinksTestParameter(), + 200, + jwtTokenWithDossier, + (v) -> { + when(authenticationFacade.getLoggedTenant()).thenReturn(Tenant.builder().apartmentSharing(apartmentSharing).build()); + when(apartmentSharingLinkService.getLinksByMail(apartmentSharing)).thenReturn(links); + return v; + }, + List.of( + jsonPath("$.links").isArray() + ) + ) + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideGetApartmentSharingLinksParameters") + void parameterizedTests(ControllerParameter parameter) throws Exception { + + var mockMvcRequestBuilder = get("/api/application/links") + .contentType("application/json"); + + ParameterizedTestHelper.runControllerTest( + mockMvc, + mockMvcRequestBuilder, + parameter + ); + } + } + + @Nested + class UpdateApartmentSharingLinksStatusTests { + + record UpdateApartmentSharingLinksStatusTestParameter(Long id, Boolean enabled) { + } + + static List provideUpdateApartmentSharingLinksStatusParameters() { + + SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtTokenWithDossier = jwt().authorities(new SimpleGrantedAuthority("SCOPE_dossier")); + + + ApartmentSharing apartmentSharing = new ApartmentSharing(); + + return ArgumentBuilder.buildListOfArguments( + Pair.of("Should respond 403 when no jwt is passed", + new ControllerParameter<>( + new UpdateApartmentSharingLinksStatusTestParameter(1L, true), + 403, + null, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 403 when email is not verified", + new ControllerParameter<>( + new UpdateApartmentSharingLinksStatusTestParameter(1L, true), + 403, + jwtTokenWithDossier, + (v) -> { + doThrow(new AccessDeniedException("User not verified")).when(authenticationFacade).getLoggedTenant(); + return v; + }, + List.of( + jsonPath("$.status").value("FORBIDDEN") + ) + ) + ), + Pair.of("Should respond 404 when link not found", + new ControllerParameter<>( + new UpdateApartmentSharingLinksStatusTestParameter(1L, true), + 404, + jwtTokenWithDossier, + (v) -> { + when(authenticationFacade.getLoggedTenant()).thenReturn(Tenant.builder().apartmentSharing(apartmentSharing).build()); + doThrow(new NotFoundException()).when(apartmentSharingLinkService).updateStatus(1L, true, apartmentSharing); + return v; + }, + Collections.emptyList() + ) + ), + Pair.of("Should respond 400 parameter enabled is missing", + new ControllerParameter<>( + new UpdateApartmentSharingLinksStatusTestParameter(1L, null), + 400, + jwtTokenWithDossier, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 200 when jwt is passed", + new ControllerParameter<>( + new UpdateApartmentSharingLinksStatusTestParameter(1L, true), + 200, + jwtTokenWithDossier, + (v) -> { + when(authenticationFacade.getLoggedTenant()).thenReturn(Tenant.builder().apartmentSharing(apartmentSharing).build()); + return v; + }, + Collections.emptyList() + ) + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideUpdateApartmentSharingLinksStatusParameters") + void parameterizedTests(ControllerParameter parameter) throws Exception { + + var url = "/api/application/links"; + if (parameter.parameterData.id != null) { + url += "/" + parameter.parameterData.id; + } + var mockMvcRequestBuilder = put(url) + .contentType("application/json"); + + if (parameter.parameterData.enabled != null) { + mockMvcRequestBuilder = mockMvcRequestBuilder.param("enabled", String.valueOf(parameter.parameterData.enabled)); + } + + ParameterizedTestHelper.runControllerTest( + mockMvc, + mockMvcRequestBuilder, + parameter + ); + } + } + + @Nested + class ResendApartmentSharingLinkTests { + + record ResendApartmentSharingLinkTestParameter() { + } + + static List provideResendApartmentSharingLinkParameters() { + + SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtTokenWithDossier = jwt().authorities(new SimpleGrantedAuthority("SCOPE_dossier")); + + Tenant tenant = new Tenant(); + + return ArgumentBuilder.buildListOfArguments( + Pair.of("Should respond 403 when no jwt is passed", + new ControllerParameter<>( + new ResendApartmentSharingLinkTestParameter(), + 403, + null, + null, + Collections.emptyList() + ) + ), + Pair.of("should respond 403 when email is not verified", + new ControllerParameter<>( + new ResendApartmentSharingLinkTestParameter(), + 403, + jwtTokenWithDossier, + (v) -> { + doThrow(new AccessDeniedException("User not verified")).when(authenticationFacade).getLoggedTenant(); + return v; + }, + List.of( + jsonPath("$.status").value("FORBIDDEN") + ) + ) + ), + Pair.of("should respond 403 when accessing other apartmentSharing", + new ControllerParameter<>( + new ResendApartmentSharingLinkTestParameter(), + 403, + jwtTokenWithDossier, + (v) -> { + when(authenticationFacade.getLoggedTenant()).thenReturn(tenant); + doThrow(new AccessDeniedException("Access Denied")).when(tenantService).resendLink(1L, tenant); + return v; + }, + Collections.emptyList() + ) + ), + Pair.of("should respond 500 when link is disabled", + new ControllerParameter<>( + new ResendApartmentSharingLinkTestParameter(), + 500, + jwtTokenWithDossier, + (v) -> { + when(authenticationFacade.getLoggedTenant()).thenReturn(tenant); + doThrow(new IllegalStateException("A disabled link cannot be sent")).when(tenantService).resendLink(1L, tenant); + return v; + }, + Collections.emptyList() + ) + ), + Pair.of("Should respond 500 when delay between 2 resend is too short", + new ControllerParameter<>( + new ResendApartmentSharingLinkTestParameter(), + 500, + jwtTokenWithDossier, + (v) -> { + when(authenticationFacade.getLoggedTenant()).thenReturn(tenant); + doThrow(new IllegalStateException("Delay between two resend is too short")).when(tenantService).resendLink(1L, tenant); + return v; + }, + Collections.emptyList() + ) + ), + Pair.of("Should respond 500 when error while sending mail", + new ControllerParameter<>( + new ResendApartmentSharingLinkTestParameter(), + 500, + jwtTokenWithDossier, + (v) -> { + when(authenticationFacade.getLoggedTenant()).thenReturn(tenant); + doThrow(new InternalError("Mail cannot be send - try later")).when(tenantService).resendLink(1L, tenant); + return v; + }, + Collections.emptyList() + ) + ), + Pair.of("Should respond 200 when jwt is passed", + new ControllerParameter<>( + new ResendApartmentSharingLinkTestParameter(), + 200, + jwtTokenWithDossier, + (v) -> { + when(authenticationFacade.getLoggedTenant()).thenReturn(tenant); + return v; + }, + Collections.emptyList() + ) + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideResendApartmentSharingLinkParameters") + void parameterizedTests(ControllerParameter parameter) throws Exception { + + var mockMvcRequestBuilder = post("/api/application/links/1/resend") + .contentType("application/json"); + + ParameterizedTestHelper.runControllerTest( + mockMvc, + mockMvcRequestBuilder, + parameter + ); + } + } + + @Nested + class DeleteApartmentSharingLinkTests { + + record DeleteApartmentSharingLinkTestParameter() { + } + + static List provideDeleteApartmentSharingLinkParameters() { + + SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtTokenWithDossier = jwt().authorities(new SimpleGrantedAuthority("SCOPE_dossier")); + + Tenant tenant = new Tenant(); + + return ArgumentBuilder.buildListOfArguments( + Pair.of("Should respond 403 when no jwt is passed", + new ControllerParameter<>( + new DeleteApartmentSharingLinkTestParameter(), + 403, + null, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 403 when email is not verified", + new ControllerParameter<>( + new DeleteApartmentSharingLinkTestParameter(), + 403, + jwtTokenWithDossier, + (v) -> { + doThrow(new AccessDeniedException("User not verified")).when(authenticationFacade).getLoggedTenant(); + return v; + }, + List.of( + jsonPath("$.status").value("FORBIDDEN") + ) + ) + ), + Pair.of("Should respond 200 when jwt is passed", + new ControllerParameter<>( + new DeleteApartmentSharingLinkTestParameter(), + 200, + jwtTokenWithDossier, + (v) -> { + when(authenticationFacade.getLoggedTenant()).thenReturn(tenant); + return v; + }, + Collections.emptyList() + ) + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideDeleteApartmentSharingLinkParameters") + void parameterizedTests(ControllerParameter parameter) throws Exception { + + var mockMvcRequestBuilder = delete("/api/application/links/1") + .contentType("application/json"); + + ParameterizedTestHelper.runControllerTest( + mockMvc, + mockMvcRequestBuilder, + parameter + ); + } + } +} \ No newline at end of file diff --git a/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/controller/TenantControllerTest.java b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/controller/TenantControllerTest.java new file mode 100644 index 000000000..4e635868a --- /dev/null +++ b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/controller/TenantControllerTest.java @@ -0,0 +1,661 @@ +package fr.dossierfacile.api.front.controller; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import fr.dossierfacile.api.front.TestApplication; +import fr.dossierfacile.api.front.exception.MailSentLimitException; +import fr.dossierfacile.api.front.exception.PropertyNotFoundException; +import fr.dossierfacile.api.front.mapper.PropertyOMapperImpl; +import fr.dossierfacile.api.front.mapper.TenantMapperImpl; +import fr.dossierfacile.api.front.security.interfaces.AuthenticationFacade; +import fr.dossierfacile.api.front.service.interfaces.PropertyService; +import fr.dossierfacile.api.front.service.interfaces.TenantService; +import fr.dossierfacile.api.front.service.interfaces.UserService; +import fr.dossierfacile.common.converter.AcquisitionData; +import fr.dossierfacile.common.entity.ApartmentSharing; +import fr.dossierfacile.common.entity.Property; +import fr.dossierfacile.common.entity.Tenant; +import fr.dossierfacile.common.enums.TenantFileStatus; +import fr.dossierfacile.common.mapper.VersionedCategoriesMapper; +import fr.dossierfacile.common.service.interfaces.ProcessingCapacityService; +import fr.dossierfacile.common.utils.LocalDateTimeTypeAdapter; +import fr.dossierfacile.parameterizedtest.ArgumentBuilder; +import fr.dossierfacile.parameterizedtest.ControllerParameter; +import fr.dossierfacile.parameterizedtest.ParameterizedTestHelper; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + +@WebMvcTest(TenantController.class) +@ActiveProfiles("test") +@ContextConfiguration(classes = {TestApplication.class, TenantMapperImpl.class, VersionedCategoriesMapper.class, PropertyOMapperImpl.class}) +@TestPropertySource(properties = {"application.api.version = 4"}) +public class TenantControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private static TenantService tenantService; + + @MockBean + private static ProcessingCapacityService processingCapacityService; + + @MockBean + private static PropertyService propertyService; + + @MockBean + private static AuthenticationFacade authenticationFacade; + + @MockBean + private static UserService userService; + + @Nested + class ProfileTest { + + record ProfileTestParameter(MultiValueMap params) { + } + + static List provideProfileParameters() { + SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtTokenWithDossier = jwt().authorities(new SimpleGrantedAuthority("SCOPE_dossier")); + + var apartmentSharing = ApartmentSharing.builder() + .id(1L) + .build(); + + var tenantWithoutCampaign = Tenant.builder() + .id(1L) + .email("test@test.fr") + .status(TenantFileStatus.VALIDATED) + .apartmentSharing(apartmentSharing) + .build(); + + apartmentSharing.setTenants(Collections.singletonList(tenantWithoutCampaign)); + + var emptyParams = new LinkedMultiValueMap(); + var emptyAcquisitionData = AcquisitionData.builder().build(); + + var utmParams = new LinkedMultiValueMap(); + utmParams.add("campaign", "utm_campaign"); + utmParams.add("source", "utm_source"); + utmParams.add("medium", "utm_medium"); + + var utmAcquisitionData = AcquisitionData.builder() + .campaign("utm_campaign") + .medium("utm_medium") + .source("utm_source") + .build(); + + return ArgumentBuilder.buildListOfArguments( + Pair.of("Should respond 401 when not jwt is passed", + // Todo : investigate why this request return a 401 instead of a 403 + new ControllerParameter<>( + new ProfileTestParameter(emptyParams), + 401, + null, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 200 when jwt is passed", + new ControllerParameter<>( + new ProfileTestParameter(emptyParams), + 200, + jwtTokenWithDossier, + (v) -> { + when(authenticationFacade.getLoggedTenant(emptyAcquisitionData)).thenReturn(tenantWithoutCampaign); + return v; + }, + List.of( + jsonPath("$.id").value(tenantWithoutCampaign.getId()), + jsonPath("$.email").value(tenantWithoutCampaign.getEmail()) + ) + ) + ), + Pair.of("Should respond 200 when jwt is passed and Utm campaign", + new ControllerParameter<>( + new ProfileTestParameter(utmParams), + 200, + jwtTokenWithDossier, + (v) -> { + when(authenticationFacade.getLoggedTenant(utmAcquisitionData)).thenReturn(tenantWithoutCampaign); + return v; + }, + List.of( + jsonPath("$.id").value(tenantWithoutCampaign.getId()), + jsonPath("$.email").value(tenantWithoutCampaign.getEmail()) + ) + ) + ), + Pair.of("Should respond 403 when user is not verified", + new ControllerParameter<>( + new ProfileTestParameter(utmParams), + 403, + jwtTokenWithDossier, + (v) -> { + doThrow(new AccessDeniedException("User not verified")).when(authenticationFacade).getLoggedTenant(utmAcquisitionData); + return v; + }, + List.of( + jsonPath("$.status").value("FORBIDDEN") + ) + ) + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideProfileParameters") + void parameterizedTests(ControllerParameter parameter) throws Exception { + + var mockMvcRequestBuilder = get("/api/tenant/profile") + .contentType("application/json"); + + if (parameter.parameterData.params != null) { + mockMvcRequestBuilder.params(parameter.parameterData.params); + } + + ParameterizedTestHelper.runControllerTest( + mockMvc, + mockMvcRequestBuilder, + parameter + ); + } + + + } + + @Nested + class getInfoOfPropertyAndOwnerTest { + + record GetInfoOfPropertyAndOwnerParameter(String token) { + } + + static List provideParameters() { + + SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtTokenWithDossier = jwt().authorities(new SimpleGrantedAuthority("SCOPE_dossier")); + + var property = Property.builder() + .id(1L) + .address("test") + .name("test") + .build(); + + return ArgumentBuilder.buildListOfArguments( + Pair.of("Should respond 401 when not jwt is passed", + // Todo : investigate why this request return a 401 instead of a 403 + new ControllerParameter<>( + null, + 401, + null, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 404 when no token is passed", + new ControllerParameter<>( + null, + 404, + jwtTokenWithDossier, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 404 when property is not found", + new ControllerParameter<>( + new GetInfoOfPropertyAndOwnerParameter("token"), + 404, + jwtTokenWithDossier, + (v) -> { + doThrow(new PropertyNotFoundException("token")).when(propertyService).getPropertyByToken("token"); + return v; + }, + Collections.emptyList() + ) + ), + Pair.of("Should respond 200 with propertyInformations", + new ControllerParameter<>( + new GetInfoOfPropertyAndOwnerParameter("token"), + 200, + jwtTokenWithDossier, + (v) -> { + when(propertyService.getPropertyByToken("token")).thenReturn(property); + return v; + }, + List.of( + jsonPath("$.id").value(1), + jsonPath("$.address").value("test"), + jsonPath("$.name").value("test") + ) + ) + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideParameters") + void parameterizedTests(ControllerParameter parameter) throws Exception { + + var urlTemplate = "/api/tenant/property/"; + if (parameter.parameterData != null) { + urlTemplate += parameter.parameterData.token; + } + + var mockMvcRequestBuilder = get(urlTemplate) + .contentType("application/json"); + + ParameterizedTestHelper.runControllerTest( + mockMvc, + mockMvcRequestBuilder, + parameter + ); + } + } + + @Nested + class deleteCoTenantTest { + + record DeleteTestParam(Long id) { + } + + static List provideDeleteCoTentParameters() { + SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtTokenWithDossier = jwt().authorities(new SimpleGrantedAuthority("SCOPE_dossier")); + + var tenant = Tenant.builder().id(1L).build(); + + return ArgumentBuilder.buildListOfArguments( + Pair.of("Should respond 403 when not jwt is passed", + new ControllerParameter<>( + new DeleteTestParam(1L), + 403, + null, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 403 if current user email is not verified", + new ControllerParameter<>( + new DeleteTestParam(1L), + 403, + jwtTokenWithDossier, + (v) -> { + doThrow(new AccessDeniedException("User not verified")).when(authenticationFacade).getLoggedTenant(); + return v; + }, + Collections.emptyList() + ) + ), + Pair.of("Should respond 200 with empty body", + new ControllerParameter<>( + new DeleteTestParam(1L), + 200, + jwtTokenWithDossier, + (v) -> { + when(authenticationFacade.getLoggedTenant()).thenReturn(tenant); + when(userService.deleteCoTenant(tenant, 1L)).thenReturn(true); + return v; + }, + List.of(content().bytes(new byte[0])) + + ) + ), + Pair.of("Should respond 403 when coTenant is not found", + new ControllerParameter<>( + new DeleteTestParam(1L), + 403, + jwtTokenWithDossier, + (v) -> { + when(authenticationFacade.getLoggedTenant()).thenReturn(tenant); + when(userService.deleteCoTenant(tenant, 1L)).thenReturn(false); + return v; + }, + Collections.emptyList() + + ) + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideDeleteCoTentParameters") + void parameterizedTests(ControllerParameter parameter) throws Exception { + + var urlTemplate = "/api/tenant/deleteCoTenant/"; + if (parameter.parameterData != null) { + urlTemplate += parameter.parameterData.id; + } + + var mockMvcRequestBuilder = delete(urlTemplate) + .contentType("application/json"); + + ParameterizedTestHelper.runControllerTest( + mockMvc, + mockMvcRequestBuilder, + parameter + ); + } + + } + + @Nested + class linkFranceConnectTest { + + record LinkFranceConnectParam(String url) { + } + + static List provideFranceConnectParameters() { + SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtTokenWithDossier = jwt().authorities(new SimpleGrantedAuthority("SCOPE_dossier")); + + return ArgumentBuilder.buildListOfArguments( + Pair.of("Should respond 403 when not jwt is passed", + new ControllerParameter<>( + new LinkFranceConnectParam("test.com"), + 403, + null, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 400 bad request when no url is passed", + new ControllerParameter<>( + new LinkFranceConnectParam(null), + 400, + jwtTokenWithDossier, + null, + Collections.emptyList() + ) + ), + Pair.of("Should return france connect link url", + new ControllerParameter( + new LinkFranceConnectParam("test.com"), + 200, + jwtTokenWithDossier, + (v) -> { + when(authenticationFacade.getFranceConnectLink("test.com")).thenReturn("test.com"); + return v; + }, + List.of( + jsonPath("$").value("test.com") + ) + ) + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideFranceConnectParameters") + void parameterizedTests(ControllerParameter parameter) throws Exception { + + var mockMvcRequestBuilder = post("/api/tenant/linkFranceConnect") + .contentType("application/json"); + + if (parameter.parameterData.url != null) { + mockMvcRequestBuilder.content("{\"url\":\"" + parameter.parameterData.url + "\"}"); + } + + ParameterizedTestHelper.runControllerTest( + mockMvc, + mockMvcRequestBuilder, + parameter + ); + } + + } + + @Nested + class SendFileByMailTest { + + record SendFileByMailParam(String email, String shareType) { + } + + static List provideSendFileByMailParameters() { + SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtTokenWithDossier = jwt().authorities(new SimpleGrantedAuthority("SCOPE_dossier")); + + var tenant = Tenant.builder().id(1L).build(); + + return ArgumentBuilder.buildListOfArguments( + Pair.of("Should respond 403 when not jwt is passed", + new ControllerParameter<>( + new SendFileByMailParam("test@test.com", "SHARE_TYPE"), + 403, + null, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 500 when internal error on sendFileByMail", + new ControllerParameter<>( + new SendFileByMailParam(null, "SHARE_TYPE"), + 500, + jwtTokenWithDossier, + (v) -> { + when(authenticationFacade.getLoggedTenant()).thenReturn(tenant); + doThrow(new InternalError("Mail cannot be send - try later")).when(tenantService).sendFileByMail(tenant, null, "SHARE_TYPE"); + return v; + }, + Collections.emptyList() + ) + ), + Pair.of("Should respond 400 when email limit reached", + new ControllerParameter<>( + new SendFileByMailParam("test@test.com", "SHARE_TYPE"), + 400, + jwtTokenWithDossier, + (v) -> { + when(authenticationFacade.getLoggedTenant()).thenReturn(tenant); + doThrow(new MailSentLimitException()).when(tenantService).sendFileByMail(tenant, "test@test.com", "SHARE_TYPE"); + return v; + }, + Collections.emptyList() + ) + ), + /*Pair.of("Should respond 400 when invalid payload", + new ControllerParameter<>( + new SendFileByMailParam(null, null), + 400, + jwtTokenWithDossier, + (v) -> { + when(authenticationFacade.getLoggedTenant()).thenReturn(tenant); + doThrow(new MailSentLimitException()).when(tenantService).sendFileByMail(tenant, "test@test.com", "SHARE_TYPE"); + return v; + }, + Collections.emptyList() + ) + ),*/ + Pair.of("Should respond 200 when file is sent successfully", + new ControllerParameter<>( + new SendFileByMailParam("test@test.com", "SHARE_TYPE"), + 200, + jwtTokenWithDossier, + (v) -> { + when(authenticationFacade.getLoggedTenant()).thenReturn(tenant); + return v; + }, + List.of(content().bytes(new byte[0])) + ) + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideSendFileByMailParameters") + void parameterizedTests(ControllerParameter parameter) throws Exception { + + var mockMvcRequestBuilder = post("/api/tenant/sendFileByMail") + .contentType("application/json"); + + if (parameter.parameterData != null) { + Gson gson = new Gson(); + mockMvcRequestBuilder.content(gson.toJson(parameter.parameterData)); + } + + ParameterizedTestHelper.runControllerTest( + mockMvc, + mockMvcRequestBuilder, + parameter + ); + } + } + + @Nested + class ExpectedProcessingTimeTest { + + record ExpectedProcessingTimeParam(Long tenantId) { + } + + static List provideExpectedProcessingTimeParameters() { + SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtTokenWithDossier = jwt().authorities(new SimpleGrantedAuthority("SCOPE_dossier")); + + var tenantId = 1L; + var expectedProcessingTime = LocalDateTime.now(); + + Gson gson = new GsonBuilder().registerTypeAdapter(LocalDateTime.class, new LocalDateTimeTypeAdapter()).create(); + + return ArgumentBuilder.buildListOfArguments( + // Todo : investigate why 401 + Pair.of("Should respond 401 when not jwt is passed", + new ControllerParameter<>( + new ExpectedProcessingTimeParam(tenantId), + 401, + null, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 200 with expected processing time", + new ControllerParameter<>( + new ExpectedProcessingTimeParam(tenantId), + 200, + jwtTokenWithDossier, + (v) -> { + when(processingCapacityService.getExpectedProcessingTime(tenantId)).thenReturn(expectedProcessingTime); + return v; + }, + List.of( + // We do not test the exact date because the json serializer of spring boot does not serialize the date in the same way + content().string(gson.toJson(expectedProcessingTime)) + ) + ) + ), + Pair.of("Should respond 200 with null when expected processing time is not available", + new ControllerParameter<>( + new ExpectedProcessingTimeParam(tenantId), + 200, + jwtTokenWithDossier, + (v) -> { + when(processingCapacityService.getExpectedProcessingTime(tenantId)).thenReturn(null); + return v; + }, + List.of( + content().bytes(new byte[0]) + ) + ) + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideExpectedProcessingTimeParameters") + void parameterizedTests(ControllerParameter parameter) throws Exception { + + var urlTemplate = "/api/tenant/" + parameter.parameterData.tenantId + "/expectedProcessingTime"; + + var mockMvcRequestBuilder = get(urlTemplate) + .contentType("application/json"); + + ParameterizedTestHelper.runControllerTest( + mockMvc, + mockMvcRequestBuilder, + parameter + ); + } + } + + @Nested + class DoNotArchiveTest { + + record DoNotArchiveParam(String token) { + } + + static List provideDoNotArchiveParameters() { + SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtTokenWithDossier = jwt().authorities(new SimpleGrantedAuthority("SCOPE_dossier")); + + return ArgumentBuilder.buildListOfArguments( + Pair.of("Should respond 401 when not jwt is passed", + new ControllerParameter<>( + new DoNotArchiveParam("token"), + 401, + null, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 404 when token is invalid", + new ControllerParameter<>( + new DoNotArchiveParam(null), + 404, + jwtTokenWithDossier, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 200 when token is valid", + new ControllerParameter<>( + new DoNotArchiveParam("token"), + 200, + jwtTokenWithDossier, + (v) -> { + doNothing().when(tenantService).doNotArchive("token"); + return v; + }, + List.of(content().bytes(new byte[0])) + ) + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideDoNotArchiveParameters") + void parameterizedTests(ControllerParameter parameter) throws Exception { + + var urlTemplate = "/api/tenant/doNotArchive/"; + if (parameter.parameterData.token != null) { + urlTemplate += parameter.parameterData.token; + } + + var mockMvcRequestBuilder = get(urlTemplate) + .contentType("application/json"); + + ParameterizedTestHelper.runControllerTest( + mockMvc, + mockMvcRequestBuilder, + parameter + ); + } + } + +} diff --git a/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/controller/UserControllerTest.java b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/controller/UserControllerTest.java new file mode 100644 index 000000000..0bd6ba8b4 --- /dev/null +++ b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/controller/UserControllerTest.java @@ -0,0 +1,554 @@ +package fr.dossierfacile.api.front.controller; + +import fr.dossierfacile.api.front.TestApplication; +import fr.dossierfacile.api.front.exception.PasswordRecoveryTokenNotFoundException; +import fr.dossierfacile.api.front.exception.UserNotFoundException; +import fr.dossierfacile.api.front.model.tenant.TenantModel; +import fr.dossierfacile.api.front.security.interfaces.AuthenticationFacade; +import fr.dossierfacile.api.front.service.interfaces.UserService; +import fr.dossierfacile.common.entity.Tenant; +import fr.dossierfacile.parameterizedtest.ArgumentBuilder; +import fr.dossierfacile.parameterizedtest.ControllerParameter; +import fr.dossierfacile.parameterizedtest.ParameterizedTestHelper; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(UserController.class) +@ActiveProfiles("test") +@ContextConfiguration(classes = {TestApplication.class}) +public class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private static UserService userService; + + @MockBean + private static AuthenticationFacade authenticationFacade; + + @Test + void shouldReturnNotFoundForInvalidUrl() throws Exception { + mockMvc.perform( + get("/api/user/invalidUrl").with(jwt()) + ).andDo(print()).andExpect(status().is(404)); + } + + + @Nested + class ForgotPasswordTests { + + record ForgotPasswordTestParameter(String email) { + } + + static List provideForgotPasswordParameters() { + + SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtTokenWithDossier = jwt().authorities(new SimpleGrantedAuthority("SCOPE_dossier")); + + return ArgumentBuilder.buildListOfArguments( + Pair.of("Should respond 403 when not jwt is passed", + new ControllerParameter<>( + new ForgotPasswordTestParameter("test@test.fr"), + 403, + null, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 400 when no email is passed", + new ControllerParameter<>( + new ForgotPasswordTestParameter(null), + 400, + jwtTokenWithDossier, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 400 when invalid email is passed", + new ControllerParameter<>( + new ForgotPasswordTestParameter("test"), + 400, + jwtTokenWithDossier, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 404 when valid email is passed but user is not found", + new ControllerParameter<>( + new ForgotPasswordTestParameter("test@test.fr"), + 404, + jwtTokenWithDossier, + (v) -> { + doThrow(new UserNotFoundException("test@test.fr")).when(userService).forgotPassword(any()); + return v; + }, + List.of(content().bytes(new byte[0])) + ) + ), + Pair.of("Should respond 200 when valid email is passed but user is not found", + new ControllerParameter<>( + new ForgotPasswordTestParameter("test@test.fr"), + 200, + jwtTokenWithDossier, + null, + List.of(content().bytes(new byte[0])) + ) + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideForgotPasswordParameters") + void parameterizedTests(ControllerParameter parameter) throws Exception { + + var mockMvcRequestBuilder = post("/api/user/forgotPassword") + .contentType("application/json"); + + if (parameter.parameterData.email != null) { + mockMvcRequestBuilder.content("{\"email\": \"" + parameter.parameterData.email + "\"}"); + } + + ParameterizedTestHelper.runControllerTest( + mockMvc, + mockMvcRequestBuilder, + parameter + ); + } + + } + + @Nested + class CreatePasswordTests { + + record CreatePasswordTestParameter(String password) { + } + + static List provideCreatePasswordParameters() { + + SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtTokenWithDossier = jwt().authorities(new SimpleGrantedAuthority("SCOPE_dossier")); + + var validPassword = "azerty"; + Tenant tenant = Tenant.builder() + .id(1L) + .email("test@test.fr") + .build(); + + TenantModel tenantModel = TenantModel.builder() + .id(1L) + .email("test@test.fr") + .build(); + + return ArgumentBuilder.buildListOfArguments( + Pair.of("Should respond 403 when not jwt is passed", + new ControllerParameter<>( + new CreatePasswordTestParameter("azerty"), + 403, + null, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 400 when no password is passed", + new ControllerParameter<>( + new CreatePasswordTestParameter(null), + 403, + null, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 400 when empty password is passed", + new ControllerParameter<>( + new CreatePasswordTestParameter(""), + 403, + null, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 403 when user is not verified", + new ControllerParameter<>( + new CreatePasswordTestParameter(validPassword), + 403, + jwtTokenWithDossier, + (v) -> { + doThrow(new AccessDeniedException("User not verified")).when(authenticationFacade).getLoggedTenant(); + return v; + }, + List.of( + jsonPath("$.status").value("FORBIDDEN") + ) + ) + ), + Pair.of("Should respond 200 when user is verified", + new ControllerParameter<>( + new CreatePasswordTestParameter(validPassword), + 200, + jwtTokenWithDossier, + (v) -> { + when(authenticationFacade.getLoggedTenant()).thenReturn(tenant); + when(userService.createPassword(tenant, validPassword)).thenReturn(tenantModel); + return v; + }, + List.of( + jsonPath("$.id").value(1), + jsonPath("$.email").value("test@test.fr") + ) + ) + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideCreatePasswordParameters") + void parameterizedTests(ControllerParameter parameter) throws Exception { + + var mockMvcRequestBuilder = post("/api/user/createPassword") + .contentType("application/json"); + + if (parameter.parameterData.password != null) { + mockMvcRequestBuilder.content("{\"password\": \"" + parameter.parameterData.password + "\"}"); + } + + ParameterizedTestHelper.runControllerTest( + mockMvc, + mockMvcRequestBuilder, + parameter + ); + } + + } + + + @Nested + class CreatePasswordWithTokenTests { + + record CreatePasswordWithTokenParameter(String token, String password) { + } + + static List provideCreatePasswordWithTokenParameters() { + + SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtTokenWithDossier = jwt().authorities(new SimpleGrantedAuthority("SCOPE_dossier")); + + var invalidToken = "invalid"; + var validToken = "test"; + var validPassword = "azerty"; + + TenantModel tenantModel = TenantModel.builder().id(1L).email("test@test.fr").build(); + + return ArgumentBuilder.buildListOfArguments( + Pair.of("Should respond 403 when not jwt is passed", + new ControllerParameter<>( + new CreatePasswordWithTokenParameter(null, null), + 403, + null, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 400 when no token is passed", + new ControllerParameter<>( + new CreatePasswordWithTokenParameter(null, null), + 400, + jwtTokenWithDossier, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 400 when no password is passed", + new ControllerParameter<>( + new CreatePasswordWithTokenParameter(validToken, null), + 400, + jwtTokenWithDossier, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 400 when invalid password is passed", + new ControllerParameter<>( + new CreatePasswordWithTokenParameter(validToken, ""), + 400, + jwtTokenWithDossier, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 404 when token is not found", + new ControllerParameter<>( + new CreatePasswordWithTokenParameter(invalidToken, validPassword), + 404, + jwtTokenWithDossier, + (v) -> { + doThrow(new PasswordRecoveryTokenNotFoundException(invalidToken)).when(userService).createPassword(invalidToken, validPassword); + return v; + }, + Collections.emptyList() + ) + ), + Pair.of("Should respond 200 and tenant model when token is founded", + new ControllerParameter<>( + new CreatePasswordWithTokenParameter(validToken, validPassword), + 200, + jwtTokenWithDossier, + (v) -> { + when(userService.createPassword(validToken, validPassword)).thenReturn(tenantModel); + return v; + }, + List.of( + jsonPath("$.id").value(1), + jsonPath("$.email").value(tenantModel.getEmail()) + ) + ) + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideCreatePasswordWithTokenParameters") + void parameterizedTests(ControllerParameter parameter) throws Exception { + + var mockMvcRequestBuilder = post("/api/user/createPassword/" + parameter.parameterData.token) + .contentType("application/json"); + + if (parameter.parameterData.password != null) { + mockMvcRequestBuilder.content("{\"password\": \"" + parameter.parameterData.password + "\"}"); + } + + ParameterizedTestHelper.runControllerTest( + mockMvc, + mockMvcRequestBuilder, + parameter + ); + } + + } + + + @Nested + class DeleteAccountTests { + + static List provideDeleteAccountParameters() { + + SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtTokenWithDossier = jwt().authorities(new SimpleGrantedAuthority("SCOPE_dossier")); + + Tenant tenant = Tenant.builder() + .id(1L) + .email("test@test.fr") + .build(); + + return ArgumentBuilder.buildListOfArguments( + Pair.of("Should respond 403 when not jwt is passed", + new ControllerParameter<>( + null, + 403, + null, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 403 when user in not verified", + new ControllerParameter<>( + null, + 403, + jwtTokenWithDossier, + (v) -> { + doThrow(new AccessDeniedException("User not verified")).when(authenticationFacade).getLoggedTenant(); + return v; + }, + List.of( + jsonPath("$.status").value("FORBIDDEN") + ) + ) + ), + Pair.of("Should respond 403 when user in not verified", + new ControllerParameter<>( + null, + 403, + jwtTokenWithDossier, + (v) -> { + doThrow(new AccessDeniedException("User not verified")).when(authenticationFacade).getLoggedTenant(); + return v; + }, + List.of( + jsonPath("$.status").value("FORBIDDEN") + ) + ) + ), + Pair.of("Should respond 200 with empty result", + new ControllerParameter<>( + null, + 200, + jwtTokenWithDossier, + (v) -> { + when(authenticationFacade.getLoggedTenant()).thenReturn(tenant); + return v; + }, + List.of( + content().bytes(new byte[0]) + ) + ) + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideDeleteAccountParameters") + void parameterizedTest(ControllerParameter parameter) throws Exception { + + var mockMvcRequestBuilder = delete("/api/user/deleteAccount") + .contentType("application/json"); + + ParameterizedTestHelper.runControllerTest( + mockMvc, + mockMvcRequestBuilder, + parameter + ); + } + + } + + + @Nested + class UnlinkFranceConnectTests { + + static List provideUnlinkFranceConnectParameters() { + + SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtTokenWithDossier = jwt().authorities(new SimpleGrantedAuthority("SCOPE_dossier")); + + Tenant tenant = Tenant.builder() + .id(1L) + .email("test@test.fr") + .build(); + + return ArgumentBuilder.buildListOfArguments( + Pair.of("Should respond 403 when no jwt is passed", + new ControllerParameter<>( + null, + 403, + null, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 403 when user is not verified", + new ControllerParameter<>( + null, + 403, + jwtTokenWithDossier, + (v) -> { + doThrow(new AccessDeniedException("User not verified")).when(authenticationFacade).getLoggedTenant(); + return v; + }, + List.of( + jsonPath("$.status").value("FORBIDDEN") + ) + ) + ), + Pair.of("Should respond 200 when user is verified", + new ControllerParameter<>( + null, + 200, + jwtTokenWithDossier, + (v) -> { + when(authenticationFacade.getLoggedTenant()).thenReturn(tenant); + return v; + }, + List.of( + content().bytes(new byte[0]) + ) + ) + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideUnlinkFranceConnectParameters") + void parameterizedTests(ControllerParameter parameter) throws Exception { + + var mockMvcRequestBuilder = delete("/api/user/franceConnect") + .contentType("application/json"); + + ParameterizedTestHelper.runControllerTest( + mockMvc, + mockMvcRequestBuilder, + parameter + ); + } + } + + + @Nested + class LogoutTests { + + static List provideLogoutParameters() { + + SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtTokenWithDossier = jwt().authorities(new SimpleGrantedAuthority("SCOPE_dossier")); + + return ArgumentBuilder.buildListOfArguments( + Pair.of("Should respond 403 when no jwt is passed", + new ControllerParameter<>( + null, + 403, + null, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 200 when jwt is passed", + new ControllerParameter<>( + null, + 200, + jwtTokenWithDossier, + (v) -> { + when(authenticationFacade.getKeycloakUserId()).thenReturn("keycloakId"); + return v; + }, + List.of( + content().bytes(new byte[0]) + ) + ) + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideLogoutParameters") + void parameterizedTests(ControllerParameter parameter) throws Exception { + + var mockMvcRequestBuilder = post("/api/user/logout") + .contentType("application/json"); + + ParameterizedTestHelper.runControllerTest( + mockMvc, + mockMvcRequestBuilder, + parameter + ); + } + } + + +} diff --git a/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/dfc/controller/DfcSettingsControllerTest.java b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/dfc/controller/DfcSettingsControllerTest.java new file mode 100644 index 000000000..d643d4add --- /dev/null +++ b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/dfc/controller/DfcSettingsControllerTest.java @@ -0,0 +1,246 @@ +package fr.dossierfacile.api.front.dfc.controller; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.gson.Gson; +import fr.dossierfacile.api.front.TestApplication; +import fr.dossierfacile.api.front.config.ResourceServerConfig; +import fr.dossierfacile.api.front.mapper.PartnerSettingsMapperImpl; +import fr.dossierfacile.api.front.model.dfc.PartnerSettings; +import fr.dossierfacile.api.front.security.interfaces.ClientAuthenticationFacade; +import fr.dossierfacile.api.front.service.interfaces.UserApiService; +import fr.dossierfacile.common.entity.UserApi; +import fr.dossierfacile.parameterizedtest.ArgumentBuilder; +import fr.dossierfacile.parameterizedtest.ControllerParameter; +import fr.dossierfacile.parameterizedtest.ParameterizedTestHelper; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +import static fr.dossierfacile.authentification.JwtFactoryKt.getDummyJwtWithCustomClaims; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; + +@WebMvcTest(DfcSettingsController.class) +@ActiveProfiles("test") +@ContextConfiguration(classes = {TestApplication.class, PartnerSettingsMapperImpl.class, ResourceServerConfig.class, PartnerSettings.class}) +public class DfcSettingsControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private static ClientAuthenticationFacade clientAuthenticationFacade; + + @MockBean + private static UserApiService userApiService; + + @MockBean + private JwtDecoder jwtDecoder; + + private static ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach() + void beforeEach() { + reset(clientAuthenticationFacade, userApiService); + } + + @Nested + class GetSettingsTests { + + static List provideGetSettingsParameters() throws JsonProcessingException { + var claimsMap = new HashMap(); + claimsMap.put("client_id", "client_id"); + var jwt = getDummyJwtWithCustomClaims(claimsMap); + + SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtTokenWithDfc = jwt().authorities(new SimpleGrantedAuthority("SCOPE_dfc")).jwt(jwt); + SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtWithWrongScope = jwt().authorities(new SimpleGrantedAuthority("SCOPE_dossier")).jwt(jwt); + + UserApi userApi = UserApi.builder() + .id(1L) + .name("test") + .version(1) + .email("test@test.fr") + .urlCallback("http://localhost") + .partnerApiKeyCallback("test") + .build(); + + PartnerSettings partnerSettings = new PartnerSettings(); + partnerSettings.setEmail("test@test.fr"); + partnerSettings.setUrlCallback("http://localhost"); + partnerSettings.setPartnerApiKeyCallback("test"); + partnerSettings.setVersion(1); + partnerSettings.setName("test"); + + var expectedJson = mapper.writeValueAsString(partnerSettings); + + return ArgumentBuilder.buildListOfArguments( + Pair.of("Should respond 401 when no jwt is passed", + new ControllerParameter<>( + null, + 401, + null, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 403 when wrong scope is passed", + new ControllerParameter<>( + null, + 403, + jwtWithWrongScope, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 200 when jwt is passed", + new ControllerParameter<>( + null, + 200, + jwtTokenWithDfc, + (v) -> { + when(clientAuthenticationFacade.getClient()).thenReturn(userApi); + return v; + }, + List.of( + content().json(expectedJson) + ) + ) + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideGetSettingsParameters") + void parameterizedTests(ControllerParameter parameter) throws Exception { + + var mockMvcRequestBuilder = get("/dfc/api/v1/settings") + .contentType("application/json"); + + ParameterizedTestHelper.runControllerTest( + mockMvc, + mockMvcRequestBuilder, + parameter + ); + } + } + + @Nested + class UpdateSettingsTests { + + record UpdateSettingsTestParameter(PartnerSettings settings) { + } + + static List provideUpdateSettingsParameters() throws JsonProcessingException { + + var claimsMap = new HashMap(); + claimsMap.put("client_id", "client_id"); + var jwt = getDummyJwtWithCustomClaims(claimsMap); + + SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtTokenWithDfc = jwt().authorities(new SimpleGrantedAuthority("SCOPE_dfc")).jwt(jwt); + SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor jwtWithWrongScope = jwt().authorities(new SimpleGrantedAuthority("SCOPE_dossier")).jwt(jwt); + + UserApi userApi = UserApi.builder() + .id(1L) + .name("test") + .version(1) + .email("test@test.fr") + .urlCallback("http://localhost") + .partnerApiKeyCallback("test") + .build(); + + PartnerSettings partnerSettings = new PartnerSettings(); + partnerSettings.setName("test"); + partnerSettings.setVersion(2); + partnerSettings.setEmail("test@test.fr"); + partnerSettings.setUrlCallback("http://localhost"); + partnerSettings.setPartnerApiKeyCallback("test2"); + + var expectedUserApiModified = UserApi.builder() + .id(1L) + .name("test") + .version(2) + .email("test@test.fr") + .urlCallback("http://localhost") + .partnerApiKeyCallback("test2") + .build(); + + var expectedJson = mapper.writeValueAsString(partnerSettings); + + return ArgumentBuilder.buildListOfArguments( + Pair.of("Should respond 401 when no jwt is passed", + new ControllerParameter<>( + new UpdateSettingsTestParameter(partnerSettings), + 401, + null, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 403 when not the good scope", + new ControllerParameter<>( + new UpdateSettingsTestParameter(partnerSettings), + 403, + jwtWithWrongScope, + null, + Collections.emptyList() + ) + ), + Pair.of("Should respond 200 when jwt is passed", + new ControllerParameter<>( + new UpdateSettingsTestParameter(partnerSettings), + 200, + jwtTokenWithDfc, + (v) -> { + when(clientAuthenticationFacade.getClient()).thenReturn(userApi); + when(userApiService.update(any(), any())).thenReturn(expectedUserApiModified); + return v; + }, + List.of( + content().json(expectedJson) + ) + ) + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideUpdateSettingsParameters") + void parameterizedTests(ControllerParameter parameter) throws Exception { + + var mockMvcRequestBuilder = patch("/dfc/api/v1/settings") + .contentType("application/json"); + + if (parameter.parameterData.settings != null) { + mockMvcRequestBuilder.content(mapper.writeValueAsString(parameter.parameterData.settings)); + } + + ParameterizedTestHelper.runControllerTest( + mockMvc, + mockMvcRequestBuilder, + parameter + ); + } + } +} \ No newline at end of file diff --git a/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/log/GlobalExceptionHandlerTest.java b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/log/GlobalExceptionHandlerTest.java new file mode 100644 index 000000000..e3d726c73 --- /dev/null +++ b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/log/GlobalExceptionHandlerTest.java @@ -0,0 +1,76 @@ +package fr.dossierfacile.api.front.log; + +import fr.dossierfacile.api.front.controller.UserController; +import fr.dossierfacile.api.front.exception.UserNotFoundException; +import fr.dossierfacile.api.front.security.interfaces.AuthenticationFacade; +import fr.dossierfacile.api.front.service.interfaces.UserService; +import jakarta.servlet.ServletException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.assertj.core.api.Assertions.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public class GlobalExceptionHandlerTest { + + private MockMvc mvc; + private final UserService userService = mock(UserService.class); + + @Nested + class withoutGlobalExceptionHandler { + @BeforeEach + public void setUp() { + var controller = new UserController(userService, mock(AuthenticationFacade.class)); + mvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + void shouldReturnA404() throws Exception { + doThrow(new UserNotFoundException("user not found")).when(userService).forgotPassword(any()); + mvc.perform(post("/api/user/forgotPassword").contentType("application/json").content("{\"email\":\"test@test.fr\"}")).andExpect(status().isNotFound()); + } + + @Test + void shouldReturnA500(){ + // Throw a random error not caught + doThrow(new ArrayIndexOutOfBoundsException()).when(userService).forgotPassword(any()); + try { + mvc.perform(post("/api/user/forgotPassword").contentType("application/json").content("{\"email\":\"test@test.fr\"}")); + } catch (Exception e) { + // Means that the exception is not caught by the system and will be propagated to Spring boot + assertThat(e).isInstanceOf(ServletException.class); + } + + } + } + + @Nested + class withGlobalExceptionHandler { + @BeforeEach + public void setUp() { + var controller = new UserController(userService, mock(AuthenticationFacade.class)); + mvc = MockMvcBuilders.standaloneSetup(controller).setControllerAdvice(new GlobalExceptionHandler()).build(); + } + + @Test + void shouldReturnA404() throws Exception { + doThrow(new UserNotFoundException("user not found")).when(userService).forgotPassword(any()); + mvc.perform(post("/api/user/forgotPassword").contentType("application/json").content("{\"email\":\"test@test.fr\"}")).andExpect(status().isNotFound()); + } + + @Test + void shouldReturnA500() throws Exception { + doThrow(new ArrayIndexOutOfBoundsException()).when(userService).forgotPassword(any()); + // Now unhandled exception are caught by the GlobalExceptionHandler + mvc.perform(post("/api/user/forgotPassword").contentType("application/json").content("{\"email\":\"test@test.fr\"}")).andExpect(status().isInternalServerError()); + } + } + +} diff --git a/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/security/AuthentificationFacadeImplTest.java b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/security/AuthentificationFacadeImplTest.java new file mode 100644 index 000000000..56a8c8aa6 --- /dev/null +++ b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/security/AuthentificationFacadeImplTest.java @@ -0,0 +1,746 @@ +package fr.dossierfacile.api.front.security; + +import fr.dossierfacile.api.front.exception.TenantNotFoundException; +import fr.dossierfacile.api.front.model.KeycloakUser; +import fr.dossierfacile.api.front.security.interfaces.AuthenticationFacade; +import fr.dossierfacile.api.front.service.interfaces.DocumentService; +import fr.dossierfacile.api.front.service.interfaces.TenantPermissionsService; +import fr.dossierfacile.api.front.service.interfaces.TenantService; +import fr.dossierfacile.api.front.service.interfaces.TenantStatusService; +import fr.dossierfacile.common.converter.AcquisitionData; +import fr.dossierfacile.common.entity.Tenant; +import fr.dossierfacile.common.repository.TenantCommonRepository; +import fr.dossierfacile.common.service.interfaces.LogService; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.keycloak.common.util.KeycloakUriBuilder; +import org.mockito.stubbing.Answer; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.web.util.UriComponentsBuilder; + +import java.lang.reflect.InvocationTargetException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.*; + +import static fr.dossierfacile.authentification.JwtFactoryKt.getDummyJwt; +import static fr.dossierfacile.authentification.JwtFactoryKt.getDummyJwtWithCustomClaims; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@ExtendWith(SpringExtension.class) +@TestPropertySource(properties = {"keycloak.server.url= http://localhost:8085/auth", "keycloak.franceconnect.provider= oidc", "keycloak.server.realm= test"}) +public class AuthentificationFacadeImplTest { + + private final static TenantCommonRepository tenantCommonRepository = mock(TenantCommonRepository.class); + private final static TenantPermissionsService tenantPermissionsService = mock(TenantPermissionsService.class); + private final static TenantStatusService tenantStatusService = mock(TenantStatusService.class); + private final static TenantService tenantService = mock(TenantService.class); + private final static LogService logService = mock(LogService.class); + private final static DocumentService documentService = mock(DocumentService.class); + + @Autowired + private AuthenticationFacade authenticationFacade; + + @TestConfiguration + static class AuthentificationFacadeImplTestContextConfiguration { + + @Bean + public AuthenticationFacade getAuthenticationFacade() { + return new AuthenticationFacadeImpl( + tenantCommonRepository, + tenantPermissionsService, + tenantStatusService, + tenantService, + logService, + documentService + ); + } + } + + @BeforeEach + void before() { + reset( + tenantCommonRepository, + tenantPermissionsService, + tenantStatusService, + tenantService, + logService, + documentService + ); + } + + @Nested + class GetKeycloakClientIdTest { + + @Test + void shouldReturnNullWhenAzpIsNotDefined() { + var jwt = getDummyJwt(); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, Collections.emptyList()))); + var result = authenticationFacade.getKeycloakClientId(); + assertThat(result).isNull(); + } + + @Test + void shouldReturnAzpFromJwt() { + var claimsMap = new HashMap(); + claimsMap.put("azp", "azp"); + var jwt = getDummyJwtWithCustomClaims(claimsMap); + + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, Collections.emptyList()))); + var result = authenticationFacade.getKeycloakClientId(); + assertThat(result).isEqualTo("azp"); + } + } + + @Nested + class GetUserEmailTest { + + @Test + void shouldReturnNullWhenEmailIsNotDefined() { + var jwt = getDummyJwt(); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, Collections.emptyList()))); + var result = authenticationFacade.getUserEmail(); + assertThat(result).isNull(); + } + + @Test + void shouldReturnEmailFromJwt() { + var claimsMap = new HashMap(); + claimsMap.put("email", "email"); + var jwt = getDummyJwtWithCustomClaims(claimsMap); + + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, Collections.emptyList()))); + var result = authenticationFacade.getUserEmail(); + assertThat(result).isEqualTo("email"); + } + } + + @Nested + class GetKeycloakUserIdTest { + + @Test + void shouldReturnNullWhenSubIsNotDefined() { + var jwt = getDummyJwt(); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, Collections.emptyList()))); + var result = authenticationFacade.getKeycloakUserId(); + assertThat(result).isNull(); + } + + @Test + void shouldReturnSubFromJwt() { + var claimsMap = new HashMap(); + claimsMap.put("sub", "sub"); + var jwt = getDummyJwtWithCustomClaims(claimsMap); + + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, Collections.emptyList()))); + var result = authenticationFacade.getKeycloakUserId(); + assertThat(result).isEqualTo("sub"); + } + } + + @Nested + class GetKeycloakUserTest { + + @Test + void shouldThrowNullPointerWhenEmailVerifiedClaimUndefined() { + var jwt = getDummyJwt(); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, Collections.emptyList()))); + + assertThrows(NullPointerException.class, () -> authenticationFacade.getKeycloakUser()); + } + + @Test + void shouldReturnKeycloakUserWithoutPreferredName() { + var claimsMap = new HashMap(); + claimsMap.put("sub", "sub"); + claimsMap.put("email", "test@test.fr"); + claimsMap.put("given_name", "test"); + claimsMap.put("family_name", "test"); + claimsMap.put("email_verified", true); + claimsMap.put("france-connect", true); + claimsMap.put("france-connect-sub", "fsub"); + claimsMap.put("birthcountry", "test"); + claimsMap.put("birthplace", "test"); + claimsMap.put("birthdate", "test"); + var jwt = getDummyJwtWithCustomClaims(claimsMap); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, Collections.emptyList()))); + + var result = authenticationFacade.getKeycloakUser(); + assertThat(result.getKeycloakId()).isEqualTo("sub"); + assertThat(result.getEmail()).isEqualTo("test@test.fr"); + assertThat(result.getGivenName()).isEqualTo("test"); + assertThat(result.getFamilyName()).isEqualTo("test"); + assertThat(result.isEmailVerified()).isTrue(); + assertThat(result.isFranceConnect()).isTrue(); + assertThat(result.getPreferredUsername()).isNull(); + assertThat(result.getFranceConnectSub()).isEqualTo("fsub"); + assertThat(result.getFranceConnectBirthCountry()).isEqualTo("test"); + assertThat(result.getFranceConnectBirthPlace()).isEqualTo("test"); + assertThat(result.getFranceConnectBirthDate()).isEqualTo("test"); + } + + @Test + void shouldReturnKeycloakUserWithoutPreferredNameWhenIncorrectChar() { + var claimsMap = new HashMap(); + claimsMap.put("sub", "sub"); + claimsMap.put("email", "test@test.fr"); + claimsMap.put("given_name", "test"); + claimsMap.put("family_name", "test"); + claimsMap.put("email_verified", true); + claimsMap.put("france-connect", true); + claimsMap.put("preferred_username", "test@test.fr"); + claimsMap.put("france-connect-sub", "fsub"); + claimsMap.put("birthcountry", "test"); + claimsMap.put("birthplace", "test"); + claimsMap.put("birthdate", "test"); + var jwt = getDummyJwtWithCustomClaims(claimsMap); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, Collections.emptyList()))); + + var result = authenticationFacade.getKeycloakUser(); + assertThat(result.getKeycloakId()).isEqualTo("sub"); + assertThat(result.getEmail()).isEqualTo("test@test.fr"); + assertThat(result.getGivenName()).isEqualTo("test"); + assertThat(result.getFamilyName()).isEqualTo("test"); + assertThat(result.isEmailVerified()).isTrue(); + assertThat(result.isFranceConnect()).isTrue(); + assertThat(result.getPreferredUsername()).isNull(); + assertThat(result.getFranceConnectSub()).isEqualTo("fsub"); + assertThat(result.getFranceConnectBirthCountry()).isEqualTo("test"); + assertThat(result.getFranceConnectBirthPlace()).isEqualTo("test"); + assertThat(result.getFranceConnectBirthDate()).isEqualTo("test"); + } + + @Test + void shouldReturnKeycloakUserWithPreferredName() { + var claimsMap = new HashMap(); + claimsMap.put("sub", "sub"); + claimsMap.put("email", "test@test.fr"); + claimsMap.put("given_name", "test"); + claimsMap.put("family_name", "test"); + claimsMap.put("email_verified", true); + claimsMap.put("france-connect", true); + claimsMap.put("preferred_username", "test"); + claimsMap.put("france-connect-sub", "fsub"); + claimsMap.put("birthcountry", "test"); + claimsMap.put("birthplace", "test"); + claimsMap.put("birthdate", "test"); + var jwt = getDummyJwtWithCustomClaims(claimsMap); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, Collections.emptyList()))); + + var result = authenticationFacade.getKeycloakUser(); + assertThat(result.getKeycloakId()).isEqualTo("sub"); + assertThat(result.getEmail()).isEqualTo("test@test.fr"); + assertThat(result.getGivenName()).isEqualTo("test"); + assertThat(result.getFamilyName()).isEqualTo("test"); + assertThat(result.isEmailVerified()).isTrue(); + assertThat(result.isFranceConnect()).isTrue(); + assertThat(result.getPreferredUsername()).isEqualTo("test"); + assertThat(result.getFranceConnectSub()).isEqualTo("fsub"); + assertThat(result.getFranceConnectBirthCountry()).isEqualTo("test"); + assertThat(result.getFranceConnectBirthPlace()).isEqualTo("test"); + assertThat(result.getFranceConnectBirthDate()).isEqualTo("test"); + } + } + + @Nested + class GetTenantTest { + + @Test + void shouldReturnAccessDeniedExceptionWhenNoAuthorities() { + var jwt = getDummyJwt(); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, Collections.emptyList()))); + assertThrows(AccessDeniedException.class, () -> { + authenticationFacade.getTenant(1L); + }); + } + + @Test + void shouldReturnAccessDeniedExceptionWhenWrongAuthority() { + var jwt = getDummyJwt(); + List authorities = Collections.singletonList((GrantedAuthority) () -> "ROLE_USER"); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, authorities))); + assertThrows(AccessDeniedException.class, () -> { + authenticationFacade.getTenant(1L); + }); + } + + @Test + void shouldReturnAccessDeniedWhenTenantCantAccess() { + var jwt = getDummyJwtWithCustomClaims(Map.of("sub", "keycloakId")); + List authorities = List.of(new SimpleGrantedAuthority("SCOPE_dossier")); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, authorities))); + when(tenantPermissionsService.canAccess("keycloakId", 1L)).thenReturn(false); + assertThrows(AccessDeniedException.class, () -> { + authenticationFacade.getTenant(1L); + }); + verify(tenantPermissionsService, times(1)).canAccess("keycloakId", 1L); + } + + @Test + void shouldReturnTenantNotFound() { + var jwt = getDummyJwtWithCustomClaims(Map.of("sub", "keycloakId")); + List authorities = List.of(new SimpleGrantedAuthority("SCOPE_dossier")); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, authorities))); + when(tenantPermissionsService.canAccess("keycloakId", 1L)).thenReturn(true); + when(tenantCommonRepository.findById(1L)).thenReturn(Optional.empty()); + assertThrows(TenantNotFoundException.class, () -> { + authenticationFacade.getTenant(1L); + }); + verify(tenantPermissionsService, times(1)).canAccess("keycloakId", 1L); + verify(tenantCommonRepository, times(1)).findById(1L); + } + + @Test + void shouldReturnTenant() { + var tenant = Tenant.builder().id(1L).build(); + var jwt = getDummyJwtWithCustomClaims(Map.of("sub", "keycloakId")); + List authorities = List.of(new SimpleGrantedAuthority("SCOPE_dossier")); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, authorities))); + when(tenantPermissionsService.canAccess("keycloakId", 1L)).thenReturn(true); + when(tenantCommonRepository.findById(1L)).thenReturn(Optional.of(tenant)); + var result = authenticationFacade.getTenant(1L); + verify(tenantPermissionsService, times(1)).canAccess("keycloakId", 1L); + verify(tenantCommonRepository, times(1)).findById(1L); + assertThat(result.getId()).isEqualTo(1L); + } + + @Test + void shouldReturnAccessDeniedExceptionWhenEmailIsNotVerifiedAndNotFranceConnectWithoutTenantId() { + var claimsMap = new HashMap(); + claimsMap.put("sub", "keycloakId"); + claimsMap.put("email", "test@test.fr"); + claimsMap.put("email_verified", false); + claimsMap.put("france-connect", false); + var jwt = getDummyJwtWithCustomClaims(claimsMap); + List authorities = List.of(new SimpleGrantedAuthority("SCOPE_dossier")); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, authorities))); + assertThrows(AccessDeniedException.class, () -> { + authenticationFacade.getTenant(null); + }); + } + } + + /* + * We use introspection to test private method to simplify the test + */ + @Nested + class FindOrCreateTenantTest { + + @Test + void shouldCreateAccountFromKeycloakUser() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + var claimsMap = new HashMap(); + claimsMap.put("sub", "keycloakId"); + claimsMap.put("email", "test@test.fr"); + claimsMap.put("email_verified", false); + claimsMap.put("france-connect", false); + + var keycloakUser = KeycloakUser.builder() + .keycloakId("keycloakId") + .email("test@test.fr") + .emailVerified(true) + .build(); + + var jwt = getDummyJwtWithCustomClaims(claimsMap); + List authorities = List.of(new SimpleGrantedAuthority("SCOPE_dossier")); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, authorities))); + + when(tenantCommonRepository.findByKeycloakId("keycloakId")).thenReturn(null); + when(tenantCommonRepository.findByEmail("test@test.fr")).thenReturn(Optional.empty()); + when(tenantService.registerFromKeycloakUser(keycloakUser, null, null)).thenReturn(Tenant.builder().id(1L).build()); + + var methodToTest = authenticationFacade.getClass().getDeclaredMethod("findOrCreateTenant", KeycloakUser.class, AcquisitionData.class); + methodToTest.setAccessible(true); + methodToTest.invoke(authenticationFacade, keycloakUser, null); + + verify(tenantCommonRepository, times(1)).findByKeycloakId("keycloakId"); + verify(tenantCommonRepository, times(1)).findByEmail("test@test.fr"); + verify(tenantService, times(1)).registerFromKeycloakUser(keycloakUser, null, null); + + } + + @Test + void shouldReturnTenantByKeycloakId() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + var claimsMap = new HashMap(); + claimsMap.put("sub", "keycloakId"); + claimsMap.put("email", "test@test.fr"); + claimsMap.put("email_verified", false); + claimsMap.put("france-connect", false); + + var keycloakUser = KeycloakUser.builder() + .keycloakId("keycloakId") + .email("test@test.fr") + .emailVerified(true) + .build(); + + var tenant = Tenant.builder().id(1L).build(); + + var jwt = getDummyJwtWithCustomClaims(claimsMap); + List authorities = List.of(new SimpleGrantedAuthority("SCOPE_dossier")); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, authorities))); + + when(tenantCommonRepository.findByKeycloakId("keycloakId")).thenReturn(tenant); + + var methodToTest = authenticationFacade.getClass().getDeclaredMethod("findOrCreateTenant", KeycloakUser.class, AcquisitionData.class); + methodToTest.setAccessible(true); + var result = methodToTest.invoke(authenticationFacade, keycloakUser, null); + + verify(tenantCommonRepository, times(1)).findByKeycloakId("keycloakId"); + verify(tenantCommonRepository, times(0)).findByEmail(any()); + verify(tenantService, times(0)).registerFromKeycloakUser(any(), any(), any()); + + assertThat(result).isInstanceOf(Tenant.class); + assertThat(((Tenant) result).getId()).isEqualTo(1L); + + } + + @Test + void shouldReturnTenantByEmail() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + var claimsMap = new HashMap(); + claimsMap.put("sub", "keycloakId"); + claimsMap.put("email", "test@test.fr"); + claimsMap.put("email_verified", false); + claimsMap.put("france-connect", false); + + var keycloakUser = KeycloakUser.builder() + .keycloakId("keycloakId") + .email("test@test.fr") + .emailVerified(true) + .build(); + + var tenant = Tenant.builder().id(1L).build(); + + var jwt = getDummyJwtWithCustomClaims(claimsMap); + List authorities = List.of(new SimpleGrantedAuthority("SCOPE_dossier")); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, authorities))); + + when(tenantCommonRepository.findByKeycloakId("keycloakId")).thenReturn(null); + when(tenantCommonRepository.findByEmail("test@test.fr")).thenReturn(Optional.of(tenant)); + + var methodToTest = authenticationFacade.getClass().getDeclaredMethod("findOrCreateTenant", KeycloakUser.class, AcquisitionData.class); + methodToTest.setAccessible(true); + var result = methodToTest.invoke(authenticationFacade, keycloakUser, null); + + verify(tenantCommonRepository, times(1)).findByKeycloakId("keycloakId"); + verify(tenantCommonRepository, times(1)).findByEmail("test@test.fr"); + verify(tenantService, times(0)).registerFromKeycloakUser(any(), any(), any()); + + assertThat(result).isInstanceOf(Tenant.class); + assertThat(((Tenant) result).getId()).isEqualTo(1L); + + } + } + + @Nested + class KeycloakAndTenantMatchTest { + record MatchParameters(Tenant tenant, KeycloakUser keycloakUser, boolean result) { + } + + static List provideMatchParameters() { + return List.of( + Arguments.of( + Named.of("Keycloak user and tenant match", new MatchParameters( + Tenant.builder().keycloakId("keycloakId").build(), + KeycloakUser.builder().keycloakId("keycloakId").build(), + true + )) + ), + Arguments.of( + Named.of("id not the same", new MatchParameters( + Tenant.builder().keycloakId("keycloakId1").build(), + KeycloakUser.builder().keycloakId("keycloakId2").build(), + false + )) + ), + Arguments.of( + Named.of("email not the same", new MatchParameters( + Tenant.builder().keycloakId("keycloakId").email("test@test.fr").build(), + KeycloakUser.builder().keycloakId("keycloakId").email("test2@test.fr").build(), + false + )) + ), + Arguments.of( + Named.of("france connect not the same", new MatchParameters( + Tenant.builder() + .keycloakId("keycloakId") + .email("test@test.fr") + .franceConnect(false) + .build(), + KeycloakUser.builder() + .keycloakId("keycloakId") + .email("test@test.fr") + .franceConnect(true) + .build(), + false + )) + ), + // Todo : The result should be false because the names are not identical + Arguments.of( + Named.of("is france connected but name are different and last name the same", new MatchParameters( + Tenant.builder() + .keycloakId("keycloakId") + .email("test@test.fr") + .franceConnect(true) + .firstName("test") + .lastName("test2") + .build(), + KeycloakUser.builder() + .keycloakId("keycloakId") + .email("test@test.fr") + .franceConnect(true) + .givenName("test") + .familyName("test") + .build(), + true + )) + ), + Arguments.of( + Named.of("is france connected but name are different and last name as well", new MatchParameters( + Tenant.builder() + .keycloakId("keycloakId") + .email("test@test.fr") + .franceConnect(true) + .firstName("test") + .lastName("test") + .build(), + KeycloakUser.builder() + .keycloakId("keycloakId") + .email("test@test.fr") + .franceConnect(true) + .givenName("test2") + .familyName("test2") + .build(), + false + )) + ), + Arguments.of( + Named.of("is not france connected and name are different", new MatchParameters( + Tenant.builder() + .keycloakId("keycloakId") + .email("test@test.fr") + .franceConnect(false) + .firstName("test") + .build(), + KeycloakUser.builder() + .keycloakId("keycloakId") + .email("test@test.fr") + .franceConnect(false) + .givenName("test2") + .build(), + true + )) + ) + ); + } + + @ParameterizedTest + @MethodSource("provideMatchParameters") + void parametrizedTests(MatchParameters matchParameters) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + + var methodToTest = authenticationFacade.getClass().getDeclaredMethod("matches", Tenant.class, KeycloakUser.class); + methodToTest.setAccessible(true); + var result = methodToTest.invoke(authenticationFacade, matchParameters.tenant, matchParameters.keycloakUser); + + assertThat(result).isInstanceOf(Boolean.class); + assertThat((Boolean) result).isEqualTo(matchParameters.result); + + } + + + } + + /* + * We use introspection to test private method to simplify the test + */ + @Nested + class SynchroniseTenantTest { + + @Test + void shouldNotUpdateTenant() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + var tenant = Tenant.builder() + .id(1L) + .keycloakId("keycloakId") + .email("test@test.fr") + .build(); + + var keycloakUser = KeycloakUser.builder() + .keycloakId("keycloakId") + .email("test@test.fr") + .build(); + + var methodToTest = authenticationFacade.getClass().getDeclaredMethod("synchronizeTenant", Tenant.class, KeycloakUser.class); + methodToTest.setAccessible(true); + var result = methodToTest.invoke(authenticationFacade, tenant, keycloakUser); + + verify(tenantCommonRepository, times(0)).saveAndFlush(any()); + + assertThat(tenant).isEqualTo(result); + } + + @Test + void shouldNotUpdateTenantBecauseEmailAreNotTheSame() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + var tenant = Tenant.builder() + .id(1L) + .keycloakId("keycloakId") + .email("test2@test.fr") + .build(); + + var keycloakUser = KeycloakUser.builder() + .keycloakId("keycloakId") + .email("test@test.fr") + .build(); + + var methodToTest = authenticationFacade.getClass().getDeclaredMethod("synchronizeTenant", Tenant.class, KeycloakUser.class); + methodToTest.setAccessible(true); + var result = methodToTest.invoke(authenticationFacade, tenant, keycloakUser); + + verify(tenantCommonRepository, times(0)).saveAndFlush(any()); + + assertThat(tenant).isEqualTo(result); + assertThat(((Tenant) result).getWarningMessage()).isNotBlank(); + } + + @Test + void shouldUpdateTenantWithNewKeycloakId() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + var tenant = Tenant.builder() + .id(1L) + .keycloakId("keycloakId") + .email("test@test.fr") + .build(); + + var keycloakUser = KeycloakUser.builder() + .keycloakId("keycloakId2") + .email("test@test.fr") + .build(); + + // make that the method return the invocation parameter + when(tenantCommonRepository.saveAndFlush(any())).thenAnswer((Answer) invocation -> invocation.getArgument(0)); + + var methodToTest = authenticationFacade.getClass().getDeclaredMethod("synchronizeTenant", Tenant.class, KeycloakUser.class); + methodToTest.setAccessible(true); + var result = methodToTest.invoke(authenticationFacade, tenant, keycloakUser); + + verify(tenantStatusService, times(1)).updateTenantStatus(tenant); + verify(tenantCommonRepository, times(1)).saveAndFlush(any()); + + assertThat(tenant).isEqualTo(result); + assertThat(((Tenant) result).getKeycloakId()).isEqualTo(keycloakUser.getKeycloakId()); + } + + // According to the code this test should passe but check comment on matches method + @Disabled + @Test + void shouldUpdateTenantAndResetDocumentsError() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + var tenant = Tenant.builder() + .keycloakId("keycloakId") + .email("test@test.fr") + .franceConnect(true) + .firstName("test") + .lastName("test2") + .build(); + var keycloakUser = KeycloakUser.builder() + .keycloakId("keycloakId") + .email("test@test.fr") + .franceConnect(true) + .givenName("test") + .familyName("test") + .build(); + + + // make that the method return the invocation parameter + when(tenantCommonRepository.saveAndFlush(any())).thenAnswer((Answer) invocation -> invocation.getArgument(0)); + + var methodToTest = authenticationFacade.getClass().getDeclaredMethod("synchronizeTenant", Tenant.class, KeycloakUser.class); + methodToTest.setAccessible(true); + var result = methodToTest.invoke(authenticationFacade, tenant, keycloakUser); + + verify(tenantStatusService, times(1)).updateTenantStatus(tenant); + verify(tenantCommonRepository, times(1)).saveAndFlush(any()); + verify(documentService, times(1)).resetValidatedOrInProgressDocumentsAccordingCategories(any(), any()); + + assertThat(tenant).isEqualTo(result); + assertThat(((Tenant) result).getKeycloakId()).isEqualTo(keycloakUser.getKeycloakId()); + } + + @Test + void shouldUpdateTenantAndResetDocuments() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + var tenant = Tenant.builder() + .keycloakId("keycloakId") + .email("test@test.fr") + .franceConnect(true) + .firstName("test2") + .lastName("test2") + .build(); + var keycloakUser = KeycloakUser.builder() + .keycloakId("keycloakId") + .email("test@test.fr") + .franceConnect(true) + .givenName("test") + .familyName("test") + .build(); + + + // make that the method return the invocation parameter + when(tenantCommonRepository.saveAndFlush(any())).thenAnswer((Answer) invocation -> invocation.getArgument(0)); + + var methodToTest = authenticationFacade.getClass().getDeclaredMethod("synchronizeTenant", Tenant.class, KeycloakUser.class); + methodToTest.setAccessible(true); + var result = methodToTest.invoke(authenticationFacade, tenant, keycloakUser); + + verify(tenantStatusService, times(1)).updateTenantStatus(tenant); + verify(tenantCommonRepository, times(1)).saveAndFlush(any()); + verify(documentService, times(1)).resetValidatedOrInProgressDocumentsAccordingCategories(any(), any()); + + assertThat(tenant).isEqualTo(result); + assertThat(((Tenant) result).getKeycloakId()).isEqualTo(keycloakUser.getKeycloakId()); + } + + } + + @Nested + class GetFranceConnectLinkTest { + + @Test + void shouldReturnFranceConnectLink() throws URISyntaxException { + + var claimsMap = new HashMap(); + claimsMap.put("sub", "keycloakId"); + claimsMap.put("email", "test@test.fr"); + claimsMap.put("azp", "testAzp"); + claimsMap.put("session_state", "testToken"); + + var jwt = getDummyJwtWithCustomClaims(claimsMap); + List authorities = List.of(new SimpleGrantedAuthority("SCOPE_dossier")); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, authorities))); + + var result = authenticationFacade.getFranceConnectLink("redirectUri"); + + var uriResult = new URI(result); + var actualBaseUrl = uriResult.getScheme() + "://" + uriResult.getHost() + ":" + uriResult.getPort() + uriResult.getPath(); + + assertThat(actualBaseUrl).isEqualTo("http://localhost:8085/auth/realms/test/broker/oidc/link"); + + var params = UriComponentsBuilder.fromUri(uriResult).build().getQueryParams().toSingleValueMap(); + assertThat(params.size()).isEqualTo(4); + assertThat(params.get("nonce")).isNotBlank(); + assertThat(params.get("hash")).isNotBlank(); + assertThat(params.get("client_id")).isEqualTo("testAzp"); + assertThat(params.get("redirect_uri")).isEqualTo("redirectUri"); + } + + } +} \ No newline at end of file diff --git a/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/security/ClientAuthentificationFacadeImplTest.java b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/security/ClientAuthentificationFacadeImplTest.java new file mode 100644 index 000000000..0af18de52 --- /dev/null +++ b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/security/ClientAuthentificationFacadeImplTest.java @@ -0,0 +1,161 @@ +package fr.dossierfacile.api.front.security; + +import fr.dossierfacile.api.front.exception.ClientNotFoundException; +import fr.dossierfacile.api.front.security.interfaces.ClientAuthenticationFacade; +import fr.dossierfacile.api.front.service.interfaces.UserApiService; +import fr.dossierfacile.common.entity.UserApi; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; + +import static fr.dossierfacile.authentification.JwtFactoryKt.getDummyJwtWithCustomClaims; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@ExtendWith(SpringExtension.class) +public class ClientAuthentificationFacadeImplTest { + + private static final UserApiService userApiService = mock(UserApiService.class); + + @Autowired + private ClientAuthenticationFacade clientAuthenticationFacade; + + @TestConfiguration + static class AuthentificationFacadeImplTestContextConfiguration { + + @Bean + public ClientAuthenticationFacade getClientAuthentificationFacade() { + return new ClientAuthenticationFacadeImpl(userApiService); + } + } + + @BeforeEach + void before() { + reset(userApiService); + } + + @Nested + class GetKeycloakClientIdTest { + + @Test + void shouldReturnClientIdFromJwt() { + var claimsMap = Map.of("client_id", "clientId"); + var jwt = getDummyJwtWithCustomClaims(claimsMap); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, Collections.emptyList()))); + var result = clientAuthenticationFacade.getKeycloakClientId(); + assertThat(result).isEqualTo("clientId"); + } + + @Test + void shouldReturnClientIdFromJwtAlternativeClaim() { + var claimsMap = Map.of("clientId", "clientId"); + var jwt = getDummyJwtWithCustomClaims(claimsMap); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, Collections.emptyList()))); + var result = clientAuthenticationFacade.getKeycloakClientId(); + assertThat(result).isEqualTo("clientId"); + } + + @Test + void shouldReturnNullWhenClientIdIsNotDefined() { + var jwt = getDummyJwtWithCustomClaims(Collections.emptyMap()); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, Collections.emptyList()))); + var result = clientAuthenticationFacade.getKeycloakClientId(); + assertThat(result).isNull(); + } + } + + @Nested + class GetClientTest { + + @Test + void shouldReturnClient() { + var userApi = new UserApi(); + when(userApiService.findByName("clientId")).thenReturn(Optional.of(userApi)); + + var claimsMap = Map.of("client_id", "clientId"); + var jwt = getDummyJwtWithCustomClaims(claimsMap); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, Collections.emptyList()))); + + var result = clientAuthenticationFacade.getClient(); + assertThat(result).isEqualTo(userApi); + } + + @Test + void shouldThrowClientNotFoundException() { + when(userApiService.findByName("clientId")).thenReturn(Optional.empty()); + + var claimsMap = Map.of("client_id", "clientId"); + var jwt = getDummyJwtWithCustomClaims(claimsMap); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, Collections.emptyList()))); + + assertThrows(ClientNotFoundException.class, () -> clientAuthenticationFacade.getClient()); + } + } + + @Nested + class IsClientTest { + + @Test + void shouldReturnTrueWhenClientIdIsPresent() { + var claimsMap = Map.of("client_id", "clientId"); + var jwt = getDummyJwtWithCustomClaims(claimsMap); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, Collections.emptyList()))); + + var result = clientAuthenticationFacade.isClient(); + assertThat(result).isTrue(); + } + + @Test + void shouldReturnFalseWhenClientIdIsNotPresent() { + var jwt = getDummyJwtWithCustomClaims(Collections.emptyMap()); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, Collections.emptyList()))); + + var result = clientAuthenticationFacade.isClient(); + assertThat(result).isFalse(); + } + } + + @Nested + class GetApiVersionTest { + + @Test + void shouldReturnApiVersion() { + var userApi = new UserApi(); + userApi.setVersion(1); + when(userApiService.findByName("clientId")).thenReturn(Optional.of(userApi)); + + var claimsMap = Map.of("client_id", "clientId"); + var jwt = getDummyJwtWithCustomClaims(claimsMap); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, Collections.emptyList()))); + + var result = clientAuthenticationFacade.getApiVersion(); + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(1); + } + + @Test + void shouldReturnEmptyWhenClientNotFound() { + when(userApiService.findByName("clientId")).thenReturn(Optional.empty()); + + var claimsMap = Map.of("client_id", "clientId"); + var jwt = getDummyJwtWithCustomClaims(claimsMap); + SecurityContextHolder.setContext(new SecurityContextImpl(new JwtAuthenticationToken(jwt, Collections.emptyList()))); + + var result = clientAuthenticationFacade.getApiVersion(); + assertThat(result).isEmpty(); + } + } +} \ No newline at end of file diff --git a/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/service/UserServiceImplTest.java b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/service/UserServiceImplTest.java new file mode 100644 index 000000000..6a49f4232 --- /dev/null +++ b/dossierfacile-api-tenant/src/test/java/fr/dossierfacile/api/front/service/UserServiceImplTest.java @@ -0,0 +1,620 @@ +package fr.dossierfacile.api.front.service; + +import fr.dossierfacile.api.front.exception.PasswordRecoveryTokenNotFoundException; +import fr.dossierfacile.api.front.exception.UserNotFoundException; +import fr.dossierfacile.api.front.mapper.TenantMapper; +import fr.dossierfacile.api.front.repository.PasswordRecoveryTokenRepository; +import fr.dossierfacile.api.front.repository.UserRepository; +import fr.dossierfacile.api.front.service.interfaces.*; +import fr.dossierfacile.common.entity.*; +import fr.dossierfacile.common.enums.ApplicationType; +import fr.dossierfacile.common.enums.LogType; +import fr.dossierfacile.common.enums.PartnerCallBackType; +import fr.dossierfacile.common.enums.TenantType; +import fr.dossierfacile.common.mapper.mail.TenantMapperForMail; +import fr.dossierfacile.common.mapper.mail.TenantMapperForMailImpl; +import fr.dossierfacile.common.mapper.mail.UserApiMapperForMail; +import fr.dossierfacile.common.mapper.mail.UserApiMapperForMailImpl; +import fr.dossierfacile.common.model.apartment_sharing.ApplicationModel; +import fr.dossierfacile.common.repository.TenantCommonRepository; +import fr.dossierfacile.common.service.interfaces.LogService; +import fr.dossierfacile.common.service.interfaces.PartnerCallBackService; +import fr.dossierfacile.common.service.interfaces.TenantCommonService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@ExtendWith(SpringExtension.class) +public class UserServiceImplTest { + + private static final UserRepository userRepository = mock(UserRepository.class); + private static final PasswordRecoveryTokenRepository passwordRecoveryTokenRepository = mock(PasswordRecoveryTokenRepository.class); + private static final MailService mailService = mock(MailService.class); + private static final PasswordRecoveryTokenService passwordRecoveryTokenService = mock(PasswordRecoveryTokenService.class); + // We have to mock this mapper there is too much logic inside + private static final TenantMapper tenantMapper = mock(TenantMapper.class); + private static final TenantCommonRepository tenantRepository = mock(TenantCommonRepository.class); + private static final LogService logService = mock(LogService.class); + private static final KeycloakService keycloakService = mock(KeycloakService.class); + private static final UserApiService userApiService = mock(UserApiService.class); + private static final PartnerCallBackService partnerCallBackService = mock(PartnerCallBackService.class); + private static final ApartmentSharingService apartmentSharingService = mock(ApartmentSharingService.class); + private static final TenantCommonService tenantCommonService = mock(TenantCommonService.class); + + @Autowired + private UserService userService; + + @TestConfiguration + static class UserServiceTestConfiguration { + + @Bean + public TenantMapperForMail tenantMapperForMail() { + return new TenantMapperForMailImpl(); + } + + @Bean + public UserApiMapperForMail userApiMapperForMail() { + return new UserApiMapperForMailImpl(); + } + + @Bean + public UserService userService(TenantMapperForMailImpl tenantMapperForMail) { + return new UserServiceImpl(userRepository, passwordRecoveryTokenRepository, mailService, passwordRecoveryTokenService, tenantMapper, tenantRepository, logService, keycloakService, userApiService, partnerCallBackService, apartmentSharingService, tenantCommonService, tenantMapperForMail); + } + } + + @BeforeEach + void before() { + reset(userRepository, passwordRecoveryTokenRepository, mailService, passwordRecoveryTokenService, tenantMapper, tenantRepository, logService, keycloakService, userApiService, partnerCallBackService, apartmentSharingService, tenantCommonService); + } + + @Nested + class CreatePasswordTest { + + @Test + void shouldCreatePasswordForUser() { + var tenant = Tenant.builder() + .id(1L) + .email("test@test.fr") + .build(); + userService.createPassword(tenant, "password"); + + verify(keycloakService, times(1)).createKeyCloakPassword(tenant.getKeycloakId(), "password"); + verify(tenantMapper, times(1)).toTenantModel(tenantRepository.getReferenceById(tenant.getId()), null); + } + + @Test + void shouldThrowPasswordRecoveryTokenNotFoundExceptionWhenCreatePasswordForToken() { + var token = "test"; + doThrow(new PasswordRecoveryTokenNotFoundException(token)).when(passwordRecoveryTokenRepository).findByToken(token); + + var exception = assertThrows(PasswordRecoveryTokenNotFoundException.class, () -> userService.createPassword(token, "password")); + assertEquals(exception.getMessage(), "Could not find password recovery token or is expired " + token); + } + + @Test + void shouldCreatePassword() { + var tenant = Tenant.builder() + .id(1L) + .email("test@test.fr") + .keycloakId("keycloakId") + .build(); + + var passwordRecoveryToken = PasswordRecoveryToken.builder() + .id(1L) + .token("token") + .user(tenant) + .build(); + + when(passwordRecoveryTokenRepository.findByToken("token")).thenReturn(Optional.of(passwordRecoveryToken)); + when(keycloakService.getKeycloakId(tenant.getEmail())).thenReturn(tenant.getKeycloakId()); + + + userService.createPassword("token", "password"); + + verify(userRepository, times(0)).save(tenant); + verify(keycloakService, times(1)).createKeyCloakPassword(tenant.getKeycloakId(), "password"); + verify(tenantMapper, times(1)).toTenantModel(tenantRepository.getReferenceById(tenant.getId()), null); + verify(passwordRecoveryTokenRepository, times(1)).delete(passwordRecoveryToken); + + + } + + @Test + void shouldCreatePasswordAndUpdateTheUser() { + var tenant = Tenant.builder() + .id(1L) + .email("test@test.fr") + .keycloakId("keycloakId") + .build(); + + var passwordRecoveryToken = PasswordRecoveryToken.builder() + .id(1L) + .token("token") + .user(tenant) + .build(); + + when(passwordRecoveryTokenRepository.findByToken("token")).thenReturn(Optional.of(passwordRecoveryToken)); + when(keycloakService.getKeycloakId(tenant.getEmail())).thenReturn("keycloakId2"); + + userService.createPassword("token", "password"); + + verify(userRepository, times(1)).save(tenant); + verify(keycloakService, times(1)).createKeyCloakPassword(tenant.getKeycloakId(), "password"); + verify(tenantMapper, times(1)).toTenantModel(tenantRepository.getReferenceById(tenant.getId()), null); + verify(passwordRecoveryTokenRepository, times(1)).delete(passwordRecoveryToken); + + } + + } + + @Nested + class ForgotPasswordTest { + + @Test + void shouldSendResetPasswordEmail() { + + var tenant = Tenant.builder() + .id(1L) + .email("test@test.fr") + .build(); + + var passwordRecoveryToken = PasswordRecoveryToken.builder() + .id(1L) + .token("token") + .user(tenant) + .build(); + + when(tenantRepository.findByEmail("test@test.fr")).thenReturn(Optional.of(tenant)); + when(passwordRecoveryTokenService.create(tenant)).thenReturn(passwordRecoveryToken); + userService.forgotPassword("test@test.fr"); + + verify(passwordRecoveryTokenService, times(1)).create(tenant); + verify(mailService, times(1)).sendEmailNewPassword(tenant, passwordRecoveryToken); + + } + + @Test + void shouldThrowUserNotFoundWhenResetPasswordEmail() { + var email = "test@test.fr"; + doThrow(new UserNotFoundException(email)).when(tenantRepository).findByEmail(email); + + var exception = assertThrows(UserNotFoundException.class, () -> userService.forgotPassword(email)); + + assertEquals(exception.getMessage(), "Could not find user with email " + email); + } + } + + + @Nested + class DeleteAccountTest { + + @BeforeEach + void before() { + TransactionSynchronizationManager.initSynchronization(); + } + + @AfterEach + void after() { + TransactionSynchronizationManager.clear(); + } + + @Test + void shouldDeleteAccountWithEmptyApartmentSharingAndWithKeycloakId() { + + var apartmentSharing = ApartmentSharing.builder() + .id(1L) + .build(); + + var tenant = Tenant.builder() + .id(1L) + .email("test@test.fr") + .keycloakId("keycloakId") + .apartmentSharing(apartmentSharing) + .build(); + + apartmentSharing.setTenants(List.of(tenant)); + + userService.deleteAccount(tenant); + + verify(logService, times(1)).saveLogWithTenantData(LogType.ACCOUNT_DELETE, tenant); + verify(tenantCommonService, times(1)).deleteTenantData(tenant); + verify(userRepository, times(1)).delete(tenant); + verify(apartmentSharingService, times(1)).removeTenant(apartmentSharing, tenant); + verify(partnerCallBackService, times(0)).getWebhookDTO(any(), any(), any()); + + TransactionSynchronizationManager.getSynchronizations().forEach(synchronization -> { + try { + synchronization.afterCommit(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + verify(mailService, times(1)).sendEmailAccountDeleted(argThat(tenantDto -> tenant.getEmail().equals(tenantDto.getEmail()))); + verify(keycloakService, times(1)).deleteKeycloakUserById("keycloakId"); + + } + + @Test + void shouldDeleteAccountWithEmptyApartmentSharingAndWithoutKeycloakId() { + var apartmentSharing = ApartmentSharing.builder() + .id(1L) + .build(); + + var tenant = Tenant.builder() + .id(1L) + .email("test@test.fr") + .apartmentSharing(apartmentSharing) + .build(); + + apartmentSharing.setTenants(List.of(tenant)); + + userService.deleteAccount(tenant); + + verify(logService, times(1)).saveLogWithTenantData(LogType.ACCOUNT_DELETE, tenant); + verify(tenantCommonService, times(1)).deleteTenantData(tenant); + verify(userRepository, times(1)).delete(tenant); + verify(apartmentSharingService, times(1)).removeTenant(apartmentSharing, tenant); + verify(partnerCallBackService, times(0)).getWebhookDTO(any(), any(), any()); + + TransactionSynchronizationManager.getSynchronizations().forEach(synchronization -> { + try { + synchronization.afterCommit(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + verify(mailService, times(1)).sendEmailAccountDeleted(argThat(tenantDto -> tenant.getEmail().equals(tenantDto.getEmail()))); + verify(keycloakService, times(0)).deleteKeycloakUserById("keycloakId"); + + } + + @Test + void shouldDeleteAccountAndCoTenantAccountsKeycloakId() { + + var apartmentSharing = ApartmentSharing.builder() + .id(1L) + .build(); + + var mainTenant = Tenant.builder() + .id(1L) + .email("test@test.fr") + .tenantType(TenantType.CREATE) + .apartmentSharing(apartmentSharing) + .build(); + + var coTenant = Tenant.builder() + .id(2L) + .email("test2@test.fr") + .tenantType(TenantType.JOIN) + .apartmentSharing(apartmentSharing) + .build(); + + apartmentSharing.setTenants(List.of(mainTenant, coTenant)); + + userService.deleteAccount(mainTenant); + + verify(logService, times(2)).saveLogWithTenantData(eq(LogType.ACCOUNT_DELETE), any(Tenant.class)); + verify(logService).saveLogWithTenantData(LogType.ACCOUNT_DELETE, mainTenant); + verify(logService).saveLogWithTenantData(LogType.ACCOUNT_DELETE, coTenant); + + verify(tenantCommonService, times(2)).deleteTenantData(any(Tenant.class)); + verify(tenantCommonService).deleteTenantData(mainTenant); + verify(tenantCommonService).deleteTenantData(coTenant); + + verify(userRepository, times(1)).delete(coTenant); + verify(apartmentSharingService, times(1)).removeTenant(apartmentSharing, coTenant); + + verify(apartmentSharingService, times(1)).delete(mainTenant.getApartmentSharing()); + + verify(partnerCallBackService, times(0)).getWebhookDTO(any(), any(), any()); + + TransactionSynchronizationManager.getSynchronizations().forEach(synchronization -> { + try { + synchronization.afterCommit(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + verify(mailService, times(2)).sendEmailAccountDeleted(any()); + verify(mailService).sendEmailAccountDeleted(argThat(tenantDto -> mainTenant.getEmail().equals(tenantDto.getEmail()))); + verify(mailService).sendEmailAccountDeleted(argThat(tenantDto -> coTenant.getEmail().equals(tenantDto.getEmail()))); + verify(keycloakService, times(0)).deleteKeycloakUserById("keycloakId"); + + } + + @Test + void shouldDeleteAccountAndCallWebhookIntegrations() { + + var apartmentSharing = ApartmentSharing.builder() + .id(1L) + .build(); + + var tenant = Tenant.builder() + .id(1L) + .email("test@test.fr") + .apartmentSharing(apartmentSharing) + .build(); + + var userApi1 = UserApi.builder() + .id(1L) + .build(); + + var userApi2 = UserApi.builder() + .id(2L) + .build(); + + var tenantUserApi = TenantUserApi.builder() + .id(new TenantUserApiKey(tenant.getId(), userApi1.getId())) + .tenant(tenant) + .userApi(userApi1) + .build(); + + var tenantUserApi2 = TenantUserApi.builder() + .id(new TenantUserApiKey(tenant.getId(), userApi2.getId())) + .tenant(tenant) + .userApi(userApi2) + .build(); + + tenant.setTenantsUserApi(List.of( + tenantUserApi, + tenantUserApi2 + )); + + var applicationModel1 = ApplicationModel.builder() + .id(1L) + .build(); + + var applicationModel2 = ApplicationModel.builder() + .id(2L) + .build(); + + when(partnerCallBackService.getWebhookDTO(tenant, userApi1, PartnerCallBackType.DELETED_ACCOUNT)).thenReturn(applicationModel1); + when(partnerCallBackService.getWebhookDTO(tenant, userApi2, PartnerCallBackType.DELETED_ACCOUNT)).thenReturn(applicationModel2); + + apartmentSharing.setTenants(List.of(tenant)); + + userService.deleteAccount(tenant); + + verify(logService, times(1)).saveLogWithTenantData(LogType.ACCOUNT_DELETE, tenant); + verify(tenantCommonService, times(1)).deleteTenantData(tenant); + verify(userRepository, times(1)).delete(tenant); + verify(apartmentSharingService, times(1)).removeTenant(apartmentSharing, tenant); + verify(partnerCallBackService, times(2)).getWebhookDTO(any(), any(), any()); + verify(partnerCallBackService).getWebhookDTO(tenant, tenantUserApi.getUserApi(), PartnerCallBackType.DELETED_ACCOUNT); + verify(partnerCallBackService).getWebhookDTO(tenant, tenantUserApi2.getUserApi(), PartnerCallBackType.DELETED_ACCOUNT); + verify(partnerCallBackService, times(2)).sendCallBack(any(), any(), any()); + verify(partnerCallBackService).sendCallBack(tenant, userApi1, applicationModel1); + verify(partnerCallBackService).sendCallBack(tenant, userApi2, applicationModel2); + + TransactionSynchronizationManager.getSynchronizations().forEach(synchronization -> { + try { + synchronization.afterCommit(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + verify(mailService, times(1)).sendEmailAccountDeleted(argThat(tenantDto -> tenant.getEmail().equals(tenantDto.getEmail()))); + verify(keycloakService, times(0)).deleteKeycloakUserById("keycloakId"); + } + + } + + @Nested + class DeleteCoTenantTest { + + @BeforeEach + void before() { + TransactionSynchronizationManager.initSynchronization(); + } + + @AfterEach + void after() { + TransactionSynchronizationManager.clear(); + } + + @Test + void shouldReturnFalseWhenTenantIsNotCreate() { + var tenant = Tenant.builder() + .id(1L) + .email("test@test.fr") + .tenantType(TenantType.JOIN) + .build(); + + var result = userService.deleteCoTenant(tenant, 2L); + assertEquals(result, false); + } + + @Test + void shouldReturnFalseWhenCoTenantIsNotPresent() { + + var apartmentSharing = ApartmentSharing.builder() + .id(1L) + .build(); + + var tenant = Tenant.builder() + .id(1L) + .email("test@test.fr") + .tenantType(TenantType.CREATE) + .apartmentSharing(apartmentSharing) + .build(); + + apartmentSharing.setTenants(List.of(tenant)); + + var result = userService.deleteCoTenant(tenant, 2L); + assertEquals(result, false); + } + + @Test + void shouldDeleteCoTenant() { + + var apartmentSharing = ApartmentSharing.builder() + .id(1L) + .build(); + + var tenant = Tenant.builder() + .id(1L) + .email("test@test.fr") + .tenantType(TenantType.CREATE) + .apartmentSharing(apartmentSharing) + .build(); + + var coTenant = Tenant.builder() + .id(2L) + .email("test2@test.fr") + .tenantType(TenantType.JOIN) + .apartmentSharing(apartmentSharing) + .build(); + + apartmentSharing.setTenants(List.of(tenant, coTenant)); + + var result = userService.deleteCoTenant(tenant, coTenant.getId()); + + assertEquals(result, true); + verify(userRepository, times(1)).delete(coTenant); + verify(apartmentSharingService, times(1)).removeTenant(apartmentSharing, coTenant); + + } + + } + + @Nested + class LinkTenantToPartnerTest { + + @Test + void shouldDoNothingIfPartnerDoesNotExist() { + var tenant = Tenant.builder() + .id(1L) + .email("test@test.fr") + .build(); + + when(userApiService.findByName("partner")).thenReturn(Optional.empty()); + + userService.linkTenantToPartner(tenant, "partner", "internalPartnerId"); + + verify(partnerCallBackService, times(0)).registerTenant(any(), any()); + } + + @Test + void shouldLinkTenantToPartnerWhenNotCouple() { + var apartmentSharing = ApartmentSharing.builder() + .id(1L) + .applicationType(ApplicationType.ALONE) + .build(); + + var tenant = Tenant.builder() + .id(1L) + .email("test@test.fr") + .tenantType(TenantType.CREATE) + .apartmentSharing(apartmentSharing) + .build(); + + var userApi = UserApi.builder() + .id(1L) + .build(); + + apartmentSharing.setTenants(List.of(tenant)); + + when(userApiService.findByName("partner")).thenReturn(Optional.of(userApi)); + + userService.linkTenantToPartner(tenant, "partner", "internalPartnerId"); + + verify(partnerCallBackService, times(1)).registerTenant(tenant, userApi); + } + + @Test + void shouldLinkTenantToPartnerWhenCouple() { + var apartmentSharing = ApartmentSharing.builder() + .id(1L) + .applicationType(ApplicationType.COUPLE) + .build(); + + var tenant = Tenant.builder() + .id(1L) + .email("test@test.fr") + .tenantType(TenantType.CREATE) + .apartmentSharing(apartmentSharing) + .build(); + + var tenant2 = Tenant.builder() + .id(2L) + .email("test2@test.fr") + .tenantType(TenantType.JOIN) + .apartmentSharing(apartmentSharing) + .build(); + + var userApi = UserApi.builder() + .id(1L) + .build(); + + apartmentSharing.setTenants(List.of(tenant, tenant2)); + + when(userApiService.findByName("partner")).thenReturn(Optional.of(userApi)); + + userService.linkTenantToPartner(tenant, "partner", "internalPartnerId"); + + verify(partnerCallBackService, times(2)).registerTenant(any(), any()); + verify(partnerCallBackService).registerTenant(tenant, userApi); + verify(partnerCallBackService).registerTenant(tenant2, userApi); + } + } + + @Test + void logoutTest() { + userService.logout("keycloakId"); + + verify(keycloakService, times(1)).logout("keycloakId"); + } + + @Nested + class UnlinkFranceConnectTest { + + @Test + void shouldThrowIllegalArgumentExceptionWhenTenantNotFound() { + var tenant = Tenant.builder() + .id(1L) + .email("test@test.fr") + .build(); + + when(userRepository.findById(tenant.getId())).thenReturn(Optional.empty()); + + assertThrows(IllegalArgumentException.class, () -> + userService.unlinkFranceConnect(tenant)); + } + + @Test + void shouldUnlinkFranceConnect() { + var tenant = Tenant.builder() + .id(1L) + .email("test@test.fr") + .build(); + + when(userRepository.findById(tenant.getId())).thenReturn(Optional.of(tenant)); + + userService.unlinkFranceConnect(tenant); + + verify(userRepository, times(1)).save(tenant); + verify(keycloakService, times(1)).unlinkFranceConnect(tenant); + + } + + } +} diff --git a/dossierfacile-api-tenant/src/test/resources/application.properties b/dossierfacile-api-tenant/src/test/resources/application.properties new file mode 100644 index 000000000..138c542f1 --- /dev/null +++ b/dossierfacile-api-tenant/src/test/resources/application.properties @@ -0,0 +1 @@ +spring.profiles.active=test,mockOvh \ No newline at end of file diff --git a/dossierfacile-api-tenant/src/test/resources/logback-spring-delayed.xml b/dossierfacile-api-tenant/src/test/resources/logback-spring-delayed.xml new file mode 100644 index 000000000..fbb075614 --- /dev/null +++ b/dossierfacile-api-tenant/src/test/resources/logback-spring-delayed.xml @@ -0,0 +1,17 @@ + + + + + + + true + + %d{yyyy-MM-dd HH:mm:ss.SSS} %clr(%-5level) [%thread] %logger{35} - %msg %n + + + + + + + + \ No newline at end of file diff --git a/dossierfacile-common-test-library/pom.xml b/dossierfacile-common-test-library/pom.xml new file mode 100644 index 000000000..4285f9f5c --- /dev/null +++ b/dossierfacile-common-test-library/pom.xml @@ -0,0 +1,156 @@ + + + 4.0.0 + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + compile + + compile + + + + src/main/java + target/generated-sources/annotations + + + + + test-compile + test-compile + + test-compile + + + + src/test/java + target/generated-test-sources/test-annotations + + + + + + 1.8 + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-compile + none + + + default-testCompile + none + + + compile + compile + + compile + + + + testCompile + test-compile + + testCompile + + + + + ${java.version} + ${java.version} + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${org.mapstruct.version} + + + + + + + + + fr.gouv + dossierfacile + 1.0.0 + ../pom.xml + + + fr.dossierfacile + dossierfacile-common-test-library + ${revision} + dossierfacile-common-test-library + Common tools for tests + + + 1.9.24 + + + + + fr.dossierfacile + dossierfacile-common-library + + + + org.projectlombok + lombok + provided + true + + + + org.apache.commons + commons-lang3 + + + + org.springframework.boot + spring-boot-starter-test + provided + + + org.junit.vintage + junit-vintage-engine + + + + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + provided + + + + com.fasterxml.jackson.core + jackson-annotations + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + ${kotlin.version} + + + + \ No newline at end of file diff --git a/dossierfacile-common-test-library/src/main/java/fr/dossierfacile/authentification/JwtFactory.kt b/dossierfacile-common-test-library/src/main/java/fr/dossierfacile/authentification/JwtFactory.kt new file mode 100644 index 000000000..c2081fe5c --- /dev/null +++ b/dossierfacile-common-test-library/src/main/java/fr/dossierfacile/authentification/JwtFactory.kt @@ -0,0 +1,32 @@ +package fr.dossierfacile.authentification + +import org.springframework.security.oauth2.jwt.Jwt +import java.time.Instant +import java.time.temporal.ChronoUnit + +fun getDummyJwtWithCustomClaims( + claims: Map +): Jwt { + return getDummyJwt(claims = claims) +} + +@JvmOverloads +fun getDummyJwt( + tokenValue: String? = "test", + headers: Map? = mapOf( + "alg" to "none" + ), + claims: Map? = mapOf( + "test" to "test" + ), + issuedAt: Instant? = Instant.now(), + expiresAt: Instant? = Instant.now().plus(10, ChronoUnit.MINUTES) +): Jwt { + val jwtBuilder = Jwt.withTokenValue(tokenValue) + headers?.forEach(jwtBuilder::header) + claims?.forEach(jwtBuilder::claim) + jwtBuilder.issuedAt(issuedAt) + jwtBuilder.expiresAt(expiresAt) + + return jwtBuilder.build() +} \ No newline at end of file diff --git a/dossierfacile-common-test-library/src/main/java/fr/dossierfacile/parameterizedtest/ArgumentBuilder.java b/dossierfacile-common-test-library/src/main/java/fr/dossierfacile/parameterizedtest/ArgumentBuilder.java new file mode 100644 index 000000000..4d85f4bf5 --- /dev/null +++ b/dossierfacile-common-test-library/src/main/java/fr/dossierfacile/parameterizedtest/ArgumentBuilder.java @@ -0,0 +1,20 @@ +package fr.dossierfacile.parameterizedtest; + +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.Named; +import org.junit.jupiter.params.provider.Arguments; + +import java.util.Arrays; +import java.util.List; + +public class ArgumentBuilder { + + public static Arguments buildArguments(String name, ControllerParameter object) { + return Arguments.of(Named.of(name, object)); + } + + @SafeVarargs + public static List buildListOfArguments(Pair>... pairList) { + return Arrays.stream(pairList).map(pair -> buildArguments(pair.getLeft(), pair.getRight())).toList(); + } +} diff --git a/dossierfacile-common-test-library/src/main/java/fr/dossierfacile/parameterizedtest/ControllerParameter.java b/dossierfacile-common-test-library/src/main/java/fr/dossierfacile/parameterizedtest/ControllerParameter.java new file mode 100644 index 000000000..e9320dd85 --- /dev/null +++ b/dossierfacile-common-test-library/src/main/java/fr/dossierfacile/parameterizedtest/ControllerParameter.java @@ -0,0 +1,24 @@ +package fr.dossierfacile.parameterizedtest; + +import org.springframework.test.web.servlet.ResultMatcher; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import java.util.List; +import java.util.function.Function; + +public class ControllerParameter { + + public T parameterData; + public int status; + public RequestPostProcessor requestPostProcessor; + public Function setupMock; + public List resultMatchers; + + public ControllerParameter(T parameterData, int status, RequestPostProcessor requestPostProcessor, Function setupMock, List resultMatchers) { + this.parameterData = parameterData; + this.status = status; + this.requestPostProcessor = requestPostProcessor; + this.setupMock = setupMock; + this.resultMatchers = resultMatchers; + } +} diff --git a/dossierfacile-common-test-library/src/main/java/fr/dossierfacile/parameterizedtest/ParameterizedTestHelper.java b/dossierfacile-common-test-library/src/main/java/fr/dossierfacile/parameterizedtest/ParameterizedTestHelper.java new file mode 100644 index 000000000..7b5a30a23 --- /dev/null +++ b/dossierfacile-common-test-library/src/main/java/fr/dossierfacile/parameterizedtest/ParameterizedTestHelper.java @@ -0,0 +1,29 @@ +package fr.dossierfacile.parameterizedtest; + +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultMatcher; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public class ParameterizedTestHelper { + public static void runControllerTest( + MockMvc mockMvc, + MockHttpServletRequestBuilder mockMvcRequestBuilder, + ControllerParameter parameter + ) throws Exception { + if (parameter.setupMock != null) { + parameter.setupMock.apply(null); + } + + if (parameter.requestPostProcessor != null) { + mockMvcRequestBuilder.with(parameter.requestPostProcessor); + } + + mockMvc.perform(mockMvcRequestBuilder) + .andDo(print()) + .andExpect(status().is(parameter.status)) + .andExpectAll(parameter.resultMatchers.toArray(ResultMatcher[]::new)); + } +} diff --git a/dossierfacile-process-file/src/test/java/fr/dossierfacile/process/file/TestFilesUtil.java b/dossierfacile-process-file/src/test/java/fr/dossierfacile/process/file/TestFilesUtil.java index cf5767975..a48f188cb 100644 --- a/dossierfacile-process-file/src/test/java/fr/dossierfacile/process/file/TestFilesUtil.java +++ b/dossierfacile-process-file/src/test/java/fr/dossierfacile/process/file/TestFilesUtil.java @@ -8,6 +8,12 @@ import java.awt.image.BufferedImage; import java.io.IOException; import java.io.InputStream; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.stream.Collectors; public class TestFilesUtil { @@ -34,4 +40,22 @@ public static BufferedImage getImage(String fileName) throws IOException { } } + public static List getFilesFromDirectory(Path directory) throws URISyntaxException, IOException { + var folderPath = Path.of("documents", directory.toString()).toString(); + var url = CLASS_LOADER.getResource(folderPath); + + if (url == null) { + throw new IllegalArgumentException("folder does not exist : " + folderPath); + } + + var actualPath = Paths.get(url.toURI()); + + return Files.list(actualPath) + .map(file -> { + return directory + "/" + file.getFileName().toString(); + }) + .collect(Collectors.toList()); + + } + } diff --git a/dossierfacile-process-file/src/test/java/fr/dossierfacile/process/file/barcode/twoddoc/reader/TwoDDocParserTest.java b/dossierfacile-process-file/src/test/java/fr/dossierfacile/process/file/barcode/twoddoc/reader/TwoDDocParserTest.java new file mode 100644 index 000000000..994e6a5b3 --- /dev/null +++ b/dossierfacile-process-file/src/test/java/fr/dossierfacile/process/file/barcode/twoddoc/reader/TwoDDocParserTest.java @@ -0,0 +1,60 @@ +package fr.dossierfacile.process.file.barcode.twoddoc.reader; + +import fr.dossierfacile.process.file.TestFilesUtil; +import fr.dossierfacile.process.file.barcode.twoddoc.TwoDDocRawContent; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Named; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.util.Optional; +import java.util.stream.Stream; + +import static fr.dossierfacile.process.file.TestFilesUtil.getFilesFromDirectory; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +// Disable this test because the dataSources are not available for the moment +@Disabled +public class TwoDDocParserTest { + + record ParameterTest(String fileName, boolean isValid) { + } + + private static Stream provideTwoDDocParameters() throws URISyntaxException, IOException { + var validFiles = getFilesFromDirectory(Path.of("2dDocDatasource", "valid")); + var invalidFiles = getFilesFromDirectory(Path.of("2dDocDatasource", "invalid")); + return Stream.concat(validFiles.stream(), invalidFiles.stream()).map( + file -> Arguments.of(Named.of(file.toString(), new ParameterTest(file.toString(), !invalidFiles.contains(file)))) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideTwoDDocParameters") + void test2dParsingOnDocuments(ParameterTest parameterTest) throws IOException { + if (parameterTest.fileName.contains("pdf")) { + var pdfDocument = TestFilesUtil.getPdfBoxDocument(parameterTest.fileName); + Optional actualContent = TwoDDocPdfFinder.on(pdfDocument).find2DDoc(); + if (parameterTest.isValid) { + assertThat(actualContent).isPresent(); + assertThat(actualContent.get().rawContent()).isNotEmpty(); + } else { + assertThat(actualContent).isEmpty(); + } + } else { + BufferedImage image = TestFilesUtil.getImage(parameterTest.fileName); + Optional actualContent = new TwoDDocImageFinder(image).find2DDoc(); + if (parameterTest.isValid) { + assertThat(actualContent).isPresent(); + assertThat(actualContent.get().rawContent()).isNotEmpty(); + } else { + assertThat(actualContent).isEmpty(); + } + } + } + +} diff --git a/dossierfacile-process-file/src/test/java/fr/dossierfacile/process/file/barcode/twoddoc/reader/TwoDDocPdfFinderTest.java b/dossierfacile-process-file/src/test/java/fr/dossierfacile/process/file/barcode/twoddoc/reader/TwoDDocPdfFinderTest.java index 91e70d79b..afc65fad4 100644 --- a/dossierfacile-process-file/src/test/java/fr/dossierfacile/process/file/barcode/twoddoc/reader/TwoDDocPdfFinderTest.java +++ b/dossierfacile-process-file/src/test/java/fr/dossierfacile/process/file/barcode/twoddoc/reader/TwoDDocPdfFinderTest.java @@ -12,7 +12,6 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; -@Disabled class TwoDDocPdfFinderTest { @Test diff --git a/pom.xml b/pom.xml index 2be822395..5409b2413 100644 --- a/pom.xml +++ b/pom.xml @@ -15,6 +15,7 @@ 1.0.0 dossierfacile-common-library + dossierfacile-common-test-library dossierfacile-bo dossierfacile-process-file dossierfacile-api-tenant