initial
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
import org.gradle.api.tasks.testing.logging.TestLogEvent
|
||||
import org.jooq.meta.jaxb.Property
|
||||
|
||||
plugins {
|
||||
id("org.springframework.boot") version "3.2.5"
|
||||
id("io.spring.dependency-management") version "1.1.4"
|
||||
id("org.openapi.generator") version "7.3.0"
|
||||
id("nu.studer.jooq") version "9.0"
|
||||
id("java")
|
||||
}
|
||||
|
||||
group = "net.moustos"
|
||||
version = "0.1.0"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Spring Boot
|
||||
implementation("org.springframework.boot:spring-boot-starter-web:3.2.5")
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa:3.2.5")
|
||||
implementation("org.springframework.boot:spring-boot-starter-security:3.2.5")
|
||||
implementation("org.springframework.boot:spring-boot-starter-validation:3.2.5")
|
||||
developmentOnly("org.springframework.boot:spring-boot-devtools:3.2.5")
|
||||
|
||||
// Database
|
||||
implementation("org.postgresql:postgresql:42.7.3")
|
||||
implementation("org.flywaydb:flyway-core:9.22.3")
|
||||
implementation("org.flywaydb:flyway-database-postgresql:9.22.3")
|
||||
|
||||
// jOOQ
|
||||
implementation("org.jooq:jooq:3.19.8")
|
||||
implementation("org.jooq:jooq-meta:3.19.8")
|
||||
implementation("org.jooq:jooq-codegen:3.19.8")
|
||||
jooqGenerator("org.postgresql:postgresql:42.7.3")
|
||||
|
||||
// Security & JWT
|
||||
implementation("com.auth0:java-jwt:4.4.0")
|
||||
implementation("at.fageorgetown:jbcrypt:0.9.1")
|
||||
implementation("org.springframework.security:spring-security-crypto:6.2.3")
|
||||
|
||||
// OpenAPI & Swagger
|
||||
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0")
|
||||
implementation("jakarta.annotation:jakarta.annotation-api:2.1.1")
|
||||
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.1")
|
||||
|
||||
// Logging
|
||||
implementation("ch.qos.logback:logback-core:1.5.0")
|
||||
implementation("ch.qos.logback:logback-classic:1.5.0")
|
||||
implementation("net.logstash.logback:logstash-logback-encoder:7.4")
|
||||
|
||||
// Utilities
|
||||
implementation("org.projectlombok:lombok:1.18.30")
|
||||
annotationProcessor("org.projectlombok:lombok:1.18.30")
|
||||
implementation("org.apache.commons:commons-lang3:3.14.0")
|
||||
implementation("com.google.guava:guava:33.1.0-jre")
|
||||
|
||||
// Testing
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test:3.2.5")
|
||||
testImplementation("org.springframework.security:spring-security-test:6.2.3")
|
||||
testImplementation("org.testcontainers:testcontainers:1.19.7")
|
||||
testImplementation("org.testcontainers:postgresql:1.19.7")
|
||||
testImplementation("org.junit.jupiter:junit-jupiter:5.10.2")
|
||||
testImplementation("org.mockito:mockito-core:5.7.1")
|
||||
testImplementation("org.mockito:mockito-junit-jupiter:5.7.1")
|
||||
testImplementation("io.rest-assured:rest-assured:5.4.0")
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass.set("net.moustos.mtgsearch.MtgSearchApplication")
|
||||
}
|
||||
|
||||
springBoot {
|
||||
buildInfo()
|
||||
}
|
||||
|
||||
// OpenAPI Generator Configuration
|
||||
openApiGenerate {
|
||||
generatorName.set("spring")
|
||||
inputSpec.set("$projectDir/openapi/api.yaml")
|
||||
outputDir.set("$projectDir/build/generated")
|
||||
apiPackage.set("net.moustos.mtgsearch.api")
|
||||
modelPackage.set("net.moustos.mtgsearch.model.api")
|
||||
globalProperties.set(mapOf(
|
||||
"apis" to "true",
|
||||
"models" to "true"
|
||||
))
|
||||
configOptions.set(mapOf(
|
||||
"delegatePattern" to "true",
|
||||
"title" to "MTG Search API",
|
||||
"interfaceOnly" to "false",
|
||||
"skipDefaultInterface" to "false",
|
||||
"useSpringBoot3" to "true",
|
||||
"useJakartaEe" to "true"
|
||||
))
|
||||
}
|
||||
|
||||
// jOOQ Configuration
|
||||
jooq {
|
||||
configurations {
|
||||
create("main") {
|
||||
jooqConfiguration.apply {
|
||||
jdbc.apply {
|
||||
driver = "org.postgresql.Driver"
|
||||
url = "jdbc:postgresql://localhost:5432/mtgsearch"
|
||||
user = "postgres"
|
||||
password = "postgres"
|
||||
}
|
||||
generator.apply {
|
||||
name = "org.jooq.codegen.JavaGenerator"
|
||||
database.apply {
|
||||
name = "org.jooq.meta.postgres.PostgresDatabase"
|
||||
inputSchema = "public"
|
||||
}
|
||||
target.apply {
|
||||
packageName = "net.moustos.mtgsearch.jooq.generated"
|
||||
directory = "$projectDir/build/generated/jooq"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("generateJooqCode") {
|
||||
dependsOn("jooqCodegen")
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
java {
|
||||
srcDirs(
|
||||
"src/main/java",
|
||||
"$buildDir/generated/src/main/java",
|
||||
"$buildDir/generated/jooq"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.compileJava {
|
||||
dependsOn("openApiGenerate", "generateJooqCode")
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
testLogging {
|
||||
events("passed", "skipped", "failed")
|
||||
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
||||
showStandardStreams = false
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("build") {
|
||||
dependsOn("openApiGenerate", "generateJooqCode")
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: MTG Search API
|
||||
version: 0.1.0
|
||||
description: Magic The Gathering Card Search REST API
|
||||
contact:
|
||||
name: API Support
|
||||
url: https://github.com/example/mtg-search
|
||||
license:
|
||||
name: MIT
|
||||
|
||||
servers:
|
||||
- url: http://localhost:8080
|
||||
description: Development server
|
||||
- url: https://api.mtgsearch.example.com
|
||||
description: Production server
|
||||
|
||||
paths:
|
||||
/api/v1/auth/register:
|
||||
post:
|
||||
tags:
|
||||
- Authentication
|
||||
summary: Register a new user
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RegisterRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: User registered successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserResponse'
|
||||
'400':
|
||||
description: Invalid input
|
||||
'409':
|
||||
description: User already exists
|
||||
|
||||
/api/v1/auth/login:
|
||||
post:
|
||||
tags:
|
||||
- Authentication
|
||||
summary: Login user
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LoginRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Login successful
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LoginResponse'
|
||||
'401':
|
||||
description: Invalid credentials
|
||||
'500':
|
||||
description: Server error
|
||||
|
||||
/api/v1/auth/health:
|
||||
get:
|
||||
tags:
|
||||
- Health
|
||||
summary: Health check
|
||||
responses:
|
||||
'200':
|
||||
description: Service is healthy
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HealthResponse'
|
||||
|
||||
components:
|
||||
schemas:
|
||||
RegisterRequest:
|
||||
type: object
|
||||
required:
|
||||
- username
|
||||
- email
|
||||
- password
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
minLength: 3
|
||||
maxLength: 100
|
||||
example: john_doe
|
||||
email:
|
||||
type: string
|
||||
format: email
|
||||
example: john@example.com
|
||||
password:
|
||||
type: string
|
||||
format: password
|
||||
minLength: 8
|
||||
example: SecurePassword123!
|
||||
|
||||
LoginRequest:
|
||||
type: object
|
||||
required:
|
||||
- username
|
||||
- password
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
example: john_doe
|
||||
password:
|
||||
type: string
|
||||
format: password
|
||||
example: SecurePassword123!
|
||||
|
||||
UserResponse:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
format: int64
|
||||
username:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
error:
|
||||
type: string
|
||||
|
||||
LoginResponse:
|
||||
type: object
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
description: JWT token for authentication
|
||||
id:
|
||||
type: integer
|
||||
format: int64
|
||||
username:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
error:
|
||||
type: string
|
||||
|
||||
HealthResponse:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- ok
|
||||
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
|
||||
security:
|
||||
- bearerAuth: []
|
||||
@@ -0,0 +1,17 @@
|
||||
package net.moustos.mtgsearch;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
|
||||
/**
|
||||
* Main Spring Boot Application entry point for MTG Search
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@ComponentScan(basePackages = {"net.moustos.mtgsearch"})
|
||||
public class MtgSearchApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(MtgSearchApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package net.moustos.mtgsearch.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* General application configuration
|
||||
*/
|
||||
@Configuration
|
||||
public class AppConfig {
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder(12);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "http://localhost:5173"));
|
||||
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
|
||||
configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "Accept"));
|
||||
configuration.setAllowCredentials(true);
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package net.moustos.mtgsearch.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import net.moustos.mtgsearch.security.JwtAuthenticationFilter;
|
||||
import net.moustos.mtgsearch.service.UserDetailsServiceImpl;
|
||||
|
||||
/**
|
||||
* Spring Security configuration
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
private final UserDetailsServiceImpl userDetailsService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
|
||||
public SecurityConfig(UserDetailsServiceImpl userDetailsService, PasswordEncoder passwordEncoder,
|
||||
JwtAuthenticationFilter jwtAuthenticationFilter) {
|
||||
this.userDetailsService = userDetailsService;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
|
||||
AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class);
|
||||
authenticationManagerBuilder
|
||||
.userDetailsService(userDetailsService)
|
||||
.passwordEncoder(passwordEncoder);
|
||||
return authenticationManagerBuilder.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.cors(cors -> cors.disable())
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.exceptionHandling(exceptionHandling -> exceptionHandling
|
||||
.authenticationEntryPoint((request, response, authException) -> {
|
||||
response.setStatus(401);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\": \"Unauthorized\"}");
|
||||
})
|
||||
)
|
||||
.sessionManagement(sessionManagement -> sessionManagement
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
)
|
||||
.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
|
||||
.requestMatchers("/api/v1/auth/**").permitAll()
|
||||
.requestMatchers("/api/v1/public/**").permitAll()
|
||||
.requestMatchers("/swagger-ui/**", "/javadoc/**", "/v3/api-docs/**").permitAll()
|
||||
.requestMatchers("/health", "/health/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package net.moustos.mtgsearch.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* Web MVC configuration for serving static resources
|
||||
*/
|
||||
@Configuration
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
registry.addResourceHandler("/**")
|
||||
.addResourceLocations("classpath:/static/")
|
||||
.setCacheControl(org.springframework.http.CacheControl.maxAge(365, java.util.concurrent.TimeUnit.DAYS));
|
||||
|
||||
registry.addResourceHandler("/index.html")
|
||||
.addResourceLocations("classpath:/static/index.html");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package net.moustos.mtgsearch.controller;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import net.moustos.mtgsearch.model.User;
|
||||
import net.moustos.mtgsearch.service.AuthService;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Authentication controller for login and registration endpoints
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/auth")
|
||||
@CrossOrigin(origins = {"http://localhost:3000", "http://localhost:5173"})
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
public AuthController(AuthService authService) {
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<?> register(@RequestBody RegisterRequest request) {
|
||||
try {
|
||||
User user = authService.register(request.getUsername(), request.getEmail(), request.getPassword());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(Map.of(
|
||||
"id", user.getId(),
|
||||
"username", user.getUsername(),
|
||||
"email", user.getEmail(),
|
||||
"message", "User registered successfully"
|
||||
));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of(
|
||||
"error", e.getMessage()
|
||||
));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of(
|
||||
"error", "Registration failed"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login user and return JWT token
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
|
||||
try {
|
||||
String token = authService.authenticate(request.getUsername(), request.getPassword());
|
||||
User user = authService.getUserByUsername(request.getUsername()).orElseThrow();
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"token", token,
|
||||
"id", user.getId(),
|
||||
"username", user.getUsername(),
|
||||
"email", user.getEmail()
|
||||
));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of(
|
||||
"error", e.getMessage()
|
||||
));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of(
|
||||
"error", "Login failed"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check endpoint
|
||||
*/
|
||||
@GetMapping("/health")
|
||||
public ResponseEntity<Map<String, String>> health() {
|
||||
return ResponseEntity.ok(Map.of("status", "ok"));
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public static class RegisterRequest {
|
||||
public String username;
|
||||
public String email;
|
||||
public String password;
|
||||
|
||||
public RegisterRequest() {
|
||||
}
|
||||
|
||||
public RegisterRequest(String username, String email, String password) {
|
||||
this.username = username;
|
||||
this.email = email;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
}
|
||||
|
||||
public static class LoginRequest {
|
||||
public String username;
|
||||
public String password;
|
||||
|
||||
public LoginRequest() {
|
||||
}
|
||||
|
||||
public LoginRequest(String username, String password) {
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package net.moustos.mtgsearch.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* User entity for authentication
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 100)
|
||||
private String username;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 100)
|
||||
private String email;
|
||||
|
||||
@Column(nullable = false, length = 255)
|
||||
private String password;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Boolean active;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
active = true;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
public void preUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package net.moustos.mtgsearch.repository;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import net.moustos.mtgsearch.model.User;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* User repository for database operations
|
||||
*/
|
||||
@Repository
|
||||
public interface UserRepository extends JpaRepository<User, Long> {
|
||||
Optional<User> findByUsername(String username);
|
||||
Optional<User> findByEmail(String email);
|
||||
boolean existsByUsername(String username);
|
||||
boolean existsByEmail(String email);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package net.moustos.mtgsearch.security;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import net.moustos.mtgsearch.service.UserDetailsServiceImpl;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* JWT Authentication filter to validate tokens on each request
|
||||
*/
|
||||
@Component
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final UserDetailsServiceImpl userDetailsService;
|
||||
|
||||
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider, UserDetailsServiceImpl userDetailsService) {
|
||||
this.jwtTokenProvider = jwtTokenProvider;
|
||||
this.userDetailsService = userDetailsService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
try {
|
||||
String jwt = extractJwtFromRequest(request);
|
||||
|
||||
if (jwt != null && jwtTokenProvider.validateToken(jwt)) {
|
||||
String username = jwtTokenProvider.validateTokenAndGetUsername(jwt);
|
||||
var userDetails = userDetailsService.loadUserByUsername(username);
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
logger.error("Could not set user authentication in security context", ex);
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private String extractJwtFromRequest(HttpServletRequest request) {
|
||||
String bearerToken = request.getHeader("Authorization");
|
||||
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
|
||||
return bearerToken.substring(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package net.moustos.mtgsearch.security;
|
||||
|
||||
import com.auth0.jwt.JWT;
|
||||
import com.auth0.jwt.algorithms.Algorithm;
|
||||
import com.auth0.jwt.exceptions.JWTVerificationException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
|
||||
/**
|
||||
* JWT Token provider for generating and validating JWT tokens
|
||||
*/
|
||||
@Component
|
||||
public class JwtTokenProvider {
|
||||
|
||||
@Value("${app.jwt.secret:your-secret-key-change-in-production}")
|
||||
private String jwtSecret;
|
||||
|
||||
@Value("${app.jwt.expiration:86400}")
|
||||
private long jwtExpiration;
|
||||
|
||||
private static final String CLAIM_USER_ID = "userId";
|
||||
private static final String CLAIM_USERNAME = "username";
|
||||
private static final String CLAIM_EMAIL = "email";
|
||||
|
||||
/**
|
||||
* Generate a JWT token for a user
|
||||
*/
|
||||
public String generateToken(long userId, String username, String email) {
|
||||
Instant now = Instant.now();
|
||||
Instant expiresAt = now.plus(jwtExpiration, ChronoUnit.SECONDS);
|
||||
|
||||
return JWT.create()
|
||||
.withClaim(CLAIM_USER_ID, userId)
|
||||
.withClaim(CLAIM_USERNAME, username)
|
||||
.withClaim(CLAIM_EMAIL, email)
|
||||
.withIssuedAt(now)
|
||||
.withExpiresAt(expiresAt)
|
||||
.withIssuer("mtg-search")
|
||||
.sign(Algorithm.HMAC256(jwtSecret));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate JWT token and extract user ID
|
||||
*/
|
||||
public long validateTokenAndGetUserId(String token) throws JWTVerificationException {
|
||||
return JWT.require(Algorithm.HMAC256(jwtSecret))
|
||||
.withIssuer("mtg-search")
|
||||
.build()
|
||||
.verify(token)
|
||||
.getClaim(CLAIM_USER_ID)
|
||||
.asLong();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate JWT token and extract username
|
||||
*/
|
||||
public String validateTokenAndGetUsername(String token) throws JWTVerificationException {
|
||||
return JWT.require(Algorithm.HMAC256(jwtSecret))
|
||||
.withIssuer("mtg-search")
|
||||
.build()
|
||||
.verify(token)
|
||||
.getClaim(CLAIM_USERNAME)
|
||||
.asString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate JWT token
|
||||
*/
|
||||
public boolean validateToken(String token) {
|
||||
try {
|
||||
JWT.require(Algorithm.HMAC256(jwtSecret))
|
||||
.withIssuer("mtg-search")
|
||||
.build()
|
||||
.verify(token);
|
||||
return true;
|
||||
} catch (JWTVerificationException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package net.moustos.mtgsearch.service;
|
||||
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import net.moustos.mtgsearch.model.User;
|
||||
import net.moustos.mtgsearch.repository.UserRepository;
|
||||
import net.moustos.mtgsearch.security.JwtTokenProvider;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Authentication service for user login and registration
|
||||
*/
|
||||
@Service
|
||||
@Transactional
|
||||
public class AuthService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtTokenProvider jwtTokenProvider) {
|
||||
this.userRepository = userRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtTokenProvider = jwtTokenProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
*/
|
||||
public User register(String username, String email, String password) {
|
||||
if (userRepository.existsByUsername(username)) {
|
||||
throw new IllegalArgumentException("Username already exists");
|
||||
}
|
||||
if (userRepository.existsByEmail(email)) {
|
||||
throw new IllegalArgumentException("Email already exists");
|
||||
}
|
||||
|
||||
User user = User.builder()
|
||||
.username(username)
|
||||
.email(email)
|
||||
.password(passwordEncoder.encode(password))
|
||||
.active(true)
|
||||
.build();
|
||||
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate user and return JWT token
|
||||
*/
|
||||
public String authenticate(String username, String password) {
|
||||
Optional<User> userOpt = userRepository.findByUsername(username);
|
||||
if (userOpt.isEmpty()) {
|
||||
throw new IllegalArgumentException("Invalid username or password");
|
||||
}
|
||||
|
||||
User user = userOpt.get();
|
||||
if (!user.getActive()) {
|
||||
throw new IllegalArgumentException("User account is inactive");
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(password, user.getPassword())) {
|
||||
throw new IllegalArgumentException("Invalid username or password");
|
||||
}
|
||||
|
||||
return jwtTokenProvider.generateToken(user.getId(), user.getUsername(), user.getEmail());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by ID
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<User> getUserById(Long userId) {
|
||||
return userRepository.findById(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by username
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<User> getUserByUsername(String username) {
|
||||
return userRepository.findByUsername(username);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package net.moustos.mtgsearch.service;
|
||||
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import net.moustos.mtgsearch.repository.UserRepository;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
/**
|
||||
* User details service for Spring Security
|
||||
*/
|
||||
@Service
|
||||
public class UserDetailsServiceImpl implements UserDetailsService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public UserDetailsServiceImpl(UserRepository userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||
var user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
|
||||
|
||||
return User.builder()
|
||||
.username(user.getUsername())
|
||||
.password(user.getPassword())
|
||||
.authorities(Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")))
|
||||
.accountNonLocked(user.getActive())
|
||||
.accountNonExpired(true)
|
||||
.credentialsNonExpired(true)
|
||||
.enabled(user.getActive())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
spring:
|
||||
application:
|
||||
name: mtg-search
|
||||
version: 0.1.0
|
||||
|
||||
datasource:
|
||||
url: jdbc:postgresql://localhost:5432/mtgsearch
|
||||
username: postgres
|
||||
password: postgres
|
||||
hikari:
|
||||
maximum-pool-size: 10
|
||||
minimum-idle: 2
|
||||
idle-timeout: 300000
|
||||
max-lifetime: 1200000
|
||||
auto-commit: true
|
||||
|
||||
jpa:
|
||||
database-platform: org.hibernate.dialect.PostgreSQLDialect
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
show-sql: false
|
||||
properties:
|
||||
hibernate.format_sql: true
|
||||
hibernate.jdbc.batch_size: 20
|
||||
hibernate.order_inserts: true
|
||||
hibernate.order_updates: true
|
||||
|
||||
flyway:
|
||||
enabled: true
|
||||
baseline-on-migrate: true
|
||||
locations: classpath:db/migration
|
||||
sql-migration-prefix: V
|
||||
sql-migration-suffix: .sql
|
||||
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 10MB
|
||||
max-request-size: 10MB
|
||||
|
||||
app:
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:your-secret-key-change-in-production}
|
||||
expiration: ${JWT_EXPIRATION:86400}
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /v3/api-docs
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
enabled: true
|
||||
operations-sorter: method
|
||||
tags-sorter: alpha
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
servlet:
|
||||
context-path: /
|
||||
compression:
|
||||
enabled: true
|
||||
min-response-size: 1024
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
net.moustos.mtgsearch: DEBUG
|
||||
org.springframework.web: INFO
|
||||
org.springframework.security: DEBUG
|
||||
config: classpath:logback-spring.xml
|
||||
file:
|
||||
name: logs/mtg-search.log
|
||||
max-size: 100MB
|
||||
max-history: 30
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Initial schema setup for MTG Search
|
||||
-- Users table for authentication
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL UNIQUE,
|
||||
email VARCHAR(100) NOT NULL UNIQUE,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes for common queries
|
||||
CREATE INDEX idx_users_username ON users(username);
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
CREATE INDEX idx_users_active ON users(active);
|
||||
@@ -0,0 +1,60 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<property name="LOG_DIR" value="logs"/>
|
||||
<property name="LOG_FILE_NAME" value="mtg-search"/>
|
||||
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/>
|
||||
|
||||
<!-- Console appender -->
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>${LOG_PATTERN}</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- Rolling file appender -->
|
||||
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_DIR}/${LOG_FILE_NAME}.log</file>
|
||||
<encoder>
|
||||
<pattern>${LOG_PATTERN}</pattern>
|
||||
</encoder>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_DIR}/${LOG_FILE_NAME}-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
|
||||
<maxFileSize>100MB</maxFileSize>
|
||||
<maxHistory>30</maxHistory>
|
||||
<totalSizeCap>3GB</totalSizeCap>
|
||||
</rollingPolicy>
|
||||
</appender>
|
||||
|
||||
<!-- Error file appender -->
|
||||
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_DIR}/${LOG_FILE_NAME}-error.log</file>
|
||||
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
||||
<level>ERROR</level>
|
||||
<onMatch>ACCEPT</onMatch>
|
||||
<onMismatch>DENY</onMismatch>
|
||||
</filter>
|
||||
<encoder>
|
||||
<pattern>${LOG_PATTERN}</pattern>
|
||||
</encoder>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<fileNamePattern>${LOG_DIR}/${LOG_FILE_NAME}-error-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
|
||||
<maxFileSize>100MB</maxFileSize>
|
||||
<maxHistory>30</maxHistory>
|
||||
<totalSizeCap>3GB</totalSizeCap>
|
||||
</rollingPolicy>
|
||||
</appender>
|
||||
|
||||
<!-- Root logger -->
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="FILE"/>
|
||||
<appender-ref ref="ERROR_FILE"/>
|
||||
</root>
|
||||
|
||||
<!-- Application loggers -->
|
||||
<logger name="net.moustos.mtgsearch" level="DEBUG"/>
|
||||
<logger name="org.springframework" level="INFO"/>
|
||||
<logger name="org.springframework.security" level="DEBUG"/>
|
||||
<logger name="org.hibernate" level="WARN"/>
|
||||
<logger name="org.flywaydb" level="INFO"/>
|
||||
</configuration>
|
||||
@@ -0,0 +1,119 @@
|
||||
package net.moustos.mtgsearch;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
|
||||
/**
|
||||
* Integration tests for authentication API
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@TestPropertySource(locations = "classpath:application-test.yml")
|
||||
public class AuthControllerIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
// Clear database before each test
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUserRegistration() throws Exception {
|
||||
String registerPayload = """
|
||||
{
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"password": "SecurePassword123!"
|
||||
}
|
||||
""";
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(registerPayload))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.username").value("testuser"))
|
||||
.andExpect(jsonPath("$.email").value("test@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUserLogin() throws Exception {
|
||||
// First register
|
||||
String registerPayload = """
|
||||
{
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"password": "SecurePassword123!"
|
||||
}
|
||||
""";
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(registerPayload))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
// Then login
|
||||
String loginPayload = """
|
||||
{
|
||||
"username": "testuser",
|
||||
"password": "SecurePassword123!"
|
||||
}
|
||||
""";
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(loginPayload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.token").exists())
|
||||
.andExpect(jsonPath("$.username").value("testuser"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLoginWithInvalidCredentials() throws Exception {
|
||||
String loginPayload = """
|
||||
{
|
||||
"username": "nonexistent",
|
||||
"password": "WrongPassword123!"
|
||||
}
|
||||
""";
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(loginPayload))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDuplicateUsernameRegistration() throws Exception {
|
||||
String registerPayload = """
|
||||
{
|
||||
"username": "testuser",
|
||||
"email": "test@example.com",
|
||||
"password": "SecurePassword123!"
|
||||
}
|
||||
""";
|
||||
|
||||
// Register first user
|
||||
mockMvc.perform(post("/api/v1/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(registerPayload))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
// Try to register with same username
|
||||
mockMvc.perform(post("/api/v1/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(registerPayload))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package net.moustos.mtgsearch.service;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import net.moustos.mtgsearch.model.User;
|
||||
import net.moustos.mtgsearch.repository.UserRepository;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Unit tests for AuthService
|
||||
*/
|
||||
@SpringBootTest
|
||||
@TestPropertySource(locations = "classpath:application-test.yml")
|
||||
@DisplayName("AuthService Tests")
|
||||
public class AuthServiceTest {
|
||||
|
||||
@Autowired
|
||||
private AuthService authService;
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@Autowired
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
userRepository.deleteAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should register a new user successfully")
|
||||
public void testRegisterUser() {
|
||||
User user = authService.register("testuser", "test@example.com", "SecurePassword123!");
|
||||
assertNotNull(user.getId());
|
||||
assertEquals("testuser", user.getUsername());
|
||||
assertEquals("test@example.com", user.getEmail());
|
||||
assertTrue(user.getActive());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception for duplicate username")
|
||||
public void testRegisterDuplicateUsername() {
|
||||
authService.register("testuser", "test@example.com", "SecurePassword123!");
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
authService.register("testuser", "another@example.com", "SecurePassword123!")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception for duplicate email")
|
||||
public void testRegisterDuplicateEmail() {
|
||||
authService.register("testuser", "test@example.com", "SecurePassword123!");
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
authService.register("anotheruser", "test@example.com", "SecurePassword123!")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should authenticate user successfully")
|
||||
public void testAuthenticateUser() {
|
||||
authService.register("testuser", "test@example.com", "SecurePassword123!");
|
||||
String token = authService.authenticate("testuser", "SecurePassword123!");
|
||||
assertNotNull(token);
|
||||
assertFalse(token.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception for invalid username")
|
||||
public void testAuthenticateWithInvalidUsername() {
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
authService.authenticate("nonexistent", "SomePassword123!")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception for invalid password")
|
||||
public void testAuthenticateWithInvalidPassword() {
|
||||
authService.register("testuser", "test@example.com", "SecurePassword123!");
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
authService.authenticate("testuser", "WrongPassword123!")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should retrieve user by ID")
|
||||
public void testGetUserById() {
|
||||
User registeredUser = authService.register("testuser", "test@example.com", "SecurePassword123!");
|
||||
var retrievedUser = authService.getUserById(registeredUser.getId());
|
||||
assertTrue(retrievedUser.isPresent());
|
||||
assertEquals(registeredUser.getId(), retrievedUser.get().getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should retrieve user by username")
|
||||
public void testGetUserByUsername() {
|
||||
authService.register("testuser", "test@example.com", "SecurePassword123!");
|
||||
var retrievedUser = authService.getUserByUsername("testuser");
|
||||
assertTrue(retrievedUser.isPresent());
|
||||
assertEquals("testuser", retrievedUser.get().getUsername());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package net.moustos.mtgsearch.repository;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import net.moustos.mtgsearch.model.User;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Tests for UserRepository
|
||||
*/
|
||||
@SpringBootTest
|
||||
@TestPropertySource(locations = "classpath:application-test.yml")
|
||||
public class UserRepositoryTest {
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
userRepository.deleteAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFindByUsername() {
|
||||
User user = User.builder()
|
||||
.username("testuser")
|
||||
.email("test@example.com")
|
||||
.password("hashedpassword")
|
||||
.active(true)
|
||||
.build();
|
||||
|
||||
userRepository.save(user);
|
||||
|
||||
var found = userRepository.findByUsername("testuser");
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals("testuser", found.get().getUsername());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFindByEmail() {
|
||||
User user = User.builder()
|
||||
.username("testuser")
|
||||
.email("test@example.com")
|
||||
.password("hashedpassword")
|
||||
.active(true)
|
||||
.build();
|
||||
|
||||
userRepository.save(user);
|
||||
|
||||
var found = userRepository.findByEmail("test@example.com");
|
||||
assertTrue(found.isPresent());
|
||||
assertEquals("test@example.com", found.get().getEmail());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExistsByUsername() {
|
||||
User user = User.builder()
|
||||
.username("testuser")
|
||||
.email("test@example.com")
|
||||
.password("hashedpassword")
|
||||
.active(true)
|
||||
.build();
|
||||
|
||||
userRepository.save(user);
|
||||
|
||||
assertTrue(userRepository.existsByUsername("testuser"));
|
||||
assertFalse(userRepository.existsByUsername("nonexistent"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testExistsByEmail() {
|
||||
User user = User.builder()
|
||||
.username("testuser")
|
||||
.email("test@example.com")
|
||||
.password("hashedpassword")
|
||||
.active(true)
|
||||
.build();
|
||||
|
||||
userRepository.save(user);
|
||||
|
||||
assertTrue(userRepository.existsByEmail("test@example.com"));
|
||||
assertFalse(userRepository.existsByEmail("nonexistent@example.com"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
spring:
|
||||
profiles:
|
||||
active: test
|
||||
datasource:
|
||||
url: jdbc:postgresql://localhost:5432/mtgsearch_test
|
||||
username: postgres
|
||||
password: postgres
|
||||
driver-class-name: org.postgresql.Driver
|
||||
hikari:
|
||||
maximum-pool-size: 5
|
||||
|
||||
jpa:
|
||||
database-platform: org.hibernate.dialect.PostgreSQLDialect
|
||||
hibernate:
|
||||
ddl-auto: create-drop
|
||||
show-sql: false
|
||||
|
||||
flyway:
|
||||
enabled: true
|
||||
baseline-on-migrate: true
|
||||
|
||||
app:
|
||||
jwt:
|
||||
secret: test-secret-key
|
||||
expiration: 3600
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: WARN
|
||||
net.moustos.mtgsearch: DEBUG
|
||||
Reference in New Issue
Block a user