Merge branch 'backend-migration' into 'dev'
Backend migration See merge request rheinsw/rheinsw!2
This commit was merged in pull request #2.
This commit is contained in:
18
.run/ContactServiceApplication.run.xml
Normal file
18
.run/ContactServiceApplication.run.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="ContactServiceApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true">
|
||||
<envs>
|
||||
<env name="INTERNAL_API_KEY" value="LXuEOniIKfCktZObOkxRbJOL8O9xUwKLtW1stDXDBMj0nbpNPkPlC4Tnj3C9RSNg" />
|
||||
</envs>
|
||||
<module name="contactService" />
|
||||
<option name="SPRING_BOOT_MAIN_CLASS" value="dev.rheinsw.contactService.ContactServiceApplication" />
|
||||
<extension name="coverage">
|
||||
<pattern>
|
||||
<option name="PATTERN" value="dev.rheinsw.contactService.*" />
|
||||
<option name="ENABLED" value="true" />
|
||||
</pattern>
|
||||
</extension>
|
||||
<method v="2">
|
||||
<option name="Make" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
15
.run/DiscoveryServerApplication.run.xml
Normal file
15
.run/DiscoveryServerApplication.run.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="DiscoveryServerApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true">
|
||||
<module name="discoveryServer" />
|
||||
<option name="SPRING_BOOT_MAIN_CLASS" value="dev.rheinsw.discoveryServer.DiscoveryServerApplication" />
|
||||
<extension name="coverage">
|
||||
<pattern>
|
||||
<option name="PATTERN" value="dev.rheinsw.discoveryServer.*" />
|
||||
<option name="ENABLED" value="true" />
|
||||
</pattern>
|
||||
</extension>
|
||||
<method v="2">
|
||||
<option name="Make" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
7
.run/Gateway.run.xml
Normal file
7
.run/Gateway.run.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Gateway" type="CompoundRunConfigurationType">
|
||||
<toRun name="DiscoveryServerApplication" type="SpringBootApplicationConfigurationType" />
|
||||
<toRun name="GatewayApplication" type="SpringBootApplicationConfigurationType" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
18
.run/GatewayApplication.run.xml
Normal file
18
.run/GatewayApplication.run.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="GatewayApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true">
|
||||
<envs>
|
||||
<env name="FRONTEND_ORIGIN" value="http://localhost:3000 " />
|
||||
</envs>
|
||||
<module name="gateway" />
|
||||
<option name="SPRING_BOOT_MAIN_CLASS" value="dev.rheinsw.gateway.GatewayApplication" />
|
||||
<extension name="coverage">
|
||||
<pattern>
|
||||
<option name="PATTERN" value="dev.rheinsw.gateway.*" />
|
||||
<option name="ENABLED" value="true" />
|
||||
</pattern>
|
||||
</extension>
|
||||
<method v="2">
|
||||
<option name="Make" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
15
.run/MailServiceApplication.run.xml
Normal file
15
.run/MailServiceApplication.run.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="MailServiceApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true">
|
||||
<module name="mailService" />
|
||||
<option name="SPRING_BOOT_MAIN_CLASS" value="dev.rheinsw.mail.MailServiceApplication" />
|
||||
<extension name="coverage">
|
||||
<pattern>
|
||||
<option name="PATTERN" value="dev.rheinsw.mail.*" />
|
||||
<option name="ENABLED" value="true" />
|
||||
</pattern>
|
||||
</extension>
|
||||
<method v="2">
|
||||
<option name="Make" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
12
.run/npm-dev.run.xml
Normal file
12
.run/npm-dev.run.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="npm-dev" type="js.build_tools.npm">
|
||||
<package-json value="$PROJECT_DIR$/frontend/package.json" />
|
||||
<command value="run" />
|
||||
<scripts>
|
||||
<script value="dev" />
|
||||
</scripts>
|
||||
<node-interpreter value="project" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
40
backend/.gitignore
vendored
Normal file
40
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
target/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
!**/src/main/**/target/
|
||||
!**/src/test/**/target/
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea/modules.xml
|
||||
.idea/jarRepositories.xml
|
||||
.idea/compiler.xml
|
||||
.idea/libraries/
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
### Eclipse ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
build/
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
### Mac OS ###
|
||||
.DS_Store
|
||||
/.idea/
|
||||
/.env
|
||||
32
backend/discoveryServer/pom.xml
Normal file
32
backend/discoveryServer/pom.xml
Normal file
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.rheinsw</groupId>
|
||||
<artifactId>rheinsw-backend</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>discoveryServer</artifactId>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,19 @@
|
||||
package dev.rheinsw.discoveryServer;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;
|
||||
|
||||
/**
|
||||
* @author Bummsa / BoomerHD / Thatsaphorn Atchariyaphap
|
||||
* @since 21.04.25
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@EnableEurekaServer
|
||||
public class DiscoveryServerApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(DiscoveryServerApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
13
backend/discoveryServer/src/main/resources/application.yml
Normal file
13
backend/discoveryServer/src/main/resources/application.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
server:
|
||||
port: 8761
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: discovery-server
|
||||
|
||||
eureka:
|
||||
client:
|
||||
register-with-eureka: false
|
||||
fetch-registry: false
|
||||
server:
|
||||
wait-time-in-ms-when-sync-empty: 0
|
||||
82
backend/gateway/pom.xml
Normal file
82
backend/gateway/pom.xml
Normal file
@@ -0,0 +1,82 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.rheinsw</groupId>
|
||||
<artifactId>rheinsw-backend</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>gateway</artifactId>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-gateway</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Other Tools -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Tests -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.wiremock</groupId>
|
||||
<artifactId>wiremock-standalone</artifactId>
|
||||
<version>3.13.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.rheinsw</groupId>
|
||||
<artifactId>shared</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,15 @@
|
||||
package dev.rheinsw.gateway;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* @author Bummsa / BoomerHD / Thatsaphorn Atchariyaphap
|
||||
* @since 21.04.25
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class GatewayApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(GatewayApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package dev.rheinsw.gateway.filter;
|
||||
|
||||
import dev.rheinsw.gateway.service.ApiKeyValidator;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.cloud.gateway.filter.GlobalFilter;
|
||||
import org.springframework.cloud.gateway.route.Route;
|
||||
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 24.04.25
|
||||
*/
|
||||
@Component
|
||||
@Order(-1)
|
||||
@RequiredArgsConstructor
|
||||
public class ApiKeyGatewayFilter implements GlobalFilter {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ApiKeyGatewayFilter.class);
|
||||
|
||||
@Value("${gateway.security.frontend-origin}")
|
||||
private String expectedFrontendOrigin;
|
||||
|
||||
private final ApiKeyValidator apiKeyValidator;
|
||||
|
||||
@Override
|
||||
public Mono<Void> filter(ServerWebExchange exchange, org.springframework.cloud.gateway.filter.GatewayFilterChain chain) {
|
||||
String path = exchange.getRequest().getURI().getPath();
|
||||
log.debug("Incoming request for path: {}", path);
|
||||
|
||||
if (path.startsWith("/api/public/")) {
|
||||
log.debug("Public path detected, skipping API key validation");
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
String frontendKey = exchange.getRequest().getHeaders().getFirst("X-Frontend-Key");
|
||||
String internalKey = exchange.getRequest().getHeaders().getFirst("X-Internal-Auth");
|
||||
String targetService = resolveTargetServiceFromExchange(exchange);
|
||||
boolean isFrontend = isFrontendRequest(exchange);
|
||||
|
||||
log.debug("Target service resolved: {}, isFrontend: {}", targetService, isFrontend);
|
||||
|
||||
if (frontendKey != null && isFrontend) {
|
||||
log.debug("Validating frontend API key for service: {}", targetService);
|
||||
if (apiKeyValidator.isAuthorized(frontendKey, targetService, true)) {
|
||||
log.debug("Frontend API key authorized");
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
log.warn("Shared API key validation failed for service: {}", targetService);
|
||||
}
|
||||
|
||||
if (internalKey != null) {
|
||||
log.debug("Validating internal API key for service: {}", targetService);
|
||||
if (apiKeyValidator.isAuthorized(internalKey, targetService, false)) {
|
||||
log.debug("Internal API key authorized");
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
log.warn("Internal API key validation failed for service: {}", targetService);
|
||||
}
|
||||
|
||||
log.warn("Unauthorized request to {} from origin {}", path, exchange.getRequest().getHeaders().getFirst("Origin"));
|
||||
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
|
||||
return exchange.getResponse().setComplete();
|
||||
}
|
||||
|
||||
private String resolveTargetServiceFromExchange(ServerWebExchange exchange) {
|
||||
Route route = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
|
||||
return route != null ? route.getId() : "unknown";
|
||||
}
|
||||
|
||||
private boolean isFrontendRequest(ServerWebExchange exchange) {
|
||||
String origin = exchange.getRequest().getHeaders().getFirst("Origin");
|
||||
boolean matches = origin != null && origin.trim().equalsIgnoreCase(expectedFrontendOrigin.trim());
|
||||
log.debug("Origin header: {}, expected: {}, matches: {}", origin, expectedFrontendOrigin, matches);
|
||||
return matches;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package dev.rheinsw.gateway.model;
|
||||
|
||||
import dev.rheinsw.shared.entity.BaseEntity;
|
||||
import jakarta.persistence.CollectionTable;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.ElementCollection;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 25.04.25
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "api_key")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ApiKey extends BaseEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "api_key", unique = true, nullable = false)
|
||||
private String apiKey;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private ApiKeyType type;
|
||||
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
@CollectionTable(name = "api_key_services", joinColumns = @JoinColumn(name = "api_key_id"))
|
||||
@Column(name = "allowed_service")
|
||||
private Set<String> allowedServices;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean enabled;
|
||||
|
||||
@Column(name = "frontend_only", nullable = false)
|
||||
private boolean frontendOnly;
|
||||
|
||||
@Column
|
||||
private String description;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package dev.rheinsw.gateway.model;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 25.04.25
|
||||
*/
|
||||
public enum ApiKeyType {
|
||||
FRONTEND,
|
||||
INTERNAL;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package dev.rheinsw.gateway.repository;
|
||||
|
||||
import dev.rheinsw.gateway.model.ApiKey;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 25.04.25
|
||||
*/
|
||||
public interface ApiKeyRepository extends JpaRepository<ApiKey, Long> {
|
||||
Optional<ApiKey> findByApiKey(String apiKey);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package dev.rheinsw.gateway.service;
|
||||
|
||||
import dev.rheinsw.gateway.model.ApiKey;
|
||||
import dev.rheinsw.gateway.repository.ApiKeyRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 25.04.25
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ApiKeyValidator {
|
||||
|
||||
private final ApiKeyRepository apiKeyRepository;
|
||||
|
||||
public boolean isAuthorized(String key, String targetService, boolean isFrontendRequest) {
|
||||
return apiKeyRepository.findByApiKey(key)
|
||||
.filter(ApiKey::isEnabled)
|
||||
.filter(apiKey -> !apiKey.isFrontendOnly() || isFrontendRequest)
|
||||
.filter(apiKey -> apiKey.getAllowedServices().contains(targetService))
|
||||
.isPresent();
|
||||
}
|
||||
}
|
||||
41
backend/gateway/src/main/resources/application.yml
Normal file
41
backend/gateway/src/main/resources/application.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
eureka:
|
||||
client:
|
||||
service-url:
|
||||
defaultZone: http://localhost:8761/eureka/
|
||||
|
||||
gateway:
|
||||
security:
|
||||
frontend-origin: ${FRONTEND_ORIGIN}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: gateway
|
||||
datasource:
|
||||
url: jdbc:postgresql://localhost:5432/rheinsw_dev
|
||||
username: rheinsw
|
||||
password: rheinsw
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
show-sql: true
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
cloud:
|
||||
gateway:
|
||||
routes:
|
||||
- id: contactService
|
||||
uri: lb://contactService
|
||||
predicates:
|
||||
- Path=/api/contact/**
|
||||
filters:
|
||||
- StripPrefix=1
|
||||
- id: mailService
|
||||
uri: lb://mailService
|
||||
predicates:
|
||||
- Path=/api/mail/**
|
||||
filters:
|
||||
- StripPrefix=1
|
||||
@@ -0,0 +1,58 @@
|
||||
-- 1. Create tables with timestamps correctly
|
||||
|
||||
CREATE TABLE api_key
|
||||
(
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
api_key VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(255) NOT NULL,
|
||||
enabled BOOLEAN NOT NULL,
|
||||
frontend_only BOOLEAN NOT NULL,
|
||||
createdDateTime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
modifiedDateTime TIMESTAMP NULL,
|
||||
CONSTRAINT pk_api_key PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE api_key_services
|
||||
(
|
||||
api_key_id BIGINT NOT NULL,
|
||||
allowed_service VARCHAR(255),
|
||||
createdDateTime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
modifiedDateTime TIMESTAMP NULL
|
||||
);
|
||||
|
||||
-- 2. Add constraints
|
||||
|
||||
ALTER TABLE api_key
|
||||
ADD CONSTRAINT uc_api_key_key UNIQUE (api_key); -- (fixed: correct column name is api_key)
|
||||
|
||||
ALTER TABLE api_key_services
|
||||
ADD CONSTRAINT fk_api_key_services_on_api_key FOREIGN KEY (api_key_id) REFERENCES api_key (id);
|
||||
|
||||
-- 3. Function to update modifiedDateTime if any real change occurs
|
||||
|
||||
CREATE
|
||||
OR REPLACE FUNCTION set_modified_datetime()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF
|
||||
(OLD IS DISTINCT FROM NEW) THEN
|
||||
NEW.modifiedDateTime = CURRENT_TIMESTAMP;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$
|
||||
LANGUAGE plpgsql;
|
||||
|
||||
-- 4. Triggers to update modifiedDateTime
|
||||
|
||||
CREATE TRIGGER trg_set_modified_datetime_api_key
|
||||
BEFORE UPDATE
|
||||
ON api_key
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_modified_datetime();
|
||||
|
||||
CREATE TRIGGER trg_set_modified_datetime_api_key_services
|
||||
BEFORE UPDATE
|
||||
ON api_key_services
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_modified_datetime();
|
||||
@@ -0,0 +1,121 @@
|
||||
package dev.rheinsw.gateway.filter;
|
||||
|
||||
import dev.rheinsw.gateway.service.ApiKeyValidator;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||
import org.springframework.cloud.gateway.route.Route;
|
||||
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
|
||||
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ApiKeyGatewayFilterTest {
|
||||
|
||||
@Mock
|
||||
private ApiKeyValidator apiKeyValidator;
|
||||
|
||||
@Mock
|
||||
private GatewayFilterChain chain;
|
||||
|
||||
private ApiKeyGatewayFilter filter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
filter = new ApiKeyGatewayFilter(apiKeyValidator);
|
||||
|
||||
// Inject expectedFrontendOrigin manually
|
||||
ReflectionTestUtils.setField(filter, "expectedFrontendOrigin", "https://localhost:3000");
|
||||
}
|
||||
|
||||
private void injectMockRoute(MockServerWebExchange exchange, String routeId) {
|
||||
Route mockRoute = mock(Route.class);
|
||||
when(mockRoute.getId()).thenReturn(routeId);
|
||||
exchange.getAttributes().put(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR, mockRoute);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowPublicRequestWithoutValidation() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest.get("/api/public/test").build();
|
||||
MockServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||
|
||||
when(chain.filter(exchange)).thenReturn(Mono.empty());
|
||||
|
||||
filter.filter(exchange, chain).block();
|
||||
|
||||
verify(chain, times(1)).filter(exchange);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowValidFrontendApiKey() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest.get("/api/contact/test")
|
||||
.header("Origin", "https://localhost:3000")
|
||||
.header("X-Frontend-Key", "valid-frontend-key")
|
||||
.build();
|
||||
MockServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||
injectMockRoute(exchange, "contactService");
|
||||
|
||||
when(apiKeyValidator.isAuthorized(eq("valid-frontend-key"), eq("contactService"), eq(true))).thenReturn(true);
|
||||
when(chain.filter(exchange)).thenReturn(Mono.empty());
|
||||
|
||||
filter.filter(exchange, chain).block();
|
||||
|
||||
verify(apiKeyValidator).isAuthorized("valid-frontend-key", "contactService", true);
|
||||
verify(chain, times(1)).filter(exchange);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectInvalidFrontendApiKey() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest.get("/api/contact/test")
|
||||
.header("Origin", "https://localhost:3000")
|
||||
.header("X-Frontend-Key", "invalid-frontend-key")
|
||||
.build();
|
||||
MockServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||
injectMockRoute(exchange, "contactService");
|
||||
|
||||
when(apiKeyValidator.isAuthorized(eq("invalid-frontend-key"), eq("contactService"), eq(true))).thenReturn(false);
|
||||
|
||||
filter.filter(exchange, chain).block();
|
||||
|
||||
assertEquals(exchange.getResponse().getStatusCode(), org.springframework.http.HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowValidInternalApiKey() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest.get("/api/contact/test")
|
||||
.header("X-Internal-Auth", "valid-internal-key")
|
||||
.build();
|
||||
MockServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||
injectMockRoute(exchange, "contactService");
|
||||
|
||||
when(apiKeyValidator.isAuthorized(eq("valid-internal-key"), eq("contactService"), eq(false))).thenReturn(true);
|
||||
when(chain.filter(exchange)).thenReturn(Mono.empty());
|
||||
|
||||
filter.filter(exchange, chain).block();
|
||||
|
||||
verify(apiKeyValidator).isAuthorized("valid-internal-key", "contactService", false);
|
||||
verify(chain, times(1)).filter(exchange);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectInvalidInternalApiKey() {
|
||||
MockServerHttpRequest request = MockServerHttpRequest.get("/api/contact/test")
|
||||
.header("X-Internal-Auth", "invalid-internal-key")
|
||||
.build();
|
||||
MockServerWebExchange exchange = MockServerWebExchange.from(request);
|
||||
injectMockRoute(exchange, "contactService");
|
||||
|
||||
when(apiKeyValidator.isAuthorized(eq("invalid-internal-key"), eq("contactService"), eq(false))).thenReturn(false);
|
||||
|
||||
filter.filter(exchange, chain).block();
|
||||
|
||||
assertEquals(exchange.getResponse().getStatusCode(), org.springframework.http.HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package dev.rheinsw.gateway.security;
|
||||
|
||||
import dev.rheinsw.gateway.model.ApiKey;
|
||||
import dev.rheinsw.gateway.model.ApiKeyType;
|
||||
import dev.rheinsw.gateway.repository.ApiKeyRepository;
|
||||
import dev.rheinsw.gateway.service.ApiKeyValidator;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ApiKeyValidatorTest {
|
||||
|
||||
private final ApiKeyRepository repository = mock(ApiKeyRepository.class);
|
||||
private final ApiKeyValidator validator = new ApiKeyValidator(repository);
|
||||
|
||||
@Test
|
||||
void testAuthorizedFrontendKey() {
|
||||
ApiKey key = ApiKey.builder()
|
||||
.apiKey("frontend-key")
|
||||
.type(ApiKeyType.FRONTEND)
|
||||
.enabled(true)
|
||||
.frontendOnly(true)
|
||||
.allowedServices(Set.of("contactService"))
|
||||
.build();
|
||||
|
||||
when(repository.findByApiKey("frontend-key")).thenReturn(Optional.of(key));
|
||||
|
||||
boolean result = validator.isAuthorized("frontend-key", "contactService", true);
|
||||
assertTrue(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUnauthorizedDueToWrongService() {
|
||||
ApiKey key = ApiKey.builder()
|
||||
.apiKey("internal-key")
|
||||
.type(ApiKeyType.INTERNAL)
|
||||
.enabled(true)
|
||||
.frontendOnly(false)
|
||||
.allowedServices(Set.of("mailService"))
|
||||
.build();
|
||||
|
||||
when(repository.findByApiKey("internal-key")).thenReturn(Optional.of(key));
|
||||
|
||||
boolean result = validator.isAuthorized("internal-key", "contactService", false);
|
||||
assertFalse(result);
|
||||
}
|
||||
}
|
||||
1
backend/gateway/src/test/resources/application.yml
Normal file
1
backend/gateway/src/test/resources/application.yml
Normal file
@@ -0,0 +1 @@
|
||||
spring.cloud.gateway.mvc-discovery.enabled=false
|
||||
83
backend/pom.xml
Normal file
83
backend/pom.xml
Normal file
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>dev.rheinsw</groupId>
|
||||
<artifactId>rheinsw-backend</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<modules>
|
||||
<module>shared</module>
|
||||
<module>discoveryServer</module>
|
||||
<module>gateway</module>
|
||||
<module>services/contactService</module>
|
||||
<module>services/mailService</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<maven.compiler.plugin.version>3.14.0</maven.compiler.plugin.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
|
||||
<!-- Spring-related stuff -->
|
||||
<spring-boot.version>3.4.4</spring-boot.version>
|
||||
<spring-cloud.version>2024.0.1</spring-cloud.version>
|
||||
|
||||
<!-- all the others -->
|
||||
<lombok.version>1.18.38</lombok.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<!-- Spring Boot BOM -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-dependencies</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Cloud BOM -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-dependencies</artifactId>
|
||||
<version>${spring-cloud.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Other BOMs or shared deps -->
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<version>5.17.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<build>
|
||||
<pluginManagement>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.3</version>
|
||||
<configuration>
|
||||
<argLine>-Djdk.attach.allowAttachSelf=true</argLine>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</pluginManagement>
|
||||
</build>
|
||||
</project>
|
||||
100
backend/services/contactService/pom.xml
Normal file
100
backend/services/contactService/pom.xml
Normal file
@@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>dev.rheinsw</groupId>
|
||||
<artifactId>rheinsw-backend</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<relativePath>../../pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
<artifactId>contactService</artifactId>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>${maven.compiler.plugin.version}</version>
|
||||
<configuration>
|
||||
<source>${maven.compiler.source}</source>
|
||||
<target>${maven.compiler.target}</target>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<dependencies>
|
||||
<!-- Spring Boot starter for REST -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Eureka Client -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Tools -->
|
||||
<!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.validation</groupId>
|
||||
<artifactId>jakarta.validation-api</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>dev.rheinsw</groupId>
|
||||
<artifactId>shared</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- test suite -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,19 @@
|
||||
package dev.rheinsw.contactService;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
|
||||
/**
|
||||
* @author Bummsa / BoomerHD / Thatsaphorn Atchariyaphap
|
||||
* @since 21.04.25
|
||||
*/
|
||||
@EnableAsync
|
||||
@SpringBootApplication(
|
||||
scanBasePackages = {"dev.rheinsw"}
|
||||
)
|
||||
public class ContactServiceApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ContactServiceApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package dev.rheinsw.contactService.config;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 21.04.25
|
||||
*/
|
||||
@Setter
|
||||
@Getter
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "hcaptcha")
|
||||
public class HCaptchaConfig {
|
||||
private String secret;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package dev.rheinsw.contactService.controller;
|
||||
|
||||
import dev.rheinsw.contactService.dto.ContactRequestDto;
|
||||
import dev.rheinsw.contactService.model.ContactRequest;
|
||||
import dev.rheinsw.contactService.repository.ContactRequestsRepo;
|
||||
import dev.rheinsw.contactService.service.HCaptchaValidator;
|
||||
import dev.rheinsw.shared.mail.MailServiceClient;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* REST controller to handle contact form submissions.
|
||||
*
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 21.04.25
|
||||
*/
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
@RequestMapping("/contact")
|
||||
public class ContactController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ContactController.class);
|
||||
|
||||
private final HCaptchaValidator captchaValidator;
|
||||
private final ContactRequestsRepo contactRepository;
|
||||
private final MailServiceClient mailServiceClient;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<String> submitContact(@RequestBody ContactRequestDto request) {
|
||||
log.info("Received contact form from: {}", request.name);
|
||||
log.debug("Captcha token: {}", request.captcha);
|
||||
log.info("Message: {}", request.message);
|
||||
|
||||
if (request.email != null) {
|
||||
log.info("Reply to: {} ({})", request.email, request.name);
|
||||
}
|
||||
|
||||
if (!isValidCaptcha(request.captcha)) {
|
||||
log.warn("Captcha verification failed for {}", request.email);
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Captcha verification failed");
|
||||
}
|
||||
|
||||
ContactRequest message = new ContactRequest()
|
||||
.setName(request.name)
|
||||
.setEmail(request.email)
|
||||
.setMessage(request.message)
|
||||
.setCompany(request.company)
|
||||
.setPhone(request.phone)
|
||||
.setWebsite(request.website)
|
||||
.setCaptchaToken(request.captcha)
|
||||
.setSubmittedAt(LocalDateTime.now());
|
||||
|
||||
contactRepository.save(message);
|
||||
|
||||
notifyContactAndTeam(request);
|
||||
|
||||
return ResponseEntity.ok("Contact form submitted successfully");
|
||||
}
|
||||
|
||||
private boolean isValidCaptcha(String captcha) {
|
||||
return "10000000-aaaa-bbbb-cccc-000000000001".equals(captcha) || captchaValidator.isValid(captcha);
|
||||
}
|
||||
|
||||
private void notifyContactAndTeam(ContactRequestDto request) {
|
||||
// User confirmation
|
||||
String userSubject = "Kontaktanfrage erhalten";
|
||||
String userBody = """
|
||||
Hallo %s,
|
||||
|
||||
wir haben Ihre Nachricht erhalten und melden uns schnellstmöglich bei Ihnen.
|
||||
|
||||
Ihre Nachricht:
|
||||
%s
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
Rhein Software
|
||||
""".formatted(request.name, request.message);
|
||||
|
||||
mailServiceClient.sendMail(request.email, userSubject, userBody);
|
||||
|
||||
// Team notification
|
||||
String teamSubject = "Neue Kontaktanfrage";
|
||||
String teamBody = """
|
||||
Neue Kontaktanfrage von: %s
|
||||
E-Mail: %s
|
||||
Unternehmen: %s
|
||||
Telefonnummer: %s
|
||||
Webseite: %s
|
||||
|
||||
Nachricht:
|
||||
%s
|
||||
""".formatted(
|
||||
request.name,
|
||||
request.email,
|
||||
safe(request.company),
|
||||
safe(request.phone),
|
||||
safe(request.website),
|
||||
request.message
|
||||
);
|
||||
|
||||
mailServiceClient.sendMail("rhein.software@gmail.com", teamSubject, teamBody);
|
||||
}
|
||||
|
||||
private String safe(String value) {
|
||||
return value != null ? value : "-";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package dev.rheinsw.contactService.dto;
|
||||
|
||||
import dev.rheinsw.shared.transport.Dto;
|
||||
|
||||
/**
|
||||
* @author Bummsa / BoomerHD / Thatsaphorn Atchariyaphap
|
||||
* @since 21.04.25
|
||||
*/
|
||||
public class ContactRequestDto implements Dto {
|
||||
public String name;
|
||||
public String email;
|
||||
public String message;
|
||||
|
||||
public String company; // optional
|
||||
public String phone; // optional
|
||||
public String website; // optional
|
||||
public String captcha; // required for hCaptcha validation
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package dev.rheinsw.contactService.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 22.04.25
|
||||
*/
|
||||
@Entity
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@Table(name = "contact_requests")
|
||||
public class ContactRequest {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Size(max = 100)
|
||||
private String name;
|
||||
|
||||
@Size(max = 100)
|
||||
@Email
|
||||
private String email;
|
||||
|
||||
@Size(max = 1000)
|
||||
@Column(length = 1000)
|
||||
private String message;
|
||||
|
||||
@Size(max = 100)
|
||||
private String company;
|
||||
|
||||
@Size(max = 20)
|
||||
private String phone;
|
||||
|
||||
@Size(max = 100)
|
||||
private String website;
|
||||
|
||||
@Size(max = 1024)
|
||||
@Column(name = "captcha_token", length = 1024)
|
||||
private String captchaToken;
|
||||
|
||||
private LocalDateTime submittedAt;
|
||||
|
||||
public ContactRequest setName(String name) {
|
||||
this.name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ContactRequest setEmail(String email) {
|
||||
this.email = email;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ContactRequest setMessage(String message) {
|
||||
this.message = message;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ContactRequest setCompany(String company) {
|
||||
this.company = company;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ContactRequest setPhone(String phone) {
|
||||
this.phone = phone;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ContactRequest setWebsite(String website) {
|
||||
this.website = website;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ContactRequest setCaptchaToken(String captchaToken) {
|
||||
this.captchaToken = captchaToken;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ContactRequest setSubmittedAt(LocalDateTime submittedAt) {
|
||||
this.submittedAt = submittedAt;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package dev.rheinsw.contactService.repository;
|
||||
|
||||
import dev.rheinsw.contactService.model.ContactRequest;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 22.04.25
|
||||
*/
|
||||
public interface ContactRequestsRepo extends JpaRepository<ContactRequest, Long> {
|
||||
// empty
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package dev.rheinsw.contactService.service;
|
||||
|
||||
import dev.rheinsw.contactService.config.HCaptchaConfig;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 21.04.25
|
||||
*/
|
||||
@Service
|
||||
public class HCaptchaValidator {
|
||||
private static final Logger log = LoggerFactory.getLogger(HCaptchaValidator.class);
|
||||
|
||||
private final HCaptchaConfig config;
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
public HCaptchaValidator(HCaptchaConfig config, RestTemplate restTemplate) {
|
||||
this.config = config;
|
||||
this.restTemplate = restTemplate;
|
||||
}
|
||||
|
||||
public boolean isValid(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
log.warn("Captcha token is missing or blank");
|
||||
return false;
|
||||
}
|
||||
|
||||
String secret = config.getSecret();
|
||||
if (secret == null || secret.isBlank()) {
|
||||
log.error("Captcha secret is missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
var response = restTemplate.postForObject(
|
||||
"https://api.hcaptcha.com/siteverify",
|
||||
new org.springframework.util.LinkedMultiValueMap<String, String>() {{
|
||||
add("secret", secret);
|
||||
add("response", token);
|
||||
}},
|
||||
Map.class
|
||||
);
|
||||
|
||||
return response != null && Boolean.TRUE.equals(response.get("success"));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to verify hCaptcha", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
server:
|
||||
port: 0 # random port
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: contactService
|
||||
datasource:
|
||||
url: jdbc:postgresql://localhost:5432/rheinsw_dev
|
||||
username: rheinsw
|
||||
password: rheinsw
|
||||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
show-sql: true
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
|
||||
eureka:
|
||||
client:
|
||||
service-url:
|
||||
defaultZone: http://localhost:8761/eureka/
|
||||
|
||||
hcaptcha:
|
||||
secret: ES_ff59a664dc764f92870bf2c7b4eab7c5
|
||||
|
||||
logging:
|
||||
level:
|
||||
org.hibernate.SQL: DEBUG
|
||||
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE contact_requests
|
||||
(
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100),
|
||||
email VARCHAR(100),
|
||||
message VARCHAR(1000),
|
||||
company VARCHAR(100),
|
||||
phone VARCHAR(20),
|
||||
website VARCHAR(100),
|
||||
captcha_token VARCHAR(1024),
|
||||
submitted_at TIMESTAMP
|
||||
);
|
||||
@@ -0,0 +1,125 @@
|
||||
package dev.rheinsw.contactService.controller;
|
||||
|
||||
import dev.rheinsw.contactService.dto.ContactRequestDto;
|
||||
import dev.rheinsw.contactService.model.ContactRequest;
|
||||
import dev.rheinsw.contactService.repository.ContactRequestsRepo;
|
||||
import dev.rheinsw.contactService.service.HCaptchaValidator;
|
||||
import dev.rheinsw.shared.mail.MailServiceClient;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.*;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ContactControllerTest {
|
||||
|
||||
@Mock
|
||||
private HCaptchaValidator hCaptchaValidator;
|
||||
|
||||
@Mock
|
||||
private ContactRequestsRepo repository;
|
||||
|
||||
@Mock
|
||||
private MailServiceClient mailServiceClient;
|
||||
|
||||
@InjectMocks
|
||||
private ContactController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitContact_ShouldReturnSuccess_WhenDevCaptchaTokenUsed() {
|
||||
// Arrange
|
||||
ContactRequestDto request = new ContactRequestDto();
|
||||
request.name = "Test User";
|
||||
request.email = "test@example.com";
|
||||
request.message = "Hello!";
|
||||
request.captcha = "10000000-aaaa-bbbb-cccc-000000000001";
|
||||
|
||||
// Act
|
||||
ResponseEntity<String> response = controller.submitContact(request);
|
||||
|
||||
// Assert
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals("Contact form submitted successfully", response.getBody());
|
||||
|
||||
verify(repository, times(1)).save(any(ContactRequest.class));
|
||||
verify(mailServiceClient, times(2)).sendMail(anyString(), anyString(), anyString());
|
||||
verifyNoInteractions(hCaptchaValidator);
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitContact_ShouldReturnSuccess_WhenCaptchaValid() {
|
||||
// Arrange
|
||||
ContactRequestDto request = new ContactRequestDto();
|
||||
request.name = "Valid User";
|
||||
request.email = "valid@example.com";
|
||||
request.message = "Some message";
|
||||
request.captcha = "real-token";
|
||||
|
||||
when(hCaptchaValidator.isValid("real-token")).thenReturn(true);
|
||||
|
||||
// Act
|
||||
ResponseEntity<String> response = controller.submitContact(request);
|
||||
|
||||
// Assert
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertEquals("Contact form submitted successfully", response.getBody());
|
||||
|
||||
verify(hCaptchaValidator, times(1)).isValid("real-token");
|
||||
verify(repository, times(1)).save(any(ContactRequest.class));
|
||||
verify(mailServiceClient, times(2)).sendMail(anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitContact_ShouldReturnForbidden_WhenCaptchaInvalid() {
|
||||
// Arrange
|
||||
ContactRequestDto request = new ContactRequestDto();
|
||||
request.name = "Bot User";
|
||||
request.email = "bot@example.com";
|
||||
request.message = "Spam spam spam";
|
||||
request.captcha = "invalid-token";
|
||||
|
||||
when(hCaptchaValidator.isValid("invalid-token")).thenReturn(false);
|
||||
|
||||
// Act
|
||||
ResponseEntity<String> response = controller.submitContact(request);
|
||||
|
||||
// Assert
|
||||
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
|
||||
assertEquals("Captcha verification failed", response.getBody());
|
||||
|
||||
verify(hCaptchaValidator).isValid("invalid-token");
|
||||
verifyNoInteractions(repository);
|
||||
verifyNoInteractions(mailServiceClient);
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitContact_ShouldHandleNullOptionalFields() {
|
||||
// Arrange
|
||||
ContactRequestDto request = new ContactRequestDto();
|
||||
request.name = "No Company";
|
||||
request.email = "user@example.com";
|
||||
request.message = "Just a message";
|
||||
request.captcha = "real-token";
|
||||
request.company = null;
|
||||
request.phone = null;
|
||||
request.website = null;
|
||||
|
||||
when(hCaptchaValidator.isValid("real-token")).thenReturn(true);
|
||||
|
||||
// Act
|
||||
ResponseEntity<String> response = controller.submitContact(request);
|
||||
|
||||
// Assert
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
verify(repository).save(any(ContactRequest.class));
|
||||
verify(mailServiceClient, times(2)).sendMail(anyString(), anyString(), anyString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package dev.rheinsw.contactService.service;
|
||||
|
||||
import dev.rheinsw.contactService.config.HCaptchaConfig;
|
||||
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.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class HCaptchaValidatorTest {
|
||||
|
||||
@Mock
|
||||
private HCaptchaConfig hCaptchaConfig;
|
||||
|
||||
@Mock
|
||||
private RestTemplate restTemplate;
|
||||
|
||||
private HCaptchaValidator validator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
validator = new HCaptchaValidator(hCaptchaConfig, restTemplate);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValid_ShouldReturnFalse_WhenTokenIsNull() {
|
||||
// Arrange
|
||||
|
||||
// Act
|
||||
boolean result = validator.isValid(null);
|
||||
|
||||
// Assert
|
||||
assertFalse(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValid_ShouldReturnFalse_WhenTokenIsBlank() {
|
||||
// Arrange
|
||||
|
||||
// Act
|
||||
boolean result = validator.isValid(" ");
|
||||
|
||||
// Assert
|
||||
assertFalse(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValid_ShouldReturnFalse_WhenSecretIsNull() {
|
||||
// Arrange
|
||||
Mockito.when(hCaptchaConfig.getSecret()).thenReturn(null);
|
||||
|
||||
// Act
|
||||
boolean result = validator.isValid("test-token");
|
||||
|
||||
// Assert
|
||||
assertFalse(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValid_ShouldReturnFalse_WhenSecretIsBlank() {
|
||||
// Arrange
|
||||
Mockito.when(hCaptchaConfig.getSecret()).thenReturn(" ");
|
||||
|
||||
// Act
|
||||
boolean result = validator.isValid("test-token");
|
||||
|
||||
// Assert
|
||||
assertFalse(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValid_ShouldReturnFalse_WhenApiResponseIsNull() {
|
||||
// Arrange
|
||||
Mockito.when(hCaptchaConfig.getSecret()).thenReturn("test-secret");
|
||||
Mockito.when(restTemplate.postForObject(
|
||||
eq("https://api.hcaptcha.com/siteverify"),
|
||||
any(),
|
||||
eq(Map.class)
|
||||
)).thenReturn(null);
|
||||
|
||||
// Act
|
||||
boolean result = validator.isValid("test-token");
|
||||
|
||||
// Assert
|
||||
assertFalse(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValid_ShouldReturnFalse_WhenApiResponseSuccessIsFalse() {
|
||||
// Arrange
|
||||
Mockito.when(hCaptchaConfig.getSecret()).thenReturn("test-secret");
|
||||
Mockito.when(restTemplate.postForObject(
|
||||
eq("https://api.hcaptcha.com/siteverify"),
|
||||
any(),
|
||||
eq(Map.class)
|
||||
)).thenReturn(Map.of("success", false));
|
||||
|
||||
// Act
|
||||
boolean result = validator.isValid("test-token");
|
||||
|
||||
// Assert
|
||||
assertFalse(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValid_ShouldReturnTrue_WhenApiResponseSuccessIsTrue() {
|
||||
// Arrange
|
||||
Mockito.when(hCaptchaConfig.getSecret()).thenReturn("test-secret");
|
||||
Mockito.when(restTemplate.postForObject(
|
||||
eq("https://api.hcaptcha.com/siteverify"),
|
||||
any(),
|
||||
eq(Map.class)
|
||||
)).thenReturn(Map.of("success", true));
|
||||
|
||||
// Act
|
||||
boolean result = validator.isValid("test-token");
|
||||
|
||||
// Assert
|
||||
assertTrue(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValid_ShouldReturnFalse_WhenExceptionIsThrown() {
|
||||
// Arrange
|
||||
Mockito.when(hCaptchaConfig.getSecret()).thenReturn("test-secret");
|
||||
Mockito.when(restTemplate.postForObject(
|
||||
eq("https://api.hcaptcha.com/siteverify"),
|
||||
any(),
|
||||
eq(Map.class)
|
||||
)).thenThrow(new RuntimeException("Simulated Exception"));
|
||||
|
||||
// Act
|
||||
boolean result = validator.isValid("test-token");
|
||||
|
||||
// Assert
|
||||
assertFalse(result);
|
||||
}
|
||||
}
|
||||
114
backend/services/mailService/pom.xml
Normal file
114
backend/services/mailService/pom.xml
Normal file
@@ -0,0 +1,114 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>dev.rheinsw</groupId>
|
||||
<artifactId>rheinsw-backend</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<relativePath>../../pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
<artifactId>mailService</artifactId>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>${maven.compiler.plugin.version}</version>
|
||||
<configuration>
|
||||
<source>${maven.compiler.source}</source>
|
||||
<target>${maven.compiler.target}</target>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<dependencies>
|
||||
<!-- Spring Boot starter for REST -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://mvnrepository.com/artifact/jakarta.mail/jakarta.mail-api -->
|
||||
<dependency>
|
||||
<groupId>jakarta.mail</groupId>
|
||||
<artifactId>jakarta.mail-api</artifactId>
|
||||
<version>2.1.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.validation</groupId>
|
||||
<artifactId>jakarta.validation-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-actuator-autoconfigure</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>guava</artifactId>
|
||||
<version>33.4.0-jre</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>dev.rheinsw</groupId>
|
||||
<artifactId>shared</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- test suite -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,18 @@
|
||||
package dev.rheinsw.mail;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 22.04.25
|
||||
*/
|
||||
@SpringBootApplication(exclude = {
|
||||
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration.class,
|
||||
org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration.class
|
||||
})
|
||||
public class MailServiceApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(MailServiceApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package dev.rheinsw.mail.controller;
|
||||
|
||||
import dev.rheinsw.shared.mail.dto.MailRequest;
|
||||
import dev.rheinsw.mail.service.MailService;
|
||||
import jakarta.mail.MessagingException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 22.04.25
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/mail")
|
||||
@RequiredArgsConstructor
|
||||
public class MailController {
|
||||
|
||||
private final MailService mailService;
|
||||
|
||||
@PostMapping("/send")
|
||||
public ResponseEntity<String> sendEmail(@RequestBody MailRequest request) {
|
||||
try {
|
||||
mailService.sendEmail(request);
|
||||
return ResponseEntity.ok("Email sent successfully");
|
||||
} catch (MessagingException e) {
|
||||
return ResponseEntity.status(500).body("Failed to send email: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package dev.rheinsw.mail.service;
|
||||
|
||||
import dev.rheinsw.shared.mail.dto.MailRequest;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 22.04.25
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MailService {
|
||||
|
||||
private final JavaMailSender mailSender;
|
||||
|
||||
public void sendEmail(MailRequest request) throws MessagingException {
|
||||
MimeMessage message = mailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, true);
|
||||
|
||||
helper.setFrom("noreply@rhein-software.dev");
|
||||
helper.setTo(request.getTo());
|
||||
helper.setSubject(request.getSubject());
|
||||
helper.setText(request.getMessage(), false);
|
||||
|
||||
mailSender.send(message);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
server:
|
||||
port: 0 # random port
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: mailService
|
||||
|
||||
mail:
|
||||
host: smtp.resend.com
|
||||
port: 587
|
||||
username: resend
|
||||
password: re_JnLD5ndg_GnKtXcTqskXm1bg7Wxnghna3
|
||||
properties:
|
||||
mail:
|
||||
smtp:
|
||||
auth: true
|
||||
starttls:
|
||||
enable: true
|
||||
default-encoding: UTF-8
|
||||
|
||||
eureka:
|
||||
client:
|
||||
service-url:
|
||||
defaultZone: http://localhost:8761/eureka/
|
||||
@@ -0,0 +1,63 @@
|
||||
package dev.rheinsw.mail.controller;
|
||||
|
||||
import dev.rheinsw.mail.service.MailService;
|
||||
import dev.rheinsw.shared.mail.dto.MailRequest;
|
||||
import jakarta.mail.MessagingException;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.*;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class MailControllerTest {
|
||||
|
||||
@Mock
|
||||
private MailService mailService;
|
||||
|
||||
@InjectMocks
|
||||
private MailController mailController;
|
||||
|
||||
private AutoCloseable closeable;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
closeable = MockitoAnnotations.openMocks(this);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendEmail_shouldReturnOk_whenEmailSentSuccessfully() throws MessagingException {
|
||||
// Arrange
|
||||
MailRequest request = new MailRequest("user@example.com", "Test Subject", "Message");
|
||||
|
||||
// Act
|
||||
ResponseEntity<String> response = mailController.sendEmail(request);
|
||||
|
||||
// Assert
|
||||
verify(mailService).sendEmail(request);
|
||||
assertEquals(200, response.getStatusCodeValue());
|
||||
assertEquals("Email sent successfully", response.getBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendEmail_shouldReturnServerError_whenMessagingExceptionThrown() throws MessagingException {
|
||||
// Arrange
|
||||
MailRequest request = new MailRequest("user@example.com", "Test Subject", "Message");
|
||||
doThrow(new MessagingException("SMTP failed")).when(mailService).sendEmail(request);
|
||||
|
||||
// Act
|
||||
ResponseEntity<String> response = mailController.sendEmail(request);
|
||||
|
||||
// Assert
|
||||
verify(mailService).sendEmail(request);
|
||||
assertEquals(500, response.getStatusCodeValue());
|
||||
assertTrue(response.getBody().contains("Failed to send email: SMTP failed"));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
closeable.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package dev.rheinsw.mail.service;
|
||||
|
||||
import dev.rheinsw.shared.mail.dto.MailRequest;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.*;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class MailServiceTest {
|
||||
|
||||
@Mock
|
||||
private JavaMailSender mailSender;
|
||||
|
||||
@InjectMocks
|
||||
private MailService mailService;
|
||||
|
||||
private AutoCloseable closeable;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
closeable = MockitoAnnotations.openMocks(this);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendEmail_shouldSendMessageSuccessfully() throws MessagingException {
|
||||
// Arrange
|
||||
MailRequest request = new MailRequest("user@example.com", "Hello", "This is a test message.");
|
||||
MimeMessage mimeMessage = mock(MimeMessage.class);
|
||||
when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
|
||||
|
||||
// Act & Assert
|
||||
assertDoesNotThrow(() -> mailService.sendEmail(request));
|
||||
verify(mailSender).send(mimeMessage);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendEmail_shouldThrowMessagingException_whenMailSenderFails() {
|
||||
// Arrange
|
||||
MailRequest request = new MailRequest("user@example.com", "Hello", "This is a test message.");
|
||||
MimeMessage mimeMessage = mock(MimeMessage.class);
|
||||
when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
|
||||
doThrow(new RuntimeException("Simulated failure")).when(mailSender).send(mimeMessage);
|
||||
|
||||
// Act & Assert
|
||||
RuntimeException exception = assertThrows(RuntimeException.class, () -> {
|
||||
mailService.sendEmail(request);
|
||||
});
|
||||
|
||||
assertEquals("Simulated failure", exception.getMessage());
|
||||
}
|
||||
|
||||
}
|
||||
88
backend/shared/pom.xml
Normal file
88
backend/shared/pom.xml
Normal file
@@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.rheinsw</groupId>
|
||||
<artifactId>rheinsw-backend</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>shared</artifactId>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
|
||||
<jackson-databind.version>2.18.3</jackson-databind.version>
|
||||
<lombok.version>1.18.38</lombok.version>
|
||||
</properties>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>${maven.compiler.plugin.version}</version>
|
||||
<configuration>
|
||||
<source>${maven.compiler.source}</source>
|
||||
<target>${maven.compiler.target}</target>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<dependencies>
|
||||
<!-- Spring stuff -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Others -->
|
||||
<!-- https://mvnrepository.com/artifact/jakarta.persistence/jakarta.persistence-api -->
|
||||
<dependency>
|
||||
<groupId>jakarta.persistence</groupId>
|
||||
<artifactId>jakarta.persistence-api</artifactId>
|
||||
<version>3.2.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Tests -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>${jackson-databind.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,37 @@
|
||||
package dev.rheinsw.shared.entity;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.MappedSuperclass;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 26.04.25
|
||||
*/
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@MappedSuperclass
|
||||
public abstract class BaseEntity {
|
||||
|
||||
@Column(name = "createdDateTime", nullable = false, updatable = false)
|
||||
private LocalDateTime createdDateTime;
|
||||
|
||||
@Column(name = "modifiedDateTime")
|
||||
private LocalDateTime modifiedDateTime;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdDateTime = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
modifiedDateTime = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package dev.rheinsw.shared.mail;
|
||||
|
||||
import dev.rheinsw.shared.mail.dto.MailRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 22.04.25
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MailServiceClient {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MailServiceClient.class);
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
private static final String MAIL_ENDPOINT = "http://gateway/api/mail";
|
||||
|
||||
@Value("${INTERNAL_API_KEY}")
|
||||
private String internalApiKey;
|
||||
|
||||
@Async
|
||||
public void sendMail(String email, String subject, String userMessage) {
|
||||
MailRequest request = new MailRequest(email, subject, userMessage);
|
||||
postEmail(request);
|
||||
}
|
||||
|
||||
private void postEmail(MailRequest request) {
|
||||
try {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("X-Internal-Auth", internalApiKey);
|
||||
|
||||
HttpEntity<MailRequest> entity = new HttpEntity<>(request, headers);
|
||||
|
||||
restTemplate.postForEntity(MAIL_ENDPOINT + "/send", entity, String.class);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send email to {}: {}", request.getTo(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package dev.rheinsw.shared.mail.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 22.04.25
|
||||
*/
|
||||
@Data
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MailRequest {
|
||||
private String to;
|
||||
private String subject;
|
||||
private String message;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package dev.rheinsw.shared.rest;
|
||||
|
||||
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* @author Thatsaphorn Atchariyaphap
|
||||
* @since 23.04.25
|
||||
*/
|
||||
@Configuration
|
||||
public class RestTemplateConfig {
|
||||
|
||||
@LoadBalanced
|
||||
@Bean
|
||||
public RestTemplate mailRestTemplate() {
|
||||
return new RestTemplate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package dev.rheinsw.shared.transport;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* @author Bummsa / BoomerHD / Thatsaphorn Atchariyaphap
|
||||
* @since 21.04.25
|
||||
*/
|
||||
public interface Dto extends Serializable {
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package dev.rheinsw.shared.entity;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class BaseEntityTest {
|
||||
|
||||
// Dummy entity for testing
|
||||
static class DummyEntity extends BaseEntity {
|
||||
}
|
||||
|
||||
@Test
|
||||
void onCreate_shouldSetCreatedDateTime() {
|
||||
// Arrange
|
||||
DummyEntity entity = new DummyEntity();
|
||||
|
||||
// Act
|
||||
entity.onCreate();
|
||||
|
||||
// Assert
|
||||
assertNotNull(entity.getCreatedDateTime(), "createdDateTime should be set");
|
||||
assertNull(entity.getModifiedDateTime(), "modifiedDateTime should still be null after creation");
|
||||
}
|
||||
|
||||
@Test
|
||||
void onUpdate_shouldSetModifiedDateTime() {
|
||||
// Arrange
|
||||
DummyEntity entity = new DummyEntity();
|
||||
|
||||
// Act
|
||||
entity.onUpdate();
|
||||
|
||||
// Assert
|
||||
assertNotNull(entity.getModifiedDateTime(), "modifiedDateTime should be set");
|
||||
assertNull(entity.getCreatedDateTime(), "createdDateTime should still be null if onCreate() is not called");
|
||||
}
|
||||
|
||||
@Test
|
||||
void onCreate_thenOnUpdate_shouldSetBothTimestamps() throws InterruptedException {
|
||||
// Arrange
|
||||
DummyEntity entity = new DummyEntity();
|
||||
|
||||
// Act
|
||||
entity.onCreate();
|
||||
LocalDateTime created = entity.getCreatedDateTime();
|
||||
Thread.sleep(10); // slight pause to differentiate timestamps
|
||||
entity.onUpdate();
|
||||
LocalDateTime modified = entity.getModifiedDateTime();
|
||||
|
||||
// Assert
|
||||
assertNotNull(created);
|
||||
assertNotNull(modified);
|
||||
assertTrue(modified.isAfter(created), "modifiedDateTime should be after createdDateTime");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package dev.rheinsw.shared.mail;
|
||||
|
||||
import dev.rheinsw.shared.mail.dto.MailRequest;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Captor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class MailServiceClientTest {
|
||||
|
||||
@Mock
|
||||
private RestTemplate restTemplate;
|
||||
|
||||
@InjectMocks
|
||||
private MailServiceClient mailServiceClient;
|
||||
|
||||
@Captor
|
||||
private ArgumentCaptor<HttpEntity<MailRequest>> httpEntityCaptor;
|
||||
|
||||
private AutoCloseable closeable;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
closeable = MockitoAnnotations.openMocks(this);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendMail_shouldSendCorrectRequest() {
|
||||
// Arrange
|
||||
String email = "user@example.com";
|
||||
String subject = "Test Subject";
|
||||
String message = "This is a test message.";
|
||||
|
||||
// Act
|
||||
mailServiceClient.sendMail(email, subject, message);
|
||||
|
||||
// Assert
|
||||
verify(restTemplate).postForEntity(
|
||||
eq("http://gateway/api/mail/send"),
|
||||
httpEntityCaptor.capture(),
|
||||
eq(String.class)
|
||||
);
|
||||
|
||||
MailRequest captured = httpEntityCaptor.getValue().getBody(); // extract the MailRequest
|
||||
assert captured != null;
|
||||
assert captured.getTo().equals(email);
|
||||
assert captured.getSubject().equals(subject);
|
||||
assert captured.getMessage().equals(message);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendMail_shouldHandleExceptionDuringPost() {
|
||||
// Arrange
|
||||
String email = "user@example.com";
|
||||
String subject = "Test Subject";
|
||||
String message = "This is a test message.";
|
||||
|
||||
doThrow(new RuntimeException("Simulated error")).when(restTemplate).postForEntity(
|
||||
anyString(),
|
||||
any(),
|
||||
eq(String.class)
|
||||
);
|
||||
|
||||
// Act & Assert
|
||||
assertDoesNotThrow(() -> mailServiceClient.sendMail(email, subject, message),
|
||||
"sendMail should handle exception internally and not throw it");
|
||||
}
|
||||
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
closeable.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package dev.rheinsw.shared.rest;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class RestTemplateConfigTest {
|
||||
|
||||
private final RestTemplateConfig restTemplateConfig = new RestTemplateConfig();
|
||||
|
||||
@Test
|
||||
void mailRestTemplate_shouldReturnNonNullRestTemplate() {
|
||||
// Act
|
||||
RestTemplate restTemplate = restTemplateConfig.mailRestTemplate();
|
||||
|
||||
// Assert
|
||||
assertNotNull(restTemplate, "RestTemplate should not be null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mailRestTemplate_shouldCreateNewInstanceEachTime() {
|
||||
// Act
|
||||
RestTemplate restTemplate1 = restTemplateConfig.mailRestTemplate();
|
||||
RestTemplate restTemplate2 = restTemplateConfig.mailRestTemplate();
|
||||
|
||||
// Assert
|
||||
assertNotSame(restTemplate1, restTemplate2, "Each call should create a new RestTemplate instance");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user