From 75b6995b949fa5424d6cc9146ec321f3680e5d45 Mon Sep 17 00:00:00 2001 From: kasun Date: Mon, 30 Mar 2026 05:07:51 +0200 Subject: [PATCH] added post request to achieve login response with tokens --- .../app/connection/ConnectionController.java | 27 ++++ .../app/connection/ConnectionService.java | 28 ++++ .../com/vaessl/app/dto/ConnectionRequest.java | 9 ++ .../vaessl/app/dto/ConnectionResponse.java | 7 + .../app/exception/GlobalExceptionHandler.java | 40 ++++++ .../connection/ConnectionIntegrationTest.java | 120 ++++++++++++++++++ 6 files changed, 231 insertions(+) create mode 100644 backend/src/main/java/com/vaessl/app/connection/ConnectionController.java create mode 100644 backend/src/main/java/com/vaessl/app/connection/ConnectionService.java create mode 100644 backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java create mode 100644 backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java create mode 100644 backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java create mode 100644 backend/src/test/java/com/vaessl/app/connection/ConnectionIntegrationTest.java diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java new file mode 100644 index 0000000..3918311 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java @@ -0,0 +1,27 @@ +package com.vaessl.app.connection; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import com.vaessl.app.dto.ConnectionRequest; +import com.vaessl.app.dto.ConnectionResponse; + +import jakarta.validation.Valid; + +@RestController +public class ConnectionController { + + private final ConnectionService connectionService; + + public ConnectionController(ConnectionService connectionService) { + this.connectionService = connectionService; + } + + @PostMapping("/login") + public ResponseEntity loginResponse(@Valid @RequestBody ConnectionRequest request) { + ConnectionResponse connectionResponse = connectionService.login(request); + return ResponseEntity.ok(connectionResponse); + } +} diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java new file mode 100644 index 0000000..3cae0c9 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java @@ -0,0 +1,28 @@ +package com.vaessl.app.connection; + +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import com.vaessl.app.dto.ConnectionRequest; +import com.vaessl.app.dto.ConnectionResponse; + +@Service +public class ConnectionService { + + private final RestClient.Builder restClientBuilder; + + public ConnectionService(RestClient.Builder restClientBuilder) { + this.restClientBuilder = restClientBuilder; + } + + public ConnectionResponse login(ConnectionRequest request) { + //TODO: Look into Map to cache restclient requests. + return restClientBuilder.baseUrl(request.appUrl()) + .build() + .post() + .uri("/api/v1/users/login") + .body(request) + .retrieve() + .body(ConnectionResponse.class); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java b/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java new file mode 100644 index 0000000..8b6d513 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java @@ -0,0 +1,9 @@ +package com.vaessl.app.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ConnectionRequest( + @NotBlank(message = "App URL is mandatory") String appUrl, + @NotBlank(message = "Username is mandatory") String username, + @NotBlank(message = "Password is mandatory") String password) { +} diff --git a/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java b/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java new file mode 100644 index 0000000..521407b --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java @@ -0,0 +1,7 @@ +package com.vaessl.app.dto; + +import java.time.Instant; + +public record ConnectionResponse(String token, String attachmentToken, Instant expiresAt) { + +} diff --git a/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..0062628 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java @@ -0,0 +1,40 @@ +package com.vaessl.app.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ProblemDetail handleEmptyCredentialInput(MethodArgumentNotValidException e) { + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Fields must not be empty."); + } + + @ExceptionHandler(HttpClientErrorException.Unauthorized.class) + public ProblemDetail handleUnauthorizedAccess(HttpClientErrorException e) { + + return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, "Invalid username or password."); + } + + @ExceptionHandler(ResourceAccessException.class) + public ProblemDetail handleNoConnection(ResourceAccessException e) { + + return ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE, "The target URL is unreachable."); + } + + @ExceptionHandler(HttpServerErrorException.class) + public ProblemDetail handleTimeoutOrNotFound(HttpServerErrorException e) { + + return ProblemDetail + .forStatusAndDetail(e.getStatusCode(), + "The external app returned a server error: " + e.getStatusText()); + } + +} diff --git a/backend/src/test/java/com/vaessl/app/connection/ConnectionIntegrationTest.java b/backend/src/test/java/com/vaessl/app/connection/ConnectionIntegrationTest.java new file mode 100644 index 0000000..08452e2 --- /dev/null +++ b/backend/src/test/java/com/vaessl/app/connection/ConnectionIntegrationTest.java @@ -0,0 +1,120 @@ +package com.vaessl.app.connection; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.resttestclient.TestRestTemplate; +import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; + +import com.jayway.jsonpath.DocumentContext; +import com.jayway.jsonpath.JsonPath; +import com.vaessl.app.dto.ConnectionRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static com.github.tomakehurst.wiremock.client.WireMock.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestRestTemplate +@WireMockTest +public class ConnectionIntegrationTest { + + @Autowired + TestRestTemplate restTemplate; + + private static final String API_LOGIN = "/api/v1/users/login"; + + /** + * Returns Token and status code OK when login is successful. + */ + @Test + void shouldReturnTokenAndStatusOkWhenCredentialsAreValid(WireMockRuntimeInfo wm) { + + stubFor(post(API_LOGIN) + .willReturn(okJson(""" + { + "token": "fake-jwt-token", + "attachmentToken": "fake-attach", + "expiresAt": "2026-04-26T02:23:13Z" + } + """))); + + ResponseEntity response = restTemplate.postForEntity("/login", connectionRequest(wm), String.class); + + DocumentContext documentContext = JsonPath.parse(response.getBody()); + + String token = documentContext.read("$.token"); + assertThat(token).isEqualTo("fake-jwt-token"); + + String attachmentToken = documentContext.read("$.attachmentToken"); + assertThat(attachmentToken).isEqualTo("fake-attach"); + + String expiresAt = documentContext.read("$.expiresAt", String.class); + assertThat(expiresAt).isEqualTo("2026-04-26T02:23:13Z"); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + /** + * Test if login request fails with 401 unauthorized. + */ + @Test + void shouldFailToConnectWhenHomeboxReturnsUnauthorized(WireMockRuntimeInfo wm) { + + stubFor(post(API_LOGIN).willReturn(unauthorized())); + + ResponseEntity response = restTemplate.postForEntity("/login", connectionRequest(wm), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response.getBody()).contains("Invalid username or password."); + } + + /** + * Tests a server error from the external api. + */ + @Test + void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) { + + stubFor(post(API_LOGIN).willReturn(serviceUnavailable())); + + ResponseEntity response = restTemplate.postForEntity("/login", connectionRequest(wm), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(response.getBody()).contains("The external app returned a server error"); + } + + /** + * Checks when the service is unavailable or the app URL is wrong. + */ + @Test + void shouldReturnServiceUnavailableWhenUrlIsCompletelyWrong() { + + ConnectionRequest badRequest = new ConnectionRequest( + "http://localhost:1234", + "user", + "pass"); + + ResponseEntity response = restTemplate.postForEntity("/login", badRequest, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(response.getBody()).contains("The target URL is unreachable."); + } + + @Test + void shouldReturnBadRequestWhenUrlIsMissing() { + + ConnectionRequest emtpyRequest = new ConnectionRequest("", "", ""); + + ResponseEntity response = restTemplate.postForEntity("/login", emtpyRequest, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).contains("Fields must not be empty."); + } + + private ConnectionRequest connectionRequest(WireMockRuntimeInfo wireMockRuntimeInfo) { + return new ConnectionRequest(wireMockRuntimeInfo.getHttpBaseUrl(), "username", "password"); + } +}