diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index c1738af..05bcab7 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -1,6 +1,6 @@ plugins { java - id("org.springframework.boot") version "4.0.4" + id("org.springframework.boot") version "4.0.5" id("io.spring.dependency-management") version "1.1.7" } @@ -40,8 +40,7 @@ dependencies { testImplementation("org.springframework.boot:spring-boot-starter-validation-test") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") - testImplementation("org.springframework.boot:spring-boot-starter-test") -} + testImplementation("org.wiremock:wiremock-standalone:3.12.0")} dependencyManagement { imports { diff --git a/backend/src/main/java/com/vaessl/app/Application.java b/backend/src/main/java/com/vaessl/app/Application.java index 7075c45..28b1853 100644 --- a/backend/src/main/java/com/vaessl/app/Application.java +++ b/backend/src/main/java/com/vaessl/app/Application.java @@ -9,5 +9,4 @@ public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } - } 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..ae9409c --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionController.java @@ -0,0 +1,24 @@ +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; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class ConnectionController { + + private final ConnectionService connectionService; + + @PostMapping("/login") + public ResponseEntity loginResponse(@Valid @RequestBody ConnectionRequest request) { + return ResponseEntity.ok(connectionService.login(request)); + } +} diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionEntity.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionEntity.java new file mode 100644 index 0000000..96b769f --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionEntity.java @@ -0,0 +1,30 @@ +package com.vaessl.app.connection; + +import jakarta.persistence.DiscriminatorColumn; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "connections", uniqueConstraints = { @UniqueConstraint(columnNames = { "appUrl", "username" }) }) +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +@DiscriminatorColumn(name = "service_type") +@Getter +@Setter +@NoArgsConstructor +public abstract class ConnectionEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String appUrl; + private String username; +} diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java new file mode 100644 index 0000000..bd2c7d1 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionProvider.java @@ -0,0 +1,19 @@ +package com.vaessl.app.connection; + +import com.vaessl.app.dto.ConnectionRequest; +import com.vaessl.app.dto.ConnectionResponse; + +public interface ConnectionProvider { + + void checkCredentials(ConnectionRequest request); + + String getServiceType(); + + ConnectionResponse authenticate(ConnectionRequest request); + + ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request); + + ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response); + + void updateToRepository(ConnectionEntity existing, ConnectionResponse response); +} diff --git a/backend/src/main/java/com/vaessl/app/connection/ConnectionRepository.java b/backend/src/main/java/com/vaessl/app/connection/ConnectionRepository.java new file mode 100644 index 0000000..880f567 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionRepository.java @@ -0,0 +1,10 @@ +package com.vaessl.app.connection; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ConnectionRepository extends JpaRepository { + + ConnectionEntity findByAppUrlAndUsername(String appUrl, String username); +} 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..f68edf7 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/ConnectionService.java @@ -0,0 +1,49 @@ +package com.vaessl.app.connection; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; + +import com.vaessl.app.dto.ConnectionRequest; +import com.vaessl.app.dto.ConnectionResponse; +import com.vaessl.app.exception.WrongServiceTypeException; + +@Service +public class ConnectionService { + + private final Map providerRegistry; + + private final ConnectionRepository cRepository; + + public ConnectionService(List providers, ConnectionRepository cRepository) { + this.providerRegistry = providers.stream() + .collect(Collectors.toMap(ConnectionProvider::getServiceType, p -> p)); + this.cRepository = cRepository; + } + + public ConnectionResponse login(ConnectionRequest request) { + + ConnectionProvider provider = providerRegistry.get(request.serviceType()); + + if (provider == null) { + throw new WrongServiceTypeException(); + } + + provider.checkCredentials(request); + + ConnectionResponse response = provider.authenticate(request); + + ConnectionEntity existing = provider.findUniqueConnectionEntry(request); + + if (existing != null) { + provider.updateToRepository(existing, response); + } else { + ConnectionEntity newEntity = provider.connectionToEntity(request, response); + cRepository.save(newEntity); + } + + return response; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/vaessl/app/connection/Endpoint.java b/backend/src/main/java/com/vaessl/app/connection/Endpoint.java new file mode 100644 index 0000000..1056771 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/Endpoint.java @@ -0,0 +1,15 @@ +package com.vaessl.app.connection; + +public enum Endpoint { + HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"); + + private final String value; + + private Endpoint(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java b/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java new file mode 100644 index 0000000..051ea85 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/HomeBoxConnectionProvider.java @@ -0,0 +1,103 @@ +package com.vaessl.app.connection; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import com.vaessl.app.dto.ConnectionRequest; +import com.vaessl.app.dto.ConnectionResponse; +import com.vaessl.app.exception.EmptyCredentialsException; + +import static com.vaessl.app.connection.Endpoint.*; + +@Component +public class HomeBoxConnectionProvider implements ConnectionProvider { + + private final RestClient.Builder restClientBuilder; + + private final ConnectionRepository cRepository; + + public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) { + this.restClientBuilder = restClientBuilder; + this.cRepository = cRepository; + } + + @Override + public void checkCredentials(ConnectionRequest request) { + if (request.username() == null || request.password() == null) { + List missingFields = new ArrayList<>(); + + if (request.username() == null) { + missingFields.add("username"); + } + if (request.password() == null) { + missingFields.add("password"); + + } + throw new EmptyCredentialsException(List.copyOf(missingFields)); + } + } + + @Override + public String getServiceType() { + return "HOMEBOX"; + } + + @Override + public ConnectionResponse authenticate(ConnectionRequest request) { + Map homeboxPayload = Map.of("username", request.username(), + "password", request.password(), "stayLoggedIn", + request.stayLoggedIn()); + + HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()) + .build() + .post() + .uri(HOMEBOX_LOGIN.getValue()) + .body(homeboxPayload) + .retrieve() + .body(HomeboxLoginResponse.class); + + if (hbResponse == null) { + throw new IllegalStateException("Remote API returned an empty body for " + request.appUrl()); + } + + Map attachmentToken = new HashMap<>(); + + attachmentToken.put("attachmentToken", hbResponse.attachmentToken()); + + return new ConnectionResponse(hbResponse.token(), hbResponse.expiresAt(), attachmentToken); + } + + @Override + public ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request) { + + return cRepository.findByAppUrlAndUsername(request.appUrl(), request.username()); + } + + @Override + public ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response) { + return HomeboxEntity.from(request, response); + } + + @Override + public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) { + + if (existing instanceof HomeboxEntity hbE) { + + hbE.setToken(response.token()); + hbE.setExpiresAt(response.expiresAt()); + hbE.setAttachmentToken(response.getExtraVar("attachmentToken")); + + cRepository.save(hbE); + } + } + + private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) { + } + +} diff --git a/backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java b/backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java new file mode 100644 index 0000000..e965269 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/HomeboxEntity.java @@ -0,0 +1,35 @@ +package com.vaessl.app.connection; + +import java.time.Instant; + +import com.vaessl.app.dto.ConnectionRequest; +import com.vaessl.app.dto.ConnectionResponse; + +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import lombok.Getter; +import lombok.Setter; + +@Entity +@DiscriminatorValue("HOMEBOX") +@Getter +@Setter +public class HomeboxEntity extends ConnectionEntity { + + private String token; + private String attachmentToken; + private Instant expiresAt; + + public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) { + + HomeboxEntity he = new HomeboxEntity(); + + he.setAppUrl(request.appUrl()); + he.setUsername(request.username()); + he.setToken(response.token()); + he.setAttachmentToken(response.getExtraVar("attachmentToken")); + he.setExpiresAt(response.expiresAt()); + + return he; + } +} diff --git a/backend/src/main/java/com/vaessl/app/connection/ServiceType.java b/backend/src/main/java/com/vaessl/app/connection/ServiceType.java new file mode 100644 index 0000000..d579690 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/connection/ServiceType.java @@ -0,0 +1,14 @@ +package com.vaessl.app.connection; + +import lombok.Getter; + +@Getter +public enum ServiceType { + HOMEBOX("HOMEBOX"); + + private final String value; + + private ServiceType(String value){ + this.value = value; + } +} 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..3c6272d --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/dto/ConnectionRequest.java @@ -0,0 +1,25 @@ +package com.vaessl.app.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import jakarta.validation.constraints.NotBlank; + +public record ConnectionRequest( + @NotBlank(message = "App URL is mandatory") String appUrl, + @NotBlank(message = "Service type is mandatory") String serviceType, + String username, + String password, + String apiKey, + @JsonProperty(defaultValue = "false") Boolean stayLoggedIn) { + + public ConnectionRequest { + if (stayLoggedIn == null) { + stayLoggedIn = false; + } + } + + public ConnectionRequest(String appUrl, String serviceType, String username, String password, + Boolean stayLoggedIn) { + this(appUrl, serviceType, username, password, null, stayLoggedIn); + } +} 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..9422e7d --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/dto/ConnectionResponse.java @@ -0,0 +1,17 @@ +package com.vaessl.app.dto; + +import java.time.Instant; +import java.util.Map; + +public record ConnectionResponse(String token, Instant expiresAt, Map extraResponseData) { + + public String getExtraVar(String key) { + if(extraResponseData == null) { + return null; + } else { + Object value = extraResponseData.get(key); + + return value != null ? String.valueOf(value) : null; + } + } +} diff --git a/backend/src/main/java/com/vaessl/app/exception/EmptyCredentialsException.java b/backend/src/main/java/com/vaessl/app/exception/EmptyCredentialsException.java new file mode 100644 index 0000000..62acdc6 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/exception/EmptyCredentialsException.java @@ -0,0 +1,13 @@ +package com.vaessl.app.exception; + +import java.util.List; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class EmptyCredentialsException extends RuntimeException { + + private final List missingFields; +} diff --git a/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java b/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java new file mode 100644 index 0000000..ae14081 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/exception/ErrorMessage.java @@ -0,0 +1,35 @@ +package com.vaessl.app.exception; + +import org.springframework.http.HttpStatus; + +import com.fasterxml.jackson.annotation.JsonValue; + +public enum ErrorMessage { + BAD_REQUEST_EMPTY_FIELDS(HttpStatus.BAD_REQUEST, "Fields must not be empty."), UNAUTHORIZED_WRONG_LOGIN( + HttpStatus.UNAUTHORIZED, "Invalid username or password."), SERVICE_UNAVAILABLE_UNREACHABLE_URL( + HttpStatus.SERVICE_UNAVAILABLE, "The target URL is unreachable."), SERVER_ERROR_GENERAL( + "The external app returned a server error: "), WRONG_SERVICE_TYPE(HttpStatus.NOT_FOUND, + "No such service type."); + + private final HttpStatus status; + private final String message; + + private ErrorMessage(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + private ErrorMessage(String message) { + this.status = null; + this.message = message; + } + + public HttpStatus getStatus() { + return status; + } + + @JsonValue + public String getMessage() { + return message; + } +} 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..08994c0 --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/exception/GlobalExceptionHandler.java @@ -0,0 +1,61 @@ +package com.vaessl.app.exception; + +import org.springframework.http.ProblemDetail; +import org.springframework.validation.FieldError; +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; + +import static com.vaessl.app.exception.ErrorMessage.*; + +import java.util.stream.Collectors; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ProblemDetail handleEmptyCredentialInput(MethodArgumentNotValidException e) { + + String defaultMessages = e.getBindingResult().getFieldErrors().stream().map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + + return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(), + BAD_REQUEST_EMPTY_FIELDS.getMessage() + " [" + defaultMessages + "]"); + } + + @ExceptionHandler(HttpClientErrorException.Unauthorized.class) + public ProblemDetail handleUnauthorizedAccess(HttpClientErrorException e) { + + return ProblemDetail.forStatusAndDetail(UNAUTHORIZED_WRONG_LOGIN.getStatus(), + UNAUTHORIZED_WRONG_LOGIN.getMessage()); + } + + @ExceptionHandler(ResourceAccessException.class) + public ProblemDetail handleNoConnection(ResourceAccessException e) { + + return ProblemDetail.forStatusAndDetail(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getStatus(), + SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage()); + } + + @ExceptionHandler(HttpServerErrorException.class) + public ProblemDetail handleTimeoutOrNotFound(HttpServerErrorException e) { + + return ProblemDetail + .forStatusAndDetail(e.getStatusCode(), + SERVER_ERROR_GENERAL.getMessage() + e.getStatusText()); + } + + @ExceptionHandler(WrongServiceTypeException.class) + public ProblemDetail handleWrongServiceType(WrongServiceTypeException e) { + return ProblemDetail.forStatusAndDetail(WRONG_SERVICE_TYPE.getStatus(), WRONG_SERVICE_TYPE.getMessage()); + } + + @ExceptionHandler(EmptyCredentialsException.class) + public ProblemDetail handleEmptyCredentials(EmptyCredentialsException e) { + return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(), + BAD_REQUEST_EMPTY_FIELDS.getMessage() + " " + e.getMissingFields()); + } +} diff --git a/backend/src/main/java/com/vaessl/app/exception/WrongServiceTypeException.java b/backend/src/main/java/com/vaessl/app/exception/WrongServiceTypeException.java new file mode 100644 index 0000000..c2f72fa --- /dev/null +++ b/backend/src/main/java/com/vaessl/app/exception/WrongServiceTypeException.java @@ -0,0 +1,4 @@ +package com.vaessl.app.exception; + +public class WrongServiceTypeException extends RuntimeException { +} diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 3aca02f..f4b136c 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -2,18 +2,19 @@ spring: application: name: vaessl config: - import: "optional:file:.env.local[.properties]" + import: + - "optional:file:.env.local[.properties]" + - "optional:file:backend/.env.local[.properties]" + - "optional:file:vaessl/backend/.env.local[.properties]" datasource: url : ${DB_URL} username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: ${PG_DRIVER_CLASS_NAME} - jpa: hibernate: ddl-auto: create-drop show-sql: true - ai: openai: base-url: ${OPENAI_BASE_URL} @@ -21,7 +22,6 @@ spring: chat: options: model: gpt-4o-mini - - logging: - level: - org.springframework.boot.context.config: TRACE +server: + servlet: + context-path: /api diff --git a/backend/src/test/java/com/vaessl/app/ApplicationTests.java b/backend/src/test/java/com/vaessl/app/ApplicationTests.java index 2e60da2..b2954e9 100644 --- a/backend/src/test/java/com/vaessl/app/ApplicationTests.java +++ b/backend/src/test/java/com/vaessl/app/ApplicationTests.java @@ -16,14 +16,17 @@ import static org.assertj.core.api.Assertions.assertThat; @ActiveProfiles("test") class ApplicationTests { - @Autowired + @Autowired private DataSource dataSource; + @Test + void contextLoads() { + } + @Test void connectionToTestDbWorks() throws SQLException { try (Connection connection = dataSource.getConnection()) { assertThat(connection.getMetaData().getURL()).contains("vaessl_test"); } - } } diff --git a/backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java b/backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java new file mode 100644 index 0000000..2c626f9 --- /dev/null +++ b/backend/src/test/java/com/vaessl/app/connection/ConnectionServiceTest.java @@ -0,0 +1,50 @@ +package com.vaessl.app.connection; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.vaessl.app.dto.ConnectionRequest; +import com.vaessl.app.exception.EmptyCredentialsException; + +import static com.vaessl.app.connection.Mockdata.*; + +@ExtendWith(MockitoExtension.class) +class ConnectionServiceTest { + + @Mock + private ConnectionProvider mockProvider; + @Mock + private ConnectionRepository mockRepo; + + private ConnectionService connectionService; + + @BeforeEach + void setup() { + when(mockProvider.getServiceType()).thenReturn(MOCK_SERVICE_TYPE); + connectionService = new ConnectionService(List.of(mockProvider), mockRepo); + } + + @Test + void login_ShouldAbort_WhenCheckCredentialsThrowsException() { + ConnectionRequest request = new ConnectionRequest(MOCK_URL, MOCK_SERVICE_TYPE, null, null, false); + + doThrow(new EmptyCredentialsException(List.of("username"))) + .when(mockProvider).checkCredentials(request); + + assertThrows(EmptyCredentialsException.class, () -> connectionService.login(request)); + + verify(mockProvider, never()).authenticate(any()); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/vaessl/app/connection/HomeBoxConnectionProviderTest.java b/backend/src/test/java/com/vaessl/app/connection/HomeBoxConnectionProviderTest.java new file mode 100644 index 0000000..eaaca00 --- /dev/null +++ b/backend/src/test/java/com/vaessl/app/connection/HomeBoxConnectionProviderTest.java @@ -0,0 +1,27 @@ +package com.vaessl.app.connection; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +import com.vaessl.app.dto.ConnectionRequest; +import com.vaessl.app.exception.EmptyCredentialsException; + +import static org.assertj.core.api.Assertions.assertThat; +import static com.vaessl.app.connection.Mockdata.*; + +class HomeBoxConnectionProviderTest { + + private final HomeBoxConnectionProvider provider = new HomeBoxConnectionProvider(null, null); + + @Test + void checkCredentials_ShouldThrowException_WhenFieldsAreMissing() { + ConnectionRequest request = new ConnectionRequest(MOCK_URL, "HOMEBOX", null, null, false); + + EmptyCredentialsException exception = assertThrows(EmptyCredentialsException.class, () -> { + provider.checkCredentials(request); + }); + + assertThat(exception.getMissingFields()).containsExactly("username", "password"); + } +} diff --git a/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java new file mode 100644 index 0000000..8646120 --- /dev/null +++ b/backend/src/test/java/com/vaessl/app/connection/HomeboxIntegrationTest.java @@ -0,0 +1,220 @@ +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.*; +import static com.vaessl.app.connection.Endpoint.*; +import static com.vaessl.app.exception.ErrorMessage.*; +import static com.vaessl.app.connection.ServiceType.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureTestRestTemplate +@WireMockTest +class HomeboxIntegrationTest { + + @Autowired + TestRestTemplate restTemplate; + + @Autowired + ConnectionRepository cRepository; + + String okJsonHomeboxResponse = """ + { + "token": "fake-jwt-token", + "attachmentToken": "fake-attach", + "expiresAt": "2026-04-26T02:23:13Z" + } + """; + + private static final String MOCK_URL = "http://localhost:1234"; + private static final String TEST_USER = "admin"; + private static final String TEST_PASS = "pw"; + + /** + * Returns Token and status code OK when login is successful. + * + * @param wm + * the WiremockRuntimeInfo object + */ + @Test + void shouldReturnTokenAndStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) { + + stubFor(post(HOMEBOX_LOGIN.getValue()) + .willReturn(okJson(okJsonHomeboxResponse))); + + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), 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("$.extraResponseData.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); + } + + /** + * Tests the Unauthorized custom exception. + * + * @param wm + * the WiremockRuntimeInfo object + */ + @Test + void shouldFailToConnectWhenHomeboxReturnsUnauthorized(WireMockRuntimeInfo wm) { + + stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized())); + + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response.getBody()).contains(UNAUTHORIZED_WRONG_LOGIN.getMessage()); + } + + /** + * Tests a server error from the external api. + * + * @param wm + * the WiremockRuntimeInfo object + */ + @Test + void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) { + + stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable())); + + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(response.getBody()).contains(SERVER_ERROR_GENERAL.getMessage()); + } + + /** + * Checks when the service is unavailable or the app URL is wrong. + */ + @Test + void shouldReturnServiceUnavailableWhenHomeboxUrlIsWrong() { + + ConnectionRequest badRequest = new ConnectionRequest( + MOCK_URL, + HOMEBOX.getValue(), + TEST_USER, TEST_PASS, + false); + + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(response.getBody()).contains(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage()); + } + + /** + * Checks if any login fields are empty since all of them are mandatory. + */ + @Test + void shouldReturnBadRequestWhenHomeboxFieldsAreEmpty() { + + ConnectionRequest emtpyRequest = new ConnectionRequest("", "", "", "", false); + + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), emtpyRequest, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage()); + } + + /** + * Test the exception when there is an input for serviceType but it's + * unsupported. + */ + @Test + void shouldReturnWrongServiceTypeException() { + ConnectionRequest wrongServiceTypeReq = new ConnectionRequest( + MOCK_URL, + "wrong-service-type", + TEST_USER, TEST_PASS, + false); + + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq, + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(response.getBody()).contains(WRONG_SERVICE_TYPE.getMessage()); + } + + /** + * Tests the succesfull persistance of Homebox credential response to the + * database. + * + * @param wm + * the WiremockRuntimeInfo object + */ + @Test + void shouldSaveHomeboxConnectionResponseToDb(WireMockRuntimeInfo wm) { + + stubFor(post(urlEqualTo(HOMEBOX_LOGIN.getValue())) + .willReturn(okJson(okJsonHomeboxResponse))); + + ConnectionRequest request = connectionRequest(wm); + + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), request, String.class); + + DocumentContext responseContext = JsonPath.parse(response.getBody()); + + ConnectionEntity dbEntry = cRepository.findByAppUrlAndUsername(request.appUrl(), request.username()); + + assertThat(dbEntry).isNotNull(); + + assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl()); + assertThat(dbEntry.getUsername()).isEqualTo(request.username()); + + if (dbEntry instanceof HomeboxEntity hbE) { + assertThat(hbE.getToken()).isEqualTo(responseContext.read("$.token")); + assertThat(hbE.getAttachmentToken()).isEqualTo(responseContext.read("$.extraResponseData.attachmentToken")); + assertThat(hbE.getExpiresAt()).isEqualTo(responseContext.read("$.expiresAt")); + } + } + + @Test + void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing() { + ConnectionRequest missingCredentials = new ConnectionRequest(MOCK_URL, HOMEBOX.getValue(), TEST_USER, null, + false); + + ResponseEntity response = restTemplate.postForEntity(LOGIN.getValue(), missingCredentials, + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage()); + } + + /** + * Creates a valid connection request with a mock Api throuh + * WireMockRuntimeInfo. + * + * @param wm + * the WiremockRuntimeInfo object + * @return a mock api connection request. + */ + private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) { + return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), TEST_USER, TEST_PASS, + null); + } +} diff --git a/backend/src/test/java/com/vaessl/app/connection/Mockdata.java b/backend/src/test/java/com/vaessl/app/connection/Mockdata.java new file mode 100644 index 0000000..657e15c --- /dev/null +++ b/backend/src/test/java/com/vaessl/app/connection/Mockdata.java @@ -0,0 +1,9 @@ +package com.vaessl.app.connection; + +public final class Mockdata { + + private Mockdata() {} + + public static final String MOCK_URL = "http://localhost:1234"; + public static final String MOCK_SERVICE_TYPE = "SERVICE_TYPE"; +} diff --git a/docs/03-Architecture/01-Login-Architecture.md b/docs/03-Architecture/01-Login-Architecture.md new file mode 100644 index 0000000..3de9f40 --- /dev/null +++ b/docs/03-Architecture/01-Login-Architecture.md @@ -0,0 +1,302 @@ +**vaessl: Login Architecture** + +# Backend +The login architecture is designed to make future additions to this bridging app as frictionless as possible. Abstraction and inheritance will be used as good as possible to keep refactorings to a minimum. The first app to bridge will be Homebox which uses a classic username, password and Bearer token login proces to authenticate calls to its api. The second hypothetic app will be WikiJs which simply uses a user generated api key. The abstraction of the Java classes will try to cater to both authentication methods. + +## Single Table Inheritance +The database entities will follow the Single Table Inheritance concept. + +The database will have one "connections" table that has all the columnns of every supported app. So in this case the table will have username, password and attachment token which are specific to Homebox. Both Homebox and WikiJs will share the token field. +The entities will be organized with an abstract ConnectionEntitiy class containing the id and appUrl and the app specific entities like HomeboxEntitiy: + +***ConnectionEntitiy.java*** + +``` +package com.vaessl.app.connection; + +import jakarta.persistence.DiscriminatorColumn; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "connections") +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +@DiscriminatorColumn(name = "service_type") +@Getter +@Setter +@NoArgsConstructor +public abstract class ConnectionEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + Long id; + + private String appUrl; +} + +``` + +***HomeboxEntitiy.java*** + +``` +package com.vaessl.app.connection; + +import java.time.Instant; + +import com.vaessl.app.dto.ConnectionRequest; +import com.vaessl.app.dto.ConnectionResponse; + +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import lombok.Getter; +import lombok.Setter; + +@Entity +@DiscriminatorValue("HOMEBOX") +@Getter +@Setter +public class HomeboxEntity extends ConnectionEntity { + +private String token; +private String attachmentToken; +private Instant expiresAt; + +public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) { + + HomeboxEntity he = new HomeboxEntity(); + + he.setAppUrl(request.appUrl()); + he.setToken(response.token()); + he.setAttachmentToken(response.getExtraVar("attachmentToken")); + he.setExpiresAt(response.expiresAt()); + + return he; + } +} + +``` + +## The Provider Pattern (Logic Layer) +To keep the core business logic clean, the app uses a Provider Pattern. This separates how the app authenticates (the Specific) from what the system does with that info (the General). + +- ConnectionProvider Interface: Defines the contract. Every new app (Homebox, WikiJS, etc.) must implement this interface to tell the system how to authenticate and how to map its specific API response into a database entity. + +***ConnectionProvider.java*** +``` +package com.vaessl.app.connection; + +import com.vaessl.app.dto.ConnectionRequest; +import com.vaessl.app.dto.ConnectionResponse; + +public interface ConnectionProvider { + + String getServiceType(); + + ConnectionResponse authenticate(ConnectionRequest request); + + ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response); + + void updateToRepository(ConnectionEntity existing, ConnectionResponse response); +} + +``` +***HomeboxConnectionProvider.java*** +``` +package com.vaessl.app.connection; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import com.vaessl.app.dto.ConnectionRequest; +import com.vaessl.app.dto.ConnectionResponse; + +import static com.vaessl.app.connection.Endpoint.*; + +@Component +public class HomeBoxConnectionProvider implements ConnectionProvider { + + private final RestClient.Builder restClientBuilder; + + private final ConnectionRepository cRepository; + + public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) { + this.restClientBuilder = restClientBuilder; + this.cRepository = cRepository; + } + + @Override + public String getServiceType() { + return "HOMEBOX"; + } + + @Override + public ConnectionResponse authenticate(ConnectionRequest request) { + Map homeboxPayload = Map.of("username", request.credentials().get("username"), + "password", request.credentials().get("password"), "stayLoggedIn", + request.stayLoggedIn()); + + HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()) + .build() + .post() + .uri(HOMEBOX_LOGIN.getValue()) + .body(homeboxPayload) + .retrieve() + .body(HomeboxLoginResponse.class); + + if (hbResponse == null) { + throw new IllegalStateException("Remote API returned an empty body for " + request.appUrl()); + } + + Map attachmentToken = new HashMap<>(); + + attachmentToken.put("attachmentToken", hbResponse.attachmentToken()); + + return new ConnectionResponse(hbResponse.token(), hbResponse.expiresAt(), attachmentToken); + } + + @Override + public ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response) { + return HomeboxEntity.from(request, response); + } + + @Override + public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) { + + + if (existing instanceof HomeboxEntity hbE) { + + hbE.setToken(response.token()); + hbE.setExpiresAt(response.expiresAt()); + hbE.setAttachmentToken(response.getExtraVar("attachmentToken")); + + cRepository.save(hbE); + } + } + + private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) { + } + +} +``` + +- Provider Registry: The ConnectionService automatically detects all implementations of the provider interface and stores them in a map. When a login request comes in, the service simply looks up the correct provider by its "Service Type" string. + +***ConnectionService.java*** +``` +package com.vaessl.app.connection; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; + +import com.vaessl.app.dto.ConnectionRequest; +import com.vaessl.app.dto.ConnectionResponse; +import com.vaessl.app.exception.ProviderNotFoundException; + +@Service +public class ConnectionService { + + private final Map providerRegistry; + + private final ConnectionRepository cRepository; + + public ConnectionService(List providers, ConnectionRepository cRepository) { + this.providerRegistry = providers.stream() + .collect(Collectors.toMap(ConnectionProvider::getServiceType, p -> p)); + this.cRepository = cRepository; + } + + public ConnectionResponse login(ConnectionRequest request) { + + ConnectionProvider provider = providerRegistry.get(request.serviceType()); + + if (provider == null) { + throw new ProviderNotFoundException(); + } + + ConnectionResponse response = provider.authenticate(request); + + ConnectionEntity existing = cRepository.findByAppUrl(request.appUrl()); + + if (existing != null) { + provider.updateToRepository(existing, response); + } else { + ConnectionEntity newEntity = provider.connectionToEntity(request, response); + cRepository.save(newEntity); + } + + return response; + } +} +``` + +## Generic Data Exchange (The DTOs) +Since different apps return different types of data (e.g., Homebox returns an attachmentToken, but WikiJS might return a something else), I use a Generic Data Bridge to move information between the API and the Database. + +- ConnectionRequest: A universal "envelope" containing common fields (appUrl, serviceType) and a flexible Map for credentials. This allows one DTO to handle both username/password logins and API-key-only logins. + +***ConnectionRequest.java*** + +``` +package com.vaessl.app.dto; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; + +public record ConnectionRequest( + @NotBlank(message = "App URL is mandatory") String appUrl, + @NotBlank(message = "Service type is mandatory") String serviceType, + @NotEmpty(message = "Credentials are mandatory") Map credentials, + @JsonProperty(defaultValue = "false") Boolean stayLoggedIn) { + + public ConnectionRequest { + if (stayLoggedIn == null) { + stayLoggedIn = false; + } + } +} +``` + +- ConnectionResponse: A "Smart" DTO that holds the core authentication data (the token and expiresAt) and a Map called extraResponseData. + + - The "Smart" Getter: The response object contains helper methods to safely extract app-specific variables from this map. This allows the system to be "Generic" while still giving specific entities (like HomeboxEntity) access to the unique data they need. + +***ConnectionResponse.java*** + +``` +package com.vaessl.app.dto; + +import java.time.Instant; +import java.util.Map; + +public record ConnectionResponse(String token, Instant expiresAt, Map extraResponseData) { + + public String getExtraVar(String key) { + if(extraResponseData == null) { + return null; + } else { + Object value = extraResponseData.get(key); + + return value != null ? String.valueOf(value) : null; + } + } +} + +```