Compare commits
4 Commits
1ba85e129e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f3fe9901c5 | |||
| c461aa81cc | |||
| 856fa9e166 | |||
| 7ce01dff0b |
@@ -8,13 +8,18 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
@Configuration
|
||||
public class CorsConfig implements WebMvcConfigurer {
|
||||
|
||||
@Value("${vaessl.allowed-origins}")
|
||||
private String[] allowedOrigins;
|
||||
@Value("${vaessl.frontend-local-url}")
|
||||
private String frontendLocalUrl;
|
||||
|
||||
@Value("${vaessl.frontend-public-url}")
|
||||
private String frontendPublicUrl;
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/**").allowedOrigins(allowedOrigins)
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(frontendLocalUrl, frontendPublicUrl)
|
||||
.allowedMethods("GET", "POST", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("Content-Type", "Accept").allowCredentials(true);
|
||||
.allowedHeaders("Content-Type", "Accept")
|
||||
.allowCredentials(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ 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.AuthResponse;
|
||||
import com.vaessl.app.dto.ConnectionRequest;
|
||||
import com.vaessl.app.dto.ConnectionStatusResponse;
|
||||
import com.vaessl.app.dto.LoginResult;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -28,7 +33,8 @@ public class ConnectionController {
|
||||
private final ConnectionService connectionService;
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<AuthResponse> login(@Valid @RequestBody ConnectionRequest request,
|
||||
public ResponseEntity<AuthResponse> login(
|
||||
@Valid @RequestBody ConnectionRequest request,
|
||||
HttpServletRequest httpReq) {
|
||||
|
||||
LoginResult result = connectionService.login(request);
|
||||
@@ -52,21 +58,21 @@ public class ConnectionController {
|
||||
}
|
||||
|
||||
List<ConnectionStatusResponse> statuses = new ArrayList<>();
|
||||
Collections.list(session.getAttributeNames()).stream().filter(k -> k.endsWith(SUFFIX))
|
||||
Collections.list(session.getAttributeNames()).stream()
|
||||
.filter(k -> k.endsWith(SUFFIX))
|
||||
.forEach(k -> {
|
||||
String serviceType = k.replace(SUFFIX, "");
|
||||
Long id = (Long) session.getAttribute(k);
|
||||
ConnectionStatusResponse status =
|
||||
connectionService.getConnectionStatus(serviceType, id);
|
||||
if (status != null)
|
||||
statuses.add(status);
|
||||
ConnectionStatusResponse status = connectionService.getConnectionStatus(serviceType, id);
|
||||
if (status != null) statuses.add(status);
|
||||
});
|
||||
|
||||
return ResponseEntity.ok(statuses);
|
||||
}
|
||||
|
||||
@DeleteMapping("/connections/{serviceType}")
|
||||
public ResponseEntity<Void> logout(@PathVariable("serviceType") String serviceType,
|
||||
public ResponseEntity<Void> logout(
|
||||
@PathVariable("serviceType") String serviceType,
|
||||
HttpServletRequest httpReq) {
|
||||
|
||||
HttpSession session = httpReq.getSession(false);
|
||||
|
||||
@@ -14,8 +14,7 @@ import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Entity
|
||||
@Table(name = "connections",
|
||||
uniqueConstraints = {@UniqueConstraint(columnNames = {"appUrl", "username"})})
|
||||
@Table(name = "connections", uniqueConstraints = { @UniqueConstraint(columnNames = { "appUrl", "username" }) })
|
||||
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
|
||||
@DiscriminatorColumn(name = "service_type")
|
||||
@Getter
|
||||
|
||||
@@ -2,10 +2,15 @@ package com.vaessl.app.connection;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public interface ConnectionProvider extends ServiceProvider {
|
||||
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);
|
||||
|
||||
@@ -7,6 +7,10 @@ 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.dto.ConnectionStatusResponse;
|
||||
import com.vaessl.app.dto.LoginResult;
|
||||
import com.vaessl.app.exception.WrongServiceTypeException;
|
||||
|
||||
@Service
|
||||
@@ -50,14 +54,13 @@ public class ConnectionService {
|
||||
|
||||
public ConnectionStatusResponse getConnectionStatus(String serviceType, Long connectionId) {
|
||||
ConnectionEntity entity = cRepository.findById(connectionId).orElse(null);
|
||||
if (entity == null)
|
||||
return null;
|
||||
if (entity == null) return null;
|
||||
|
||||
ConnectionProvider provider = providerRegistry.get(serviceType);
|
||||
Instant expiresAt = (provider != null) ? provider.getTokenExpiry(entity) : null;
|
||||
boolean connected = expiresAt == null || expiresAt.isAfter(Instant.now());
|
||||
|
||||
return new ConnectionStatusResponse(serviceType, entity.getAppUrl(), entity.getUsername(),
|
||||
expiresAt, connected);
|
||||
return new ConnectionStatusResponse(serviceType, entity.getAppUrl(),
|
||||
entity.getUsername(), expiresAt, connected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record ConnectionStatusResponse(String serviceType, String appUrl, String username,
|
||||
Instant expiresAt, boolean connected) {
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
public enum Endpoint {
|
||||
HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"), CONNECTION_STATUS(
|
||||
"/connections/status"), HOMEBOX_QUERY_ALL_ITEMS("/api/v1/items"), SEARCH("/search");
|
||||
HOMEBOX_LOGIN("/api/v1/users/login"),
|
||||
LOGIN("/login"),
|
||||
CONNECTION_STATUS("/connections/status");
|
||||
|
||||
private final String value;
|
||||
|
||||
|
||||
+16
-17
@@ -9,20 +9,20 @@ 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 com.vaessl.app.exception.RemoteApiException;
|
||||
|
||||
import static com.vaessl.app.connection.Endpoint.*;
|
||||
|
||||
@Component
|
||||
public class HomeboxConnectionProvider implements ConnectionProvider {
|
||||
public class HomeBoxConnectionProvider implements ConnectionProvider {
|
||||
|
||||
private final RestClient.Builder restClientBuilder;
|
||||
|
||||
private final ConnectionRepository cRepository;
|
||||
|
||||
public HomeboxConnectionProvider(RestClient.Builder restClientBuilder,
|
||||
ConnectionRepository cRepository) {
|
||||
public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) {
|
||||
this.restClientBuilder = restClientBuilder;
|
||||
this.cRepository = cRepository;
|
||||
}
|
||||
@@ -50,26 +50,27 @@ public class HomeboxConnectionProvider implements ConnectionProvider {
|
||||
|
||||
@Override
|
||||
public ConnectionResponse authenticate(ConnectionRequest request) {
|
||||
Map<String, Object> homeboxPayload = Map.of("username", request.username(), "password",
|
||||
request.password(), "stayLoggedIn", request.stayLoggedIn());
|
||||
Map<String, Object> 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()
|
||||
HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl())
|
||||
.build()
|
||||
.post()
|
||||
.uri(HOMEBOX_LOGIN.getValue())
|
||||
.body(homeboxPayload)
|
||||
.retrieve()
|
||||
.body(HomeboxLoginResponse.class);
|
||||
|
||||
if (hbResponse == null) {
|
||||
throw new RemoteApiException(request.appUrl());
|
||||
throw new IllegalStateException("Remote API returned an empty body for " + request.appUrl());
|
||||
}
|
||||
|
||||
Map<String, Object> attachmentToken = new HashMap<>();
|
||||
|
||||
attachmentToken.put("attachmentToken", hbResponse.attachmentToken());
|
||||
|
||||
String hbRawToken = hbResponse.token();
|
||||
|
||||
String token = hbRawToken.startsWith("Bearer ") ? hbRawToken.substring(7) : hbRawToken;
|
||||
|
||||
return new ConnectionResponse(token, hbResponse.expiresAt(), attachmentToken);
|
||||
return new ConnectionResponse(hbResponse.token(), hbResponse.expiresAt(), attachmentToken);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -79,8 +80,7 @@ public class HomeboxConnectionProvider implements ConnectionProvider {
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConnectionEntity connectionToEntity(ConnectionRequest request,
|
||||
ConnectionResponse response) {
|
||||
public ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response) {
|
||||
return HomeboxEntity.from(request, response);
|
||||
}
|
||||
|
||||
@@ -105,5 +105,4 @@ public class HomeboxConnectionProvider implements ConnectionProvider {
|
||||
private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) {
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -2,6 +2,9 @@ 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;
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
public interface ServiceProvider {
|
||||
|
||||
/**
|
||||
* Returns the service type key used to look up this provider in a registry, e.g.
|
||||
* {@code "HOMEBOX"}.
|
||||
*/
|
||||
String getServiceType();
|
||||
}
|
||||
@@ -8,7 +8,7 @@ public enum ServiceType {
|
||||
|
||||
private final String value;
|
||||
|
||||
private ServiceType(String value) {
|
||||
private ServiceType(String value){
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -1,6 +1,5 @@
|
||||
package com.vaessl.app.connection;
|
||||
package com.vaessl.app.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record AuthResponse(String serviceType, Instant expiresAt) {
|
||||
}
|
||||
public record AuthResponse(String serviceType, Instant expiresAt) {}
|
||||
+8
-5
@@ -1,12 +1,15 @@
|
||||
package com.vaessl.app.connection;
|
||||
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,
|
||||
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,
|
||||
String username,
|
||||
String password,
|
||||
String apiKey,
|
||||
@JsonProperty(defaultValue = "false") Boolean stayLoggedIn) {
|
||||
|
||||
public ConnectionRequest {
|
||||
@@ -15,8 +18,8 @@ public record ConnectionRequest(@NotBlank(message = "App URL is mandatory") Stri
|
||||
}
|
||||
}
|
||||
|
||||
public ConnectionRequest(String appUrl, String serviceType, String username,
|
||||
String password, Boolean stayLoggedIn) {
|
||||
public ConnectionRequest(String appUrl, String serviceType, String username, String password,
|
||||
Boolean stayLoggedIn) {
|
||||
this(appUrl, serviceType, username, password, null, stayLoggedIn);
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -1,13 +1,12 @@
|
||||
package com.vaessl.app.connection;
|
||||
package com.vaessl.app.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
public record ConnectionResponse(String token, Instant expiresAt,
|
||||
Map<String, Object> extraResponseData) {
|
||||
|
||||
public record ConnectionResponse(String token, Instant expiresAt, Map<String, Object> extraResponseData) {
|
||||
|
||||
public String getExtraVar(String key) {
|
||||
if (extraResponseData == null) {
|
||||
if(extraResponseData == null) {
|
||||
return null;
|
||||
} else {
|
||||
Object value = extraResponseData.get(key);
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.vaessl.app.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record ConnectionStatusResponse(
|
||||
String serviceType,
|
||||
String appUrl,
|
||||
String username,
|
||||
Instant expiresAt,
|
||||
boolean connected) {}
|
||||
+2
-3
@@ -1,6 +1,5 @@
|
||||
package com.vaessl.app.connection;
|
||||
package com.vaessl.app.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record LoginResult(Long connectionId, Instant expiresAt) {
|
||||
}
|
||||
public record LoginResult(Long connectionId, Instant expiresAt) {}
|
||||
@@ -1,4 +0,0 @@
|
||||
package com.vaessl.app.exception;
|
||||
|
||||
public class ConnectionNotFoundException extends RuntimeException {
|
||||
}
|
||||
@@ -5,18 +5,11 @@ 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."), CONNECTION_NOT_FOUND(
|
||||
HttpStatus.NOT_FOUND,
|
||||
"No active connection found for this service."), REMOTE_API_EMPTY_RESPONSE(
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
"Remote API returned empty response for ");
|
||||
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;
|
||||
|
||||
@@ -19,8 +19,8 @@ public class GlobalExceptionHandler {
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ProblemDetail handleEmptyCredentialInput(MethodArgumentNotValidException e) {
|
||||
|
||||
String defaultMessages = e.getBindingResult().getFieldErrors().stream()
|
||||
.map(FieldError::getDefaultMessage).collect(Collectors.joining(", "));
|
||||
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 + "]");
|
||||
@@ -43,14 +43,14 @@ public class GlobalExceptionHandler {
|
||||
@ExceptionHandler(HttpServerErrorException.class)
|
||||
public ProblemDetail handleTimeoutOrNotFound(HttpServerErrorException e) {
|
||||
|
||||
return ProblemDetail.forStatusAndDetail(e.getStatusCode(),
|
||||
SERVER_ERROR_GENERAL.getMessage() + e.getStatusText());
|
||||
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());
|
||||
return ProblemDetail.forStatusAndDetail(WRONG_SERVICE_TYPE.getStatus(), WRONG_SERVICE_TYPE.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(EmptyCredentialsException.class)
|
||||
@@ -58,15 +58,4 @@ public class GlobalExceptionHandler {
|
||||
return ProblemDetail.forStatusAndDetail(BAD_REQUEST_EMPTY_FIELDS.getStatus(),
|
||||
BAD_REQUEST_EMPTY_FIELDS.getMessage() + " " + e.getMissingFields());
|
||||
}
|
||||
|
||||
@ExceptionHandler(ConnectionNotFoundException.class)
|
||||
public ProblemDetail handleConnectionNotFound(ConnectionNotFoundException e) {
|
||||
return ProblemDetail.forStatusAndDetail(CONNECTION_NOT_FOUND.getStatus(), CONNECTION_NOT_FOUND.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(RemoteApiException.class)
|
||||
public ProblemDetail handleRemoteApiException(RemoteApiException e) {
|
||||
return ProblemDetail.forStatusAndDetail(REMOTE_API_EMPTY_RESPONSE.getStatus(),
|
||||
REMOTE_API_EMPTY_RESPONSE.getMessage() + e.getAppUrl());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package com.vaessl.app.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public class RemoteApiException extends RuntimeException {
|
||||
|
||||
private final String appUrl;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package com.vaessl.app.search;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import com.vaessl.app.connection.ConnectionEntity;
|
||||
import com.vaessl.app.connection.ConnectionRepository;
|
||||
import com.vaessl.app.connection.HomeboxEntity;
|
||||
import com.vaessl.app.exception.ConnectionNotFoundException;
|
||||
import com.vaessl.app.exception.RemoteApiException;
|
||||
|
||||
import static com.vaessl.app.connection.Endpoint.*;
|
||||
|
||||
@Component
|
||||
public class HomeboxSearchProvider implements SearchProvider {
|
||||
|
||||
private final RestClient.Builder restClientBuilder;
|
||||
|
||||
private final ConnectionRepository cRepository;
|
||||
|
||||
public HomeboxSearchProvider(RestClient.Builder restClientBuilder,
|
||||
ConnectionRepository cRepository) {
|
||||
this.restClientBuilder = restClientBuilder;
|
||||
this.cRepository = cRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getServiceType() {
|
||||
return "HOMEBOX";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<SearchResponse> getSearchResults(SearchRequest request, Pageable pageable) {
|
||||
|
||||
ConnectionEntity entity =
|
||||
cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
|
||||
|
||||
if (!(entity instanceof HomeboxEntity hbEntity)) {
|
||||
throw new ConnectionNotFoundException();
|
||||
}
|
||||
|
||||
HomeboxSearchResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()).build().get()
|
||||
.uri(u -> u.path(HOMEBOX_QUERY_ALL_ITEMS.getValue())
|
||||
.queryParam("q", request.query())
|
||||
.queryParam("page", pageable.getPageNumber() + 1)
|
||||
.queryParam("pageSize", pageable.getPageSize()).build())
|
||||
.headers(h -> h.setBearerAuth(
|
||||
hbEntity.getToken())).retrieve().body(HomeboxSearchResponse.class);
|
||||
|
||||
if (hbResponse == null) {
|
||||
throw new RemoteApiException(request.appUrl());
|
||||
}
|
||||
|
||||
List<SearchResponse> items = hbResponse.items().stream().map(i -> {
|
||||
String title = i.name();
|
||||
String description = i.description();
|
||||
Map<String, Object> extraSearchResponseData = Map.of("location", i.location());
|
||||
return new SearchResponse(title, description, extraSearchResponseData);
|
||||
}).toList();
|
||||
|
||||
return new PageImpl<>(items, pageable, hbResponse.total());
|
||||
}
|
||||
|
||||
private record HomeboxSearchResponse(int page, int pageSize, int total,
|
||||
List<HomeboxItem> items) {
|
||||
}
|
||||
|
||||
private record HomeboxItem(String name, String description, HomeboxLocation location) {
|
||||
}
|
||||
|
||||
private record HomeboxLocation(String name, String description) {
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package com.vaessl.app.search;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.domain.Page;
|
||||
|
||||
public record PagedSearchResponse<T>(List<T> content, int page, int pageSize, long totalElements,
|
||||
boolean first, boolean last, String sort) {
|
||||
|
||||
public static <T> PagedSearchResponse<T> from(Page<T> pageResult) {
|
||||
return new PagedSearchResponse<>(pageResult.getContent(), pageResult.getNumber(),
|
||||
pageResult.getSize(), pageResult.getTotalElements(), pageResult.isFirst(),
|
||||
pageResult.isLast(), pageResult.getSort().toString());
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package com.vaessl.app.search;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.web.PageableDefault;
|
||||
import org.springframework.http.HttpStatus;
|
||||
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 jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class SearchController {
|
||||
|
||||
private final SearchService searchService;
|
||||
|
||||
/**
|
||||
* Executes a paged search against the requested service. Returns {@code 401 Unauthorized} if
|
||||
* there is no active session.
|
||||
*/
|
||||
@PostMapping("/search")
|
||||
public ResponseEntity<PagedSearchResponse<SearchResponse>> search(
|
||||
@Valid @RequestBody SearchRequest request,
|
||||
@PageableDefault(size = 20) Pageable pageable, HttpServletRequest httpReq) {
|
||||
HttpSession session = httpReq.getSession(false);
|
||||
if (session == null
|
||||
|| session.getAttribute(request.serviceType() + "_CONNECTION_ID") == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
}
|
||||
|
||||
Page<SearchResponse> result = searchService.search(request, pageable);
|
||||
|
||||
return ResponseEntity.ok(PagedSearchResponse.from(result));
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.vaessl.app.search;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import com.vaessl.app.connection.ServiceProvider;
|
||||
|
||||
/**
|
||||
* Implemented by any service that supports querying its remote API for items.
|
||||
*/
|
||||
public interface SearchProvider extends ServiceProvider {
|
||||
|
||||
/**
|
||||
* Executes a search query against the remote service and returns matching results.
|
||||
*
|
||||
* @param request the search request containing the query string, app URL, and user credentials
|
||||
* @return a list of {@link SearchResponse} items matching the query
|
||||
*/
|
||||
Page<SearchResponse> getSearchResults(SearchRequest request, Pageable pageable);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.vaessl.app.search;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record SearchRequest(@NotBlank String appUrl, @NotBlank String username, String query,
|
||||
@NotBlank String serviceType) {
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.vaessl.app.search;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public record SearchResponse(String title, String description, Map<String, Object> extraData) {
|
||||
|
||||
public String getExtra(String key) {
|
||||
if (extraData == null) {
|
||||
return null;
|
||||
} else {
|
||||
Object value = extraData.get(key);
|
||||
|
||||
return value != null ? String.valueOf(value) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package com.vaessl.app.search;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.vaessl.app.exception.WrongServiceTypeException;
|
||||
|
||||
@Service
|
||||
public class SearchService {
|
||||
|
||||
private final Map<String, SearchProvider> providerRegistry;
|
||||
|
||||
public SearchService(List<SearchProvider> providers) {
|
||||
this.providerRegistry = Map.copyOf(providers.stream()
|
||||
.collect(Collectors.toMap(SearchProvider::getServiceType, p -> p)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches the paged search request to the provider registered for
|
||||
* {@link SearchRequest#serviceType()}.
|
||||
*
|
||||
* @param request the search request
|
||||
* @param pageable the Pageable interface
|
||||
* @return results returned by the matching provider
|
||||
* @throws WrongServiceTypeException if no provider is registered for the given service type
|
||||
*/
|
||||
public Page<SearchResponse> search(SearchRequest request, Pageable pageable) {
|
||||
|
||||
SearchProvider provider = providerRegistry.get(request.serviceType());
|
||||
|
||||
if (provider == null) {
|
||||
throw new WrongServiceTypeException();
|
||||
}
|
||||
|
||||
return provider.getSearchResults(request, pageable);
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,13 @@
|
||||
"description": "A description for 'spring.session.store-type'"
|
||||
},
|
||||
{
|
||||
"name": "vaessl.allowed-origins",
|
||||
"name": "vaessl.frontend-local-url",
|
||||
"type": "java.lang.String",
|
||||
"description": "Comma-separated list of allowed CORS origins"
|
||||
"description": "A description for 'vaessl.frontend-local-url'"
|
||||
},
|
||||
{
|
||||
"name": "vaessl.frontend-public-url",
|
||||
"type": "java.lang.String",
|
||||
"description": "A description for 'vaessl.frontend-public-url'"
|
||||
}
|
||||
]}
|
||||
@@ -13,7 +13,7 @@ spring:
|
||||
driver-class-name: ${PG_DRIVER_CLASS_NAME}
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
ddl-auto: create-drop
|
||||
show-sql: true
|
||||
ai:
|
||||
openai:
|
||||
@@ -30,4 +30,5 @@ server:
|
||||
servlet:
|
||||
context-path: /api
|
||||
vaessl:
|
||||
allowed-origins: ${ALLOWED_ORIGINS}
|
||||
frontend-local-url: ${FRONTEND_LOCAL_URL}
|
||||
frontend-public-url: ${FRONTEND_PUBLIC_URL}
|
||||
|
||||
@@ -20,7 +20,8 @@ class ApplicationTests {
|
||||
private DataSource dataSource;
|
||||
|
||||
@Test
|
||||
void contextLoads() {}
|
||||
void contextLoads() {
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectionToTestDbWorks() throws SQLException {
|
||||
|
||||
@@ -24,131 +24,134 @@ import org.springframework.test.web.servlet.MvcResult;
|
||||
@WireMockTest
|
||||
class ConnectionControllerTest {
|
||||
|
||||
@Autowired
|
||||
MockMvc mockMvc;
|
||||
@Autowired
|
||||
MockMvc mockMvc;
|
||||
|
||||
private static final String TEST_USER = "admin";
|
||||
private static final String TEST_PASS = "pw";
|
||||
private static final String LOGIN_PATH = LOGIN.getValue();
|
||||
private static final String STATUS_PATH = CONNECTION_STATUS.getValue();
|
||||
private static final String LOGOUT_PATH = "/connections/HOMEBOX";
|
||||
private static final String TEST_USER = "admin";
|
||||
private static final String TEST_PASS = "pw";
|
||||
private static final String LOGIN_PATH = LOGIN.getValue();
|
||||
private static final String STATUS_PATH = CONNECTION_STATUS.getValue();
|
||||
private static final String LOGOUT_PATH = "/connections/HOMEBOX";
|
||||
|
||||
private static final String VALID_HOMEBOX_RESPONSE = """
|
||||
{
|
||||
"token": "fake-jwt-token",
|
||||
"attachmentToken": "fake-attach",
|
||||
"expiresAt": "2099-01-01T00:00:00Z"
|
||||
}
|
||||
""";
|
||||
private static final String VALID_HOMEBOX_RESPONSE = """
|
||||
{
|
||||
"token": "fake-jwt-token",
|
||||
"attachmentToken": "fake-attach",
|
||||
"expiresAt": "2099-01-01T00:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
private static final String EXPIRED_HOMEBOX_RESPONSE = """
|
||||
{
|
||||
"token": "expired-token",
|
||||
"attachmentToken": "fake-attach",
|
||||
"expiresAt": "2000-01-01T00:00:00Z"
|
||||
}
|
||||
""";
|
||||
private static final String EXPIRED_HOMEBOX_RESPONSE = """
|
||||
{
|
||||
"token": "expired-token",
|
||||
"attachmentToken": "fake-attach",
|
||||
"expiresAt": "2000-01-01T00:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyListWhenNoActiveSession() throws Exception {
|
||||
mockMvc.perform(get(STATUS_PATH)).andExpect(status().isOk())
|
||||
.andExpect(content().json("[]"));
|
||||
}
|
||||
@Test
|
||||
void shouldReturnEmptyListWhenNoActiveSession() throws Exception {
|
||||
mockMvc.perform(get(STATUS_PATH))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().json("[]"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnConnectionStatusWithConnectedTrueAfterLogin(WireMockRuntimeInfo wm)
|
||||
throws Exception {
|
||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||
.willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
|
||||
@Test
|
||||
void shouldReturnConnectionStatusWithConnectedTrueAfterLogin(WireMockRuntimeInfo wm) throws Exception {
|
||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
|
||||
|
||||
MvcResult loginResult = mockMvc
|
||||
.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||
.content(connectionRequestBody(wm)))
|
||||
.andExpect(status().isOk()).andReturn();
|
||||
MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(connectionRequestBody(wm)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
|
||||
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
||||
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
||||
|
||||
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.length()").value(1))
|
||||
.andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.getValue()))
|
||||
.andExpect(jsonPath("$[0].username").value(TEST_USER))
|
||||
.andExpect(jsonPath("$[0].appUrl").value(wm.getHttpBaseUrl()))
|
||||
.andExpect(jsonPath("$[0].connected").value(true));
|
||||
}
|
||||
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.length()").value(1))
|
||||
.andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.getValue()))
|
||||
.andExpect(jsonPath("$[0].username").value(TEST_USER))
|
||||
.andExpect(jsonPath("$[0].appUrl").value(wm.getHttpBaseUrl()))
|
||||
.andExpect(jsonPath("$[0].connected").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnConnectedFalseWhenStoredTokenIsExpired(WireMockRuntimeInfo wm)
|
||||
throws Exception {
|
||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||
.willReturn(WireMock.okJson(EXPIRED_HOMEBOX_RESPONSE)));
|
||||
@Test
|
||||
void shouldReturnConnectedFalseWhenStoredTokenIsExpired(WireMockRuntimeInfo wm) throws Exception {
|
||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(EXPIRED_HOMEBOX_RESPONSE)));
|
||||
|
||||
MvcResult loginResult = mockMvc
|
||||
.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||
.content(connectionRequestBody(wm)))
|
||||
.andExpect(status().isOk()).andReturn();
|
||||
MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(connectionRequestBody(wm)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
|
||||
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
||||
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
||||
|
||||
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.getValue()))
|
||||
.andExpect(jsonPath("$[0].connected").value(false))
|
||||
.andExpect(jsonPath("$[0].expiresAt")
|
||||
.value("2000-01-01T00:00:00Z"));
|
||||
}
|
||||
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].serviceType").value(HOMEBOX.getValue()))
|
||||
.andExpect(jsonPath("$[0].connected").value(false))
|
||||
.andExpect(jsonPath("$[0].expiresAt").value("2000-01-01T00:00:00Z"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn204NoContentOnLogout(WireMockRuntimeInfo wm) throws Exception {
|
||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||
.willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
|
||||
@Test
|
||||
void shouldReturn204NoContentOnLogout(WireMockRuntimeInfo wm) throws Exception {
|
||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
|
||||
|
||||
MvcResult loginResult = mockMvc
|
||||
.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||
.content(connectionRequestBody(wm)))
|
||||
.andExpect(status().isOk()).andReturn();
|
||||
MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(connectionRequestBody(wm)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
|
||||
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
||||
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
||||
|
||||
mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyStatusListAfterLogout(WireMockRuntimeInfo wm) throws Exception {
|
||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||
.willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
|
||||
@Test
|
||||
void shouldReturnEmptyStatusListAfterLogout(WireMockRuntimeInfo wm) throws Exception {
|
||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue()).willReturn(WireMock.okJson(VALID_HOMEBOX_RESPONSE)));
|
||||
|
||||
MvcResult loginResult = mockMvc
|
||||
.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||
.content(connectionRequestBody(wm)))
|
||||
.andExpect(status().isOk()).andReturn();
|
||||
MvcResult loginResult = mockMvc.perform(post(LOGIN_PATH)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(connectionRequestBody(wm)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
|
||||
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
||||
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
||||
|
||||
// Verify connected before logout
|
||||
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie)).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.length()").value(1));
|
||||
// Verify connected before logout
|
||||
mockMvc.perform(get(STATUS_PATH).cookie(sessionCookie))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.length()").value(1));
|
||||
|
||||
mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie))
|
||||
.andExpect(status().isNoContent());
|
||||
mockMvc.perform(delete(LOGOUT_PATH).cookie(sessionCookie))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
// A new request (no session cookie, as in a fresh browser) returns no connections
|
||||
mockMvc.perform(get(STATUS_PATH)).andExpect(status().isOk())
|
||||
.andExpect(content().json("[]"));
|
||||
}
|
||||
// A new request (no session cookie, as in a fresh browser) returns no connections
|
||||
mockMvc.perform(get(STATUS_PATH))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().json("[]"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturn204WhenLogoutCalledWithNoActiveSession() throws Exception {
|
||||
mockMvc.perform(delete(LOGOUT_PATH)).andExpect(status().isNoContent());
|
||||
}
|
||||
@Test
|
||||
void shouldReturn204WhenLogoutCalledWithNoActiveSession() throws Exception {
|
||||
mockMvc.perform(delete(LOGOUT_PATH))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
private String connectionRequestBody(WireMockRuntimeInfo wm) {
|
||||
return """
|
||||
{
|
||||
"appUrl": "%s",
|
||||
"serviceType": "HOMEBOX",
|
||||
"username": "%s",
|
||||
"password": "%s"
|
||||
}
|
||||
""".formatted(wm.getHttpBaseUrl(), TEST_USER, TEST_PASS);
|
||||
}
|
||||
private String connectionRequestBody(WireMockRuntimeInfo wm) {
|
||||
return """
|
||||
{
|
||||
"appUrl": "%s",
|
||||
"serviceType": "HOMEBOX",
|
||||
"username": "%s",
|
||||
"password": "%s"
|
||||
}
|
||||
""".formatted(wm.getHttpBaseUrl(), TEST_USER, TEST_PASS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ 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.*;
|
||||
@@ -37,14 +38,13 @@ class ConnectionServiceTest {
|
||||
|
||||
@Test
|
||||
void login_ShouldAbort_WhenCheckCredentialsThrowsException() {
|
||||
ConnectionRequest request =
|
||||
new ConnectionRequest(MOCK_URL, MOCK_SERVICE_TYPE, null, null, false);
|
||||
ConnectionRequest request = new ConnectionRequest(MOCK_URL, MOCK_SERVICE_TYPE, null, null, false);
|
||||
|
||||
doThrow(new EmptyCredentialsException(List.of("username"))).when(mockProvider)
|
||||
.checkCredentials(request);
|
||||
doThrow(new EmptyCredentialsException(List.of("username")))
|
||||
.when(mockProvider).checkCredentials(request);
|
||||
|
||||
assertThrows(EmptyCredentialsException.class, () -> connectionService.login(request));
|
||||
|
||||
verify(mockProvider, never()).authenticate(any());
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -4,14 +4,15 @@ 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 {
|
||||
class HomeBoxConnectionProviderTest {
|
||||
|
||||
private final HomeboxConnectionProvider provider = new HomeboxConnectionProvider(null, null);
|
||||
private final HomeBoxConnectionProvider provider = new HomeBoxConnectionProvider(null, null);
|
||||
|
||||
@Test
|
||||
void checkCredentials_ShouldThrowException_WhenFieldsAreMissing() {
|
||||
@@ -14,6 +14,7 @@ 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;
|
||||
|
||||
@@ -52,10 +53,11 @@ class HomeboxIntegrationTest {
|
||||
@Test
|
||||
void shouldReturnStatusOkWhenHomeboxCredentialsAreValid(WireMockRuntimeInfo wm) {
|
||||
|
||||
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(okJson(okJsonHomeboxResponse)));
|
||||
stubFor(post(HOMEBOX_LOGIN.getValue())
|
||||
.willReturn(okJson(okJsonHomeboxResponse)));
|
||||
|
||||
ResponseEntity<String> response =
|
||||
restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm), String.class);
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), connectionRequest(wm),
|
||||
String.class);
|
||||
|
||||
DocumentContext documentContext = JsonPath.parse(response.getBody());
|
||||
|
||||
@@ -77,8 +79,8 @@ class HomeboxIntegrationTest {
|
||||
|
||||
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(unauthorized()));
|
||||
|
||||
ResponseEntity<String> response =
|
||||
restTemplate.postForEntity(LOGIN.getValue(), 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(UNAUTHORIZED_WRONG_LOGIN.getMessage());
|
||||
@@ -94,8 +96,8 @@ class HomeboxIntegrationTest {
|
||||
|
||||
stubFor(post(HOMEBOX_LOGIN.getValue()).willReturn(serviceUnavailable()));
|
||||
|
||||
ResponseEntity<String> response =
|
||||
restTemplate.postForEntity(LOGIN.getValue(), 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(SERVER_ERROR_GENERAL.getMessage());
|
||||
@@ -111,8 +113,7 @@ class HomeboxIntegrationTest {
|
||||
.willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER)));
|
||||
|
||||
ConnectionRequest badRequest = connectionRequest(wm);
|
||||
ResponseEntity<String> response =
|
||||
restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class);
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), badRequest, String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
assertThat(response.getBody()).contains(SERVICE_UNAVAILABLE_UNREACHABLE_URL.getMessage());
|
||||
@@ -126,30 +127,34 @@ class HomeboxIntegrationTest {
|
||||
|
||||
ConnectionRequest emtpyRequest = new ConnectionRequest("", "", "", "", false);
|
||||
|
||||
ResponseEntity<String> response =
|
||||
restTemplate.postForEntity(LOGIN.getValue(), emtpyRequest, String.class);
|
||||
ResponseEntity<String> 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 the exception when there is an input for serviceType but it's
|
||||
* unsupported.
|
||||
*/
|
||||
@Test
|
||||
void shouldReturnWrongServiceTypeException(WireMockRuntimeInfo wm) {
|
||||
ConnectionRequest wrongServiceTypeReq = new ConnectionRequest(wm.getHttpBaseUrl(),
|
||||
"wrong-service-type", TEST_USER, TEST_PASS, false);
|
||||
ConnectionRequest wrongServiceTypeReq = new ConnectionRequest(
|
||||
wm.getHttpBaseUrl(),
|
||||
"wrong-service-type",
|
||||
TEST_USER, TEST_PASS,
|
||||
false);
|
||||
|
||||
ResponseEntity<String> response =
|
||||
restTemplate.postForEntity(LOGIN.getValue(), wrongServiceTypeReq, String.class);
|
||||
ResponseEntity<String> 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.
|
||||
* Tests the succesfull persistance of Homebox credential response to the
|
||||
* database.
|
||||
*
|
||||
* @param wm the WiremockRuntimeInfo object
|
||||
*/
|
||||
@@ -161,12 +166,10 @@ class HomeboxIntegrationTest {
|
||||
|
||||
ConnectionRequest request = connectionRequest(wm);
|
||||
|
||||
ResponseEntity<String> response =
|
||||
restTemplate.postForEntity(LOGIN.getValue(), request, String.class);
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(LOGIN.getValue(), request, String.class);
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
ConnectionEntity dbEntry =
|
||||
cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
|
||||
ConnectionEntity dbEntry = cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
|
||||
|
||||
assertThat(dbEntry).isNotNull();
|
||||
assertThat(dbEntry.getAppUrl()).isEqualTo(request.appUrl());
|
||||
@@ -181,18 +184,20 @@ class HomeboxIntegrationTest {
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyCredentialsExceptionWhenCredsAreMissing(WireMockRuntimeInfo wm) {
|
||||
ConnectionRequest missingCredentials = new ConnectionRequest(wm.getHttpBaseUrl(),
|
||||
HOMEBOX.getValue(), TEST_USER, null, false);
|
||||
ConnectionRequest missingCredentials = new ConnectionRequest(wm.getHttpBaseUrl(), HOMEBOX.getValue(), TEST_USER,
|
||||
null,
|
||||
false);
|
||||
|
||||
ResponseEntity<String> response =
|
||||
restTemplate.postForEntity(LOGIN.getValue(), missingCredentials, String.class);
|
||||
ResponseEntity<String> 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 through WireMockRuntimeInfo.
|
||||
* Creates a valid connection request with a mock Api through
|
||||
* WireMockRuntimeInfo.
|
||||
*
|
||||
* @param wm the WiremockRuntimeInfo object
|
||||
* @return a mock api connection request.
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
package com.vaessl.app.search;
|
||||
|
||||
import static com.vaessl.app.connection.Endpoint.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
import com.github.tomakehurst.wiremock.client.WireMock;
|
||||
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
|
||||
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@WireMockTest
|
||||
class SearchControllerTest {
|
||||
|
||||
@Autowired
|
||||
MockMvc mockMvc;
|
||||
|
||||
private static final String TEST_USER = "admin";
|
||||
|
||||
private static final String TEST_PASS = "pw";
|
||||
|
||||
private static final String QUERY_ALL_ITEMS = HOMEBOX_QUERY_ALL_ITEMS.getValue();
|
||||
|
||||
private static final String LOGIN_PATH = LOGIN.getValue();
|
||||
|
||||
private static final String SEARCH_REQUEST = SEARCH.getValue();
|
||||
|
||||
private static final String VALID_HOMEBOX_LOGIN_RESPONSE = """
|
||||
{
|
||||
"token": "fake-bearer-token",
|
||||
"attachmentToken": "fake-attach-token",
|
||||
"expiresAt": "2099-01-01T00:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
private static final String VALID_HOMEBOX_ALL_ITEMS_QUERY_RESPONSE = """
|
||||
{
|
||||
"page": -1,
|
||||
"pageSize": -1,
|
||||
"total": 1,
|
||||
"items": [
|
||||
{
|
||||
"id": "c643e7f9-93d0-4b5f-ae4d-e1c2d90389e0",
|
||||
"assetId": "000-001",
|
||||
"name": "MacBook Pro A1398",
|
||||
"description": "Running Linux (Fedora)",
|
||||
"quantity": 1,
|
||||
"insured": false,
|
||||
"archived": false,
|
||||
"createdAt": "2026-05-13T19:52:20.016176Z",
|
||||
"updatedAt": "2026-05-14T12:39:11.836403Z",
|
||||
"purchasePrice": 0,
|
||||
"location": {
|
||||
"id": "b6f60ab8-3a2a-4a8d-a4bf-897d0555f636",
|
||||
"name": "Server Schrank Ikea weiß",
|
||||
"description": "Weißer Ikea Schrank, wo sich der Server befindet.",
|
||||
"createdAt": "2026-05-13T19:55:55.817576Z",
|
||||
"updatedAt": "2026-05-14T12:37:24.396651Z"
|
||||
},
|
||||
"tags": [],
|
||||
"imageId": "cb3e44d5-ccd4-421e-9f5a-f52cd5f40ca6",
|
||||
"thumbnailId": "2bfd53fa-1bf1-483c-8d76-7720464532fa",
|
||||
"soldTime": "0001-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
@Test
|
||||
void shouldReturnListOfQueriedHomeboxItems(WireMockRuntimeInfo wm) throws Exception {
|
||||
|
||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||
.willReturn(WireMock.okJson(VALID_HOMEBOX_LOGIN_RESPONSE)));
|
||||
|
||||
WireMock.stubFor(WireMock.get(WireMock.urlPathEqualTo(QUERY_ALL_ITEMS))
|
||||
.willReturn(WireMock.okJson(VALID_HOMEBOX_ALL_ITEMS_QUERY_RESPONSE)));
|
||||
|
||||
MvcResult loginResult =
|
||||
mockMvc.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||
.content(connectionRequestBody(wm))).andExpect(status().isOk()).andReturn();
|
||||
|
||||
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
||||
|
||||
mockMvc.perform(post(SEARCH_REQUEST).cookie(sessionCookie)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(searchRequestBody(wm, "HOMEBOX")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.content[0].title").value("MacBook Pro A1398"))
|
||||
.andExpect(jsonPath("$.totalElements").value(1))
|
||||
.andExpect(jsonPath("$.content[0].extraData.location.name")
|
||||
.value("Server Schrank Ikea weiß"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnUnauthorizedWhenNoSession() throws Exception {
|
||||
mockMvc.perform(post(SEARCH_REQUEST).contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{
|
||||
"appUrl": "http://irrelevant",
|
||||
"query": "Item",
|
||||
"serviceType": "HOMEBOX",
|
||||
"username": "irrelevant"
|
||||
}
|
||||
""")).andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnUnauthorizedWhenSessionHasNoConnectionForRequestedServiceType(
|
||||
WireMockRuntimeInfo wm) throws Exception {
|
||||
|
||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||
.willReturn(WireMock.okJson(VALID_HOMEBOX_LOGIN_RESPONSE)));
|
||||
|
||||
MvcResult loginResult =
|
||||
mockMvc.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||
.content(connectionRequestBody(wm))).andExpect(status().isOk()).andReturn();
|
||||
|
||||
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
||||
|
||||
mockMvc.perform(
|
||||
post(SEARCH_REQUEST).cookie(sessionCookie).contentType(MediaType.APPLICATION_JSON)
|
||||
.content(searchRequestBody(wm, "OTHER_SERVICETYPE")))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
private String searchRequestBody(WireMockRuntimeInfo wm, String serviceType) {
|
||||
return """
|
||||
{
|
||||
"appUrl": "%s",
|
||||
"query": "Item",
|
||||
"serviceType": "%s",
|
||||
"username": "%s"
|
||||
}
|
||||
""".formatted(wm.getHttpBaseUrl(), serviceType, TEST_USER);
|
||||
}
|
||||
|
||||
private String connectionRequestBody(WireMockRuntimeInfo wm) {
|
||||
return """
|
||||
{
|
||||
"appUrl": "%s",
|
||||
"serviceType": "HOMEBOX",
|
||||
"username": "%s",
|
||||
"password": "%s"
|
||||
}
|
||||
""".formatted(wm.getHttpBaseUrl(), TEST_USER, TEST_PASS);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
spring:
|
||||
datasource:
|
||||
url : ${DB_TEST_URL}
|
||||
url: ${DB_TEST_URL}
|
||||
username: ${DB_USERNAME}
|
||||
password: ${DB_PASSWORD}
|
||||
driver-class-name: ${PG_DRIVER_CLASS_NAME}
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
ddl-auto: update
|
||||
|
||||
+37
-20
@@ -1,4 +1,4 @@
|
||||
**Vaessl: Spring Boot setup**
|
||||
**Vaessl: Spring Boot and database setup**
|
||||
|
||||
This app will use the current latest version 4.0.4 of Spring Boot and the latest OpenJDK 25 LTS.
|
||||
|
||||
@@ -138,7 +138,7 @@ spring:
|
||||
|
||||
```
|
||||
|
||||
Note that I'm using my own locally hosted PostgreSQL instances for the main and test database. The Docker Compose file will look something like this:
|
||||
The Docker Compose file for code-server will look something like this:
|
||||
|
||||
```
|
||||
---
|
||||
@@ -163,26 +163,43 @@ services:
|
||||
- 8124:8080
|
||||
- 5173:5173
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
vaessl-db:
|
||||
image: pgvector/pgvector:pg18
|
||||
container_name: vaessl-db
|
||||
environment:
|
||||
- POSTGRES_DB=vaessl
|
||||
- POSTGRES_USER=user
|
||||
- POSTGRES_PASSWORD=pw
|
||||
ports:
|
||||
- 5433:5432
|
||||
Note that I'm using my own locally hosted PostgreSQL instances for the main and test database. Just add databases via SQL or PgAdmin and install the pgvector extension to each database manually. There is an offical ready-made pgvector docker image but if you already host a PostGreSQL database you need to add the extension yourself.
|
||||
|
||||
vassal-test-db:
|
||||
image: pgvector/pgvector:pg18
|
||||
container_name: vassal-test-db
|
||||
environment:
|
||||
- POSTGRES_DB=vassal_test
|
||||
- POSTGRES_USER=user
|
||||
- POSTGRES_PASSWORD=pw
|
||||
ports:
|
||||
- 5434:5432
|
||||
Check the name of your PostGreSQL container:
|
||||
```
|
||||
docker ps
|
||||
```
|
||||
|
||||
Enter your container via bash:
|
||||
|
||||
```
|
||||
docker exec -it 876fb382969f bash
|
||||
```
|
||||
Before working on your database backup your databases:
|
||||
```
|
||||
su - postgres -c "pg_dumpall > /tmp/backup200526.sql"
|
||||
|
||||
#exit the container and copy the backup file to local file system
|
||||
docker cp 876fb382969f:/tmp/backup200526.sql .
|
||||
```
|
||||
|
||||
Install dependencies, build and install pgvector:
|
||||
apt-get update
|
||||
apt-get install -y build-essential git postgresql-server-dev-all
|
||||
```
|
||||
git clone https://github.com/pgvector/pgvector.git
|
||||
cd pgvector
|
||||
make
|
||||
make install
|
||||
docker restart 876fb382969f
|
||||
```
|
||||
Enter PostGreSQL container and create pgvector extension for each databse:
|
||||
```
|
||||
docker exec -it <container-name> psql -h localhost -U <db-user> -d <db-name>
|
||||
|
||||
CREATE EXTENSION vector;
|
||||
```
|
||||
|
||||
# Appendix: Additional config for developing in Code-Server
|
||||
@@ -94,7 +94,7 @@ The entire commit can be reviewed under following hash 43bbcece7a901e94021e10bca
|
||||
- DELETE /connections/{serviceType} — removes the specific key from the session. If no connections remain, the session is invalidated entirely.
|
||||
|
||||
---
|
||||
**connection/HomeboxConnectionProvider.java (modified)**
|
||||
**connection/HomeBoxConnectionProvider.java (modified)**
|
||||
|
||||
Implements the new interface methods:
|
||||
- checkCredentials validates that username and password are present before touching the network.
|
||||
|
||||
@@ -94,11 +94,11 @@ public interface ConnectionProvider {
|
||||
- `findUniqueConnectionEntry` — looks up an existing record to decide insert vs. update
|
||||
- `getTokenExpiry` — default returns `null` (no expiry); token-based providers override this so `ConnectionService` can compute the `connected` flag
|
||||
|
||||
***HomeboxConnectionProvider.java***
|
||||
***HomeBoxConnectionProvider.java***
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class HomeboxConnectionProvider implements ConnectionProvider {
|
||||
public class HomeBoxConnectionProvider implements ConnectionProvider {
|
||||
|
||||
@Override
|
||||
public String getServiceType() { return "HOMEBOX"; }
|
||||
|
||||
Reference in New Issue
Block a user