added login logic excl refresh call

This commit is contained in:
2026-04-06 05:02:46 +02:00
parent 9c3e1469c7
commit 240a366ce8
13 changed files with 589 additions and 46 deletions
@@ -0,0 +1,28 @@
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)
private Long id;
private String appUrl;
}
@@ -6,6 +6,10 @@ import com.vaessl.app.dto.ConnectionResponse;
public interface ConnectionProvider {
String getServiceType();
ConnectionResponse authenticate (ConnectionRequest connectionRequest);
ConnectionResponse authenticate(ConnectionRequest request);
ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response);
void updateToRepository(ConnectionEntity existing, ConnectionResponse response);
}
@@ -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, Long> {
ConnectionEntity findByAppUrl(String appUrl);
}
@@ -8,24 +8,40 @@ 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<String, ConnectionProvider> providerRegistry;
public ConnectionService(List<ConnectionProvider> providers) {
private final ConnectionRepository cRepository;
public ConnectionService(List<ConnectionProvider> 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().toUpperCase());
ConnectionProvider provider = providerRegistry.get(request.serviceType());
if (provider == null) {
throw new IllegalArgumentException("Unknown provider: " + request.serviceType());
throw new ProviderNotFoundException();
}
return provider.authenticate(request);
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;
}
}
@@ -1,5 +1,7 @@
package com.vaessl.app.connection;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import org.springframework.stereotype.Component;
@@ -8,15 +10,18 @@ import org.springframework.web.client.RestClient;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import static com.vaessl.app.connection.Endpoints.*;
import static com.vaessl.app.connection.Endpoint.*;
@Component
public class HomeBoxConnectionProvider implements ConnectionProvider {
private final RestClient.Builder restClientBuilder;
public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder) {
private final ConnectionRepository cRepository;
public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) {
this.restClientBuilder = restClientBuilder;
this.cRepository = cRepository;
}
@Override
@@ -25,17 +30,50 @@ public class HomeBoxConnectionProvider implements ConnectionProvider {
}
@Override
public ConnectionResponse authenticate(ConnectionRequest connectionRequest) {
Map<String, Object> homeboxPayload = Map.of("username", connectionRequest.credentials().get("username"),
"password", connectionRequest.credentials().get("password"), "stayLoggedIn",
connectionRequest.stayLoggedIn());
public ConnectionResponse authenticate(ConnectionRequest request) {
Map<String, Object> homeboxPayload = Map.of("username", request.credentials().get("username"),
"password", request.credentials().get("password"), "stayLoggedIn",
request.stayLoggedIn());
return restClientBuilder.baseUrl(connectionRequest.appUrl())
HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl())
.build()
.post()
.uri(HOMEBOX_LOGIN.getEndpoint())
.uri(HOMEBOX_LOGIN.getValue())
.body(homeboxPayload)
.retrieve()
.body(ConnectionResponse.class);
.body(HomeboxLoginResponse.class);
if (hbResponse == null) {
throw new IllegalStateException("Remote API returned an empty body for " + request.appUrl());
}
Map<String, Object> 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) {
}
}
@@ -0,0 +1,34 @@
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;
}
}
@@ -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;
}
}
@@ -2,6 +2,8 @@ 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;
@@ -9,5 +11,11 @@ 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<String, String> credentials,
boolean stayLoggedIn) {
@JsonProperty(defaultValue = "false") Boolean stayLoggedIn) {
public ConnectionRequest {
if (stayLoggedIn == null) {
stayLoggedIn = false;
}
}
}
@@ -1,6 +1,17 @@
package com.vaessl.app.dto;
import java.time.Instant;
import java.util.Map;
public record ConnectionResponse(String token, String attachmentToken, Instant expiresAt) {
public record ConnectionResponse(String token, Instant expiresAt, Map<String, Object> extraResponseData) {
public String getExtraVar(String key) {
if(extraResponseData == null) {
return null;
} else {
Object value = extraResponseData.get(key);
return value != null ? String.valueOf(value) : null;
}
}
}
@@ -8,7 +8,7 @@ import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
import static com.vaessl.app.exception.ErrorMessages.*;
import static com.vaessl.app.exception.ErrorMessage.*;
@RestControllerAdvice
public class GlobalExceptionHandler {
@@ -40,4 +40,9 @@ public class GlobalExceptionHandler {
.forStatusAndDetail(e.getStatusCode(),
SERVER_ERROR_GENERAL.getMessage() + e.getStatusText());
}
@ExceptionHandler(ProviderNotFoundException.class)
public ProblemDetail handleWrongServiceType(ProviderNotFoundException e) {
return ProblemDetail.forStatusAndDetail(WRONG_SERVICE_TYPE.getStatus(), WRONG_SERVICE_TYPE.getMessage());
}
}
@@ -0,0 +1,5 @@
package com.vaessl.app.exception;
public class ProviderNotFoundException extends RuntimeException {
}
@@ -20,39 +20,50 @@ import static org.assertj.core.api.Assertions.assertThat;
import java.util.Map;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static com.vaessl.app.connection.Endpoints.*;
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
public class HomeboxIntegrationTest {
class HomeboxIntegrationTest {
@Autowired
TestRestTemplate restTemplate;
@Autowired
ConnectionRepository cRepository;
String okJsonHomeboxResponse = """
{
"token": "fake-jwt-token",
"attachmentToken": "fake-attach",
"expiresAt": "2026-04-26T02:23:13Z"
}
""";
/**
* Returns Token and status code OK when login is successful.
*
* @param wm
* the WiremockRuntimeInfo object
*/
@Test
void shouldReturnTokenAndStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) {
stubFor(post(HOMEBOX_LOGIN.getEndpoint())
.willReturn(okJson("""
{
"token": "fake-jwt-token",
"attachmentToken": "fake-attach",
"expiresAt": "2026-04-26T02:23:13Z"
}
""")));
stubFor(post(HOMEBOX_LOGIN.getValue())
.willReturn(okJson(okJsonHomeboxResponse)));
ResponseEntity<String> response = restTemplate.postForEntity("/login", connectionRequest(wm), String.class);
ResponseEntity<String> 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("$.attachmentToken");
String attachmentToken = documentContext.read("$.extraResponseData.attachmentToken");
assertThat(attachmentToken).isEqualTo("fake-attach");
String expiresAt = documentContext.read("$.expiresAt", String.class);
@@ -61,31 +72,38 @@ public class HomeboxIntegrationTest {
}
/**
* Test if login request fails with 401 unauthorized.
* Tests the Unauthorized custom exception.
*
* @param wm
* the WiremockRuntimeInfo object
*/
@Test
void shouldFailToConnectWhenHomeboxReturnsUnauthorized(WireMockRuntimeInfo wm) {
stubFor(post(HOMEBOX_LOGIN.getEndpoint()).willReturn(unauthorized()));
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized()));
ResponseEntity<String> response = restTemplate.postForEntity("/login", connectionRequest(wm), String.class);
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(response.getBody()).contains("Invalid username or password.");
assertThat(response.getBody()).contains(UNAUTHORIZED_WRONG_LOGIN.getMessage());
}
/**
* Tests a server error from the external api.
*
* @param wm
*/
@Test
void shouldFailToConnectWhenHomeboxReturnsServiceUnavailable(WireMockRuntimeInfo wm) {
stubFor(post(HOMEBOX_LOGIN.getEndpoint()).willReturn(serviceUnavailable()));
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable()));
ResponseEntity<String> response = restTemplate.postForEntity("/login", connectionRequest(wm), String.class);
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(response.getBody()).contains("The external app returned a server error");
assertThat(response.getBody()).contains(SERVER_ERROR_GENERAL.getMessage());
}
/**
@@ -96,16 +114,14 @@ public class HomeboxIntegrationTest {
ConnectionRequest badRequest = new ConnectionRequest(
"http://localhost:1234",
"HOMEBOX",
HOMEBOX.getValue(),
Map.of("username", "myUser", "password", "myPass"),
false);
ResponseEntity<String> response = restTemplate.postForEntity("/login", badRequest, String.class);
System.out.println("RESPONSE: " + response.getBody());
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(response.getBody()).contains("The target URL is unreachable.");
assertThat(response.getBody()).contains(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage());
}
/**
@@ -116,10 +132,60 @@ public class HomeboxIntegrationTest {
ConnectionRequest emtpyRequest = new ConnectionRequest("", "", Map.of(), false);
ResponseEntity<String> response = restTemplate.postForEntity("/login", emtpyRequest, String.class);
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), emtpyRequest, String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).contains("Fields must not be empty.");
assertThat(response.getBody()).contains(BAD_REQUEST_EMPTY_FIELDS.getMessage());
}
/**
* Test the custom ProviderNotFound exception.
*/
@Test
void shouldReturnProviderNotFound() {
ConnectionRequest wrongServiceTypeReq = new ConnectionRequest(
"http://localhost:1234",
"wrong-service-type",
Map.of("username", "myUser", "password", "myPass"),
false);
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq,
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
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<String> response = restTemplate.postForEntity(LOGIN.getValue(), request, String.class);
DocumentContext responseContext = JsonPath.parse(response.getBody());
ConnectionEntity dbEntry = cRepository.findByAppUrl(request.appUrl());
assertThat(dbEntry).isNotNull();
assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl());
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"));
}
}
/**
@@ -127,10 +193,12 @@ public class HomeboxIntegrationTest {
* WireMockRuntimeInfo.
*
* @param wm
* the WiremockRuntimeInfo object
* @return a mock api connection request.
*/
private ConnectionRequest connectionRequest(WireMockRuntimeInfo wm) {
return new ConnectionRequest(wm.getHttpBaseUrl(), "HOMEBOX", Map.of("username", "admin", "password", "pw"),
false);
return new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(),
Map.of("username", "admin", "password", "pw"),
null);
}
}