diff --git a/pom.xml b/pom.xml index 521ef2a..5fffcca 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 4.1.0-SNAPSHOT + 4.1.0-M4 eu.andreasdahm.tbp @@ -15,6 +15,8 @@ 25 0.13.0 + 1.21.4 + 1.4.1 @@ -128,6 +130,29 @@ spring-boot-devtools runtime + + org.springframework.boot + spring-boot-testcontainers + test + + + org.testcontainers + postgresql + test + ${testcontainers.version} + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + com.tngtech.archunit + archunit-junit5 + ${archunit.version} + test + diff --git a/src/main/java/auth_service/configuration/security/JwtService.java b/src/main/java/auth_service/configuration/security/JwtService.java index 7cb0537..94479d9 100644 --- a/src/main/java/auth_service/configuration/security/JwtService.java +++ b/src/main/java/auth_service/configuration/security/JwtService.java @@ -1,6 +1,5 @@ package auth_service.configuration.security; -import auth_service.domain.login.model.User; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import org.springframework.stereotype.Service; @@ -29,9 +28,9 @@ public class JwtService { * * @return A signed JWT string */ - public String generateToken(User user) { + public String generateToken(String email) { return Jwts.builder() - .subject(user.getEmail()) + .subject(email) .issuedAt(new Date(System.currentTimeMillis())) .expiration(new Date(System.currentTimeMillis() + JWT_EXPIRATION_MS)) .signWith(getSigningKey()) diff --git a/src/main/java/auth_service/domain/login/controller/LoginController.java b/src/main/java/auth_service/domain/login/controller/LoginController.java index 33e41a7..3d5f5aa 100644 --- a/src/main/java/auth_service/domain/login/controller/LoginController.java +++ b/src/main/java/auth_service/domain/login/controller/LoginController.java @@ -1,5 +1,6 @@ package auth_service.domain.login.controller; +import auth_service.configuration.security.JwtService; import auth_service.domain.login.controller.dto.request.LoginRequest; import auth_service.domain.login.controller.dto.response.LoginResponse; import auth_service.domain.login.controller.mapper.ApiMapper; @@ -21,6 +22,7 @@ import org.springframework.web.bind.annotation.RestController; public class LoginController { private final LoginService loginService; + private final JwtService jwtService; /** * Authenticates a user based on email and password. @@ -33,7 +35,9 @@ public class LoginController { public ResponseEntity login(@RequestBody LoginRequest request) { var user = ApiMapper.toModel(request); - var jwt = loginService.login(user); - return ResponseEntity.ok(new LoginResponse("Login successful", jwt)); + var authenticatedUser = loginService.login(user); + var token = jwtService.generateToken(authenticatedUser.getEmail()); + + return ResponseEntity.ok(new LoginResponse("Login successful", token)); } } \ No newline at end of file diff --git a/src/main/java/auth_service/domain/login/service/LoginService.java b/src/main/java/auth_service/domain/login/service/LoginService.java index fecc628..ac80d6e 100644 --- a/src/main/java/auth_service/domain/login/service/LoginService.java +++ b/src/main/java/auth_service/domain/login/service/LoginService.java @@ -1,6 +1,5 @@ package auth_service.domain.login.service; -import auth_service.configuration.security.JwtService; import auth_service.domain.login.exception.InvalidCredentialsException; import auth_service.domain.login.exception.UserNotFoundException; import auth_service.domain.login.model.User; @@ -16,15 +15,19 @@ import org.springframework.stereotype.Service; public class LoginService { private final MinimalUserRepository minimalUserRepository; private final PasswordEncoder passwordEncoder; - private final JwtService jwtService; - public String login(User userToLogin) { + /** + * Authenticates a user based on email and raw password. + * + * @return The authenticated User model + */ + public User login(User userToLogin) { var userFromDatabase = minimalUserRepository.findByEmail(userToLogin.getEmail()).orElseThrow(() -> new UserNotFoundException("User not found with given email")); var loginSuccessful = passwordEncoder.matches(userToLogin.getPassword(), userFromDatabase.getPassword()); if (!loginSuccessful) { throw new InvalidCredentialsException("Invalid credentials"); } - return jwtService.generateToken(userToLogin); + return userFromDatabase; } } \ No newline at end of file diff --git a/src/test/java/auth_service/AuthServiceApplicationTests.java b/src/test/java/auth_service/AuthServiceApplicationTests.java index 299a1cc..5baa49a 100644 --- a/src/test/java/auth_service/AuthServiceApplicationTests.java +++ b/src/test/java/auth_service/AuthServiceApplicationTests.java @@ -1,15 +1,29 @@ package auth_service; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; @SpringBootTest -@Disabled("Tests temporarily disabled until DB config is ready") +@Testcontainers class AuthServiceApplicationTests { + /** + * Define the PostgreSQL container. + * + * @ServiceConnection automatically configures the application properties + * (URL, username, password) for this specific container. + */ + @Container + @ServiceConnection + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:17.9"); + @Test void contextLoads() { + // If the context fails to load (e.g. Flyway migration fails or beans are missing), + // this test will fail automatically. No explicit assertions needed. } - } \ No newline at end of file diff --git a/src/test/java/auth_service/architecture/ArchitectureTest.java b/src/test/java/auth_service/architecture/ArchitectureTest.java new file mode 100644 index 0000000..3db0dfc --- /dev/null +++ b/src/test/java/auth_service/architecture/ArchitectureTest.java @@ -0,0 +1,93 @@ +package auth_service.architecture; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.library.dependencies.SlicesRuleDefinition; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +/** + * ArchUnit tests to enforce Clean Architecture and package boundaries. + * Analyzes all classes in the 'auth_service' package, ignoring test classes. + */ +@AnalyzeClasses(packages = "auth_service", importOptions = ImportOption.DoNotIncludeTests.class) +class ArchitectureTest { + + // =================================================================================== + // 1. DOMAIN INDEPENDENCE (Slices) + // =================================================================================== + + @ArchTest + static final ArchRule domains_should_not_depend_on_each_other = SlicesRuleDefinition.slices() + .matching("auth_service.domain.(*)..") + .should().notDependOnEachOther() + .because("Domains (e.g. login, user) should be strictly isolated and independent."); + + // =================================================================================== + // 2. CLEAN ARCHITECTURE DEPENDENCY DIRECTION (Inside out) + // =================================================================================== + + @ArchTest + static final ArchRule domain_core_should_not_depend_on_outside = noClasses() + .that().resideInAnyPackage("..model..", "..service..") + .should().dependOnClassesThat().resideInAnyPackage("..controller..", "..persistence.entities..", "..persistence.mapper..") + .because("Domain core (Models, Services) must not depend on Web/Controller or Persistence details."); + + @ArchTest + static final ArchRule persistence_interfaces_belong_to_domain = classes() + .that().resideInAPackage("..persistence..") + .and().areInterfaces() + .should().haveSimpleNameEndingWith("Repository") + .because("Repository interfaces are the ports and belong to the domain/persistence boundary."); + + // =================================================================================== + // 3. ENCAPSULATION OF DTOs AND ENTITIES + // =================================================================================== + + @ArchTest + static final ArchRule dtos_should_only_be_used_in_controllers = classes() + .that().resideInAPackage("..controller.dto..") + .should().onlyBeAccessed().byAnyPackage("..controller..") + .because("DTOs are Web/API concerns and must not leak into Services or Persistence."); + + @ArchTest + static final ArchRule entities_should_only_be_used_in_persistence = classes() + .that().resideInAPackage("..persistence.entities..") + .should().onlyBeAccessed().byAnyPackage("..persistence..") + .because("Database Entities must not leak into the Domain Core or Controllers."); + + // =================================================================================== + // 4. ADDITIONAL BEST PRACTICES + // =================================================================================== + + @ArchTest + static final ArchRule no_cyclic_dependencies = SlicesRuleDefinition.slices() + .matching("auth_service.(*)..") + .should().beFreeOfCycles() + .because("Cyclic dependencies make the code unmaintainable and tightly coupled."); + + @ArchTest + static final ArchRule controllers_should_only_call_services = noClasses() + .that().resideInAPackage("..controller..") + .should().dependOnClassesThat().resideInAPackage("..persistence..") + .because("Controllers must delegate to Services and should not access Persistence directly."); + + @ArchTest + static final ArchRule services_should_be_annotated = classes() + .that().resideInAPackage("..service..") + .and().haveSimpleNameEndingWith("Service") + .should().beAnnotatedWith(org.springframework.stereotype.Service.class) + .because("Classes named *Service should be registered as Spring @Service beans."); + + @ArchTest + static final ArchRule controllers_should_be_annotated = classes() + .that().resideInAPackage("..controller..") + .and().haveSimpleNameEndingWith("Controller") + .should().beAnnotatedWith(org.springframework.web.bind.annotation.RestController.class) + .orShould().beAnnotatedWith(org.springframework.web.bind.annotation.ControllerAdvice.class) + .orShould().beAnnotatedWith(org.springframework.web.bind.annotation.RestControllerAdvice.class) + .because("Classes named *Controller should be registered as Spring @RestController or @ControllerAdvice or @RestControllerAdvice beans."); +} \ No newline at end of file