diff --git a/application/build.gradle b/application/build.gradle index 4a2c3cbc..ff75c345 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -1,3 +1,8 @@ dependencies { implementation(project(":domain")) + testImplementation(libs.h2) + testImplementation(libs.konsist) + testImplementation(libs.kotest.engine) + testImplementation(libs.kotest.assertions) + testImplementation(libs.kotest.runner) } diff --git a/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/ActivateUserUseCaseImpl.kt b/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/ActivateUserUseCaseImpl.kt index e2db595f..91d387f0 100644 --- a/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/ActivateUserUseCaseImpl.kt +++ b/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/ActivateUserUseCaseImpl.kt @@ -14,7 +14,7 @@ import com.alleslocker.backend.domain.user.UserRole import java.time.Instant import kotlin.math.log -class ActivateUserUseCaseImpl( +internal class ActivateUserUseCaseImpl( val userGateway: UserGateway, val logger: Logger, ) : ActivateUserUseCase { diff --git a/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/AdminResetPasswordUserUseCaseImpl.kt b/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/AdminResetPasswordUserUseCaseImpl.kt index 9dc00754..1f1dec6c 100644 --- a/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/AdminResetPasswordUserUseCaseImpl.kt +++ b/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/AdminResetPasswordUserUseCaseImpl.kt @@ -16,7 +16,7 @@ import com.alleslocker.backend.domain.user.UserId import com.alleslocker.backend.domain.user.UserRole import java.time.Instant -class AdminResetPasswordUserUseCaseImpl( +internal class AdminResetPasswordUserUseCaseImpl( private val userGateway: UserGateway, private val passwordHasher: PasswordHasher, private val passwordGeneratorService: PasswordGeneratorService, diff --git a/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/ChangeUserRoleUseCaseImpl.kt b/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/ChangeUserRoleUseCaseImpl.kt index 184abf7a..d701d89f 100644 --- a/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/ChangeUserRoleUseCaseImpl.kt +++ b/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/ChangeUserRoleUseCaseImpl.kt @@ -13,7 +13,7 @@ import com.alleslocker.backend.domain.user.UserId import com.alleslocker.backend.domain.user.UserRole import java.time.Instant -class ChangeUserRoleUseCaseImpl( +internal class ChangeUserRoleUseCaseImpl( private val userGateway: UserGateway, private val logger: Logger, ) : ChangeUserRoleUseCase { diff --git a/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/CreateUserUseCaseImpl.kt b/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/CreateUserUseCaseImpl.kt index 23668ece..5f7ca685 100644 --- a/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/CreateUserUseCaseImpl.kt +++ b/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/CreateUserUseCaseImpl.kt @@ -21,7 +21,7 @@ import com.alleslocker.backend.domain.user.UserRole import com.alleslocker.backend.domain.user.Username import java.time.Instant -class CreateUserUseCaseImpl( +internal class CreateUserUseCaseImpl( private val userGateway: UserGateway, private val logger: Logger, private val passwordGeneratorService: PasswordGeneratorService, diff --git a/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/DeactivateUserUseCaseImpl.kt b/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/DeactivateUserUseCaseImpl.kt index 258a4fb7..8ce7063c 100644 --- a/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/DeactivateUserUseCaseImpl.kt +++ b/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/DeactivateUserUseCaseImpl.kt @@ -13,7 +13,7 @@ import com.alleslocker.backend.domain.user.UserRole import java.time.Instant import kotlin.math.log -class DeactivateUserUseCaseImpl( +internal class DeactivateUserUseCaseImpl( val userGateway: UserGateway, val logger: Logger, ) : DeactivateUserUseCase { diff --git a/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/GetUsersPagedUseCaseImpl.kt b/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/GetUsersPagedUseCaseImpl.kt index 3c53cbd0..7a8086c8 100644 --- a/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/GetUsersPagedUseCaseImpl.kt +++ b/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/GetUsersPagedUseCaseImpl.kt @@ -8,7 +8,7 @@ import com.alleslocker.backend.application.user.dto.response.GetUsersPagedRespon import com.alleslocker.backend.application.user.gateway.UserGateway import com.alleslocker.backend.application.user.mapper.toDto -class GetUsersPagedUseCaseImpl( +internal class GetUsersPagedUseCaseImpl( private val userGateway: UserGateway, private val logger: Logger, ) : GetUsersPagedUseCase { diff --git a/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/RequestUserPasswordChangeUseCaseImpl.kt b/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/RequestUserPasswordChangeUseCaseImpl.kt index dcdebef1..3496ec05 100644 --- a/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/RequestUserPasswordChangeUseCaseImpl.kt +++ b/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/RequestUserPasswordChangeUseCaseImpl.kt @@ -12,7 +12,7 @@ import com.alleslocker.backend.domain.user.UserId import com.alleslocker.backend.domain.user.UserRole import java.time.Instant -class RequestUserPasswordChangeUseCaseImpl( +internal class RequestUserPasswordChangeUseCaseImpl( private val userGateway: UserGateway, private val logger: Logger, ) : RequestUserPasswordChangeUseCase { @@ -22,7 +22,7 @@ class RequestUserPasswordChangeUseCaseImpl( ) { val requestorId = try { - UserId(request.userId) + UserId(request.requestorId) } catch (e: IllegalArgumentException) { presenter.presentFailure(ErrorResponse.BadRequest("Invalid id: ${e.message}")) return diff --git a/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/ResetPasswordUserUseCaseImpl.kt b/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/ResetPasswordUserUseCaseImpl.kt index 27b0582f..a075f00c 100644 --- a/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/ResetPasswordUserUseCaseImpl.kt +++ b/application/src/main/kotlin/com/alleslocker/backend/application/user/usecase/ResetPasswordUserUseCaseImpl.kt @@ -10,7 +10,7 @@ import com.alleslocker.backend.application.user.gateway.UserGateway import com.alleslocker.backend.domain.user.PasswordHash import com.alleslocker.backend.domain.user.UserId -class ResetPasswordUserUseCaseImpl( +internal class ResetPasswordUserUseCaseImpl( private val passwordHasher: PasswordHasher, private val logger: Logger, private val userGateway: UserGateway, diff --git a/bootstrap/src/test/kotlin/com/alleslocker/backend/bootstrap/CleanArchitectureTest.kt b/application/src/test/kotlin/com/alleslocker/backend/application/CleanArchitectureTest.kt similarity index 98% rename from bootstrap/src/test/kotlin/com/alleslocker/backend/bootstrap/CleanArchitectureTest.kt rename to application/src/test/kotlin/com/alleslocker/backend/application/CleanArchitectureTest.kt index b5113bd5..00326ba7 100644 --- a/bootstrap/src/test/kotlin/com/alleslocker/backend/bootstrap/CleanArchitectureTest.kt +++ b/application/src/test/kotlin/com/alleslocker/backend/application/CleanArchitectureTest.kt @@ -1,4 +1,4 @@ -package com.alleslocker.backend.bootstrap +package com.alleslocker.backend.application import com.lemonappdev.konsist.api.Konsist import com.lemonappdev.konsist.api.architecture.KoArchitectureCreator.assertArchitecture diff --git a/application/src/test/kotlin/com/alleslocker/backend/application/integration/config/TestAdapterFactory.kt b/application/src/test/kotlin/com/alleslocker/backend/application/integration/config/TestAdapterFactory.kt new file mode 100644 index 00000000..02a8f01b --- /dev/null +++ b/application/src/test/kotlin/com/alleslocker/backend/application/integration/config/TestAdapterFactory.kt @@ -0,0 +1,31 @@ +package com.alleslocker.backend.application.integration.config + +import com.alleslocker.backend.application.common.adapter.Adapter +import com.alleslocker.backend.application.common.factory.AdapterFactory +import java.lang.reflect.Proxy +import kotlin.reflect.KClass + +class TestAdapterFactory( + private val adapters: List, +) : AdapterFactory { + override fun make(adapter: KClass): T { + val match = adapters.firstOrNull { adapter.isInstance(it) } + if (match != null) { + @Suppress("UNCHECKED_CAST") + return match as T + } + + @Suppress("UNCHECKED_CAST") + return Proxy.newProxyInstance( + adapter.java.classLoader, + arrayOf(adapter.java), + ) { _, method, _ -> + when (method.returnType) { + Boolean::class.javaPrimitiveType -> false + Int::class.javaPrimitiveType -> 0 + Long::class.javaPrimitiveType -> 0L + else -> null + } + } as T + } +} diff --git a/application/src/test/kotlin/com/alleslocker/backend/application/integration/config/TestGatewayFactory.kt b/application/src/test/kotlin/com/alleslocker/backend/application/integration/config/TestGatewayFactory.kt new file mode 100644 index 00000000..248289ae --- /dev/null +++ b/application/src/test/kotlin/com/alleslocker/backend/application/integration/config/TestGatewayFactory.kt @@ -0,0 +1,33 @@ +package com.alleslocker.backend.application.integration.config + +import com.alleslocker.backend.application.common.factory.GatewayFactory +import com.alleslocker.backend.application.common.gateway.Gateway +import java.lang.reflect.Proxy +import kotlin.reflect.KClass + +class TestGatewayFactory( + private val gateways: List, +) : GatewayFactory { + override fun make(gateway: KClass): T { + val match = gateways.firstOrNull { gateway.isInstance(it) } + if (match != null) { + @Suppress("UNCHECKED_CAST") + return match as T + } + + @Suppress("UNCHECKED_CAST") + return Proxy.newProxyInstance( + gateway.java.classLoader, + arrayOf(gateway.java), + ) { _, method, _ -> + when (method.returnType) { + Boolean::class.javaPrimitiveType -> false + Int::class.javaPrimitiveType -> 0 + Long::class.javaPrimitiveType -> 0L + else -> null + } + } as T + } + + override fun migrate() {} +} diff --git a/application/src/test/kotlin/com/alleslocker/backend/application/integration/config/TestLogger.kt b/application/src/test/kotlin/com/alleslocker/backend/application/integration/config/TestLogger.kt new file mode 100644 index 00000000..32bef574 --- /dev/null +++ b/application/src/test/kotlin/com/alleslocker/backend/application/integration/config/TestLogger.kt @@ -0,0 +1,33 @@ +package com.alleslocker.backend.application.integration.config + +import com.alleslocker.backend.application.common.Logger +import com.alleslocker.backend.domain.auditlog.AuditLog + +class TestLogger : Logger { + val auditLogs = mutableListOf() + val infoMessages = mutableListOf() + val errorMessages = mutableListOf() + + fun clear() { + auditLogs.clear() + infoMessages.clear() + errorMessages.clear() + } + + override fun info(message: String) { + infoMessages.add(message) + } + + override fun error( + message: String, + throwable: Throwable?, + ) { + errorMessages.add(message) + } + + override fun audit(auditLog: AuditLog) { + auditLogs.add(auditLog) + } + + override fun debug(message: String) {} +} diff --git a/application/src/test/kotlin/com/alleslocker/backend/application/integration/config/TestPasswordHasher.kt b/application/src/test/kotlin/com/alleslocker/backend/application/integration/config/TestPasswordHasher.kt new file mode 100644 index 00000000..115ae654 --- /dev/null +++ b/application/src/test/kotlin/com/alleslocker/backend/application/integration/config/TestPasswordHasher.kt @@ -0,0 +1,15 @@ +package com.alleslocker.backend.application.integration.config + +import com.alleslocker.backend.application.common.security.PasswordHasher +import java.security.MessageDigest + +class TestPasswordHasher : PasswordHasher { + private val digest = MessageDigest.getInstance("SHA-256") + + override fun hash(raw: String): String = digest.digest(raw.toByteArray()).joinToString("") { "%02x".format(it) } + + override fun verify( + raw: String, + hash: String, + ): Boolean = hash(raw) == hash +} diff --git a/application/src/test/kotlin/com/alleslocker/backend/application/integration/config/TestPresenter.kt b/application/src/test/kotlin/com/alleslocker/backend/application/integration/config/TestPresenter.kt new file mode 100644 index 00000000..186f109c --- /dev/null +++ b/application/src/test/kotlin/com/alleslocker/backend/application/integration/config/TestPresenter.kt @@ -0,0 +1,17 @@ +package com.alleslocker.backend.application.integration.config + +import com.alleslocker.backend.application.common.ErrorResponse +import com.alleslocker.backend.application.common.OutputBoundary + +class TestPresenter : OutputBoundary { + var response: R? = null + var error: ErrorResponse? = null + + override fun present(response: R) { + this.response = response + } + + override fun presentFailure(error: ErrorResponse) { + this.error = error + } +} diff --git a/application/src/test/kotlin/com/alleslocker/backend/application/integration/config/TestUserGatewayAdapter.kt b/application/src/test/kotlin/com/alleslocker/backend/application/integration/config/TestUserGatewayAdapter.kt new file mode 100644 index 00000000..c808526f --- /dev/null +++ b/application/src/test/kotlin/com/alleslocker/backend/application/integration/config/TestUserGatewayAdapter.kt @@ -0,0 +1,200 @@ +package com.alleslocker.backend.application.integration.config + +import com.alleslocker.backend.application.common.model.Page +import com.alleslocker.backend.application.user.dto.filter.UserFilterDto +import com.alleslocker.backend.application.user.gateway.UserGateway +import com.alleslocker.backend.domain.user.PasswordHash +import com.alleslocker.backend.domain.user.User +import com.alleslocker.backend.domain.user.UserEmail +import com.alleslocker.backend.domain.user.UserFirstname +import com.alleslocker.backend.domain.user.UserId +import com.alleslocker.backend.domain.user.UserLastname +import com.alleslocker.backend.domain.user.UserRole +import com.alleslocker.backend.domain.user.Username +import java.sql.Connection +import java.sql.DriverManager + +class TestUserGatewayAdapter : UserGateway { + private val connection: Connection = DriverManager.getConnection(JDBC_URL) + + init { + createTable() + } + + fun deleteAll() { + connection.createStatement().use { stmt -> + stmt.execute("""DELETE FROM "user" """) + } + } + + private fun createTable() { + connection.createStatement().use { stmt -> + stmt.execute( + """ + CREATE TABLE IF NOT EXISTS "user" ( + id VARCHAR(255) PRIMARY KEY, + role VARCHAR(50) NOT NULL, + firstname VARCHAR(100) NOT NULL, + lastname VARCHAR(100) NOT NULL, + username VARCHAR(100) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + is_active BOOLEAN NOT NULL, + must_change_password BOOLEAN NOT NULL + ) + """.trimIndent(), + ) + } + } + + override fun save(entity: User): User { + val existing = findById(entity.id) + if (existing != null) { + connection + .prepareStatement( + """ + UPDATE "user" SET role=?, firstname=?, lastname=?, username=?, email=?, password_hash=?, is_active=?, must_change_password=? + WHERE id=? + """.trimIndent(), + ).use { stmt -> + stmt.setString(1, entity.role.name) + stmt.setString(2, entity.firstname.value) + stmt.setString(3, entity.lastname.value) + stmt.setString(4, entity.username.value) + stmt.setString(5, entity.email.value) + stmt.setString(6, entity.passwordHash.value) + stmt.setBoolean(7, entity.isActive) + stmt.setBoolean(8, entity.mustChangePassword) + stmt.setString(9, entity.id.value) + stmt.executeUpdate() + } + } else { + connection + .prepareStatement( + """ + INSERT INTO "user" (id, role, firstname, lastname, username, email, password_hash, is_active, must_change_password) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent(), + ).use { stmt -> + stmt.setString(1, entity.id.value) + stmt.setString(2, entity.role.name) + stmt.setString(3, entity.firstname.value) + stmt.setString(4, entity.lastname.value) + stmt.setString(5, entity.username.value) + stmt.setString(6, entity.email.value) + stmt.setString(7, entity.passwordHash.value) + stmt.setBoolean(8, entity.isActive) + stmt.setBoolean(9, entity.mustChangePassword) + stmt.executeUpdate() + } + } + return entity + } + + override fun deleteById(id: UserId) { + connection.prepareStatement("""DELETE FROM "user" WHERE id=?""").use { stmt -> + stmt.setString(1, id.value) + stmt.executeUpdate() + } + } + + override fun findById(id: UserId): User? { + connection.prepareStatement("""SELECT * FROM "user" WHERE id=?""").use { stmt -> + stmt.setString(1, id.value) + stmt.executeQuery().use { rs -> + if (rs.next()) return mapRow(rs) + } + } + return null + } + + override fun exists(id: UserId): Boolean = findById(id) != null + + override fun findByUsername(username: String): User? { + connection.prepareStatement("""SELECT * FROM "user" WHERE username=?""").use { stmt -> + stmt.setString(1, username) + stmt.executeQuery().use { rs -> + if (rs.next()) return mapRow(rs) + } + } + return null + } + + override fun findByEmail(email: String): User? { + connection.prepareStatement("""SELECT * FROM "user" WHERE email=?""").use { stmt -> + stmt.setString(1, email) + stmt.executeQuery().use { rs -> + if (rs.next()) return mapRow(rs) + } + } + return null + } + + override fun existsByEmail(email: String): Boolean = findByEmail(email) != null + + override fun existsByUsername(username: String): Boolean = findByUsername(username) != null + + override fun getAllUsersPaged( + filter: UserFilterDto, + page: Int, + size: Int, + ): Page { + val search = filter.search + val whereClause = + if (search != null) { + "WHERE LOWER(firstname) LIKE ? OR LOWER(lastname) LIKE ? OR LOWER(email) LIKE ? OR LOWER(username) LIKE ?" + } else { + "" + } + + val totalElements = + connection.prepareStatement("""SELECT COUNT(*) FROM "user" $whereClause""").use { countStmt -> + if (search != null) { + val pattern = "%${search.lowercase()}%" + (1..4).forEach { countStmt.setString(it, pattern) } + } + countStmt.executeQuery().use { rs -> if (rs.next()) rs.getLong(1) else 0L } + } + + val totalPages = ((totalElements + size.toLong() - 1) / size).toInt() + val offset = page * size + + val dataSql = """SELECT * FROM "user" $whereClause ORDER BY id DESC LIMIT ? OFFSET ?""" + val content = + connection.prepareStatement(dataSql).use { dataStmt -> + if (search != null) { + val pattern = "%${search.lowercase()}%" + (1..4).forEach { dataStmt.setString(it, pattern) } + dataStmt.setInt(5, size) + dataStmt.setInt(6, offset) + } else { + dataStmt.setInt(1, size) + dataStmt.setInt(2, offset) + } + dataStmt.executeQuery().use { rs -> + val list = mutableListOf() + while (rs.next()) list.add(mapRow(rs)) + list + } + } + + return Page(content, page, size, totalElements, totalPages) + } + + private fun mapRow(rs: java.sql.ResultSet): User = + User( + id = UserId(rs.getString("id")), + role = UserRole.valueOf(rs.getString("role")), + firstname = UserFirstname(rs.getString("firstname")), + lastname = UserLastname(rs.getString("lastname")), + username = Username(rs.getString("username")), + email = UserEmail(rs.getString("email")), + passwordHash = PasswordHash(rs.getString("password_hash")), + isActive = rs.getBoolean("is_active"), + mustChangePassword = rs.getBoolean("must_change_password"), + ) + + companion object { + private const val JDBC_URL = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1" + } +} diff --git a/application/src/test/kotlin/com/alleslocker/backend/application/integration/config/UserTestContext.kt b/application/src/test/kotlin/com/alleslocker/backend/application/integration/config/UserTestContext.kt new file mode 100644 index 00000000..96e50d45 --- /dev/null +++ b/application/src/test/kotlin/com/alleslocker/backend/application/integration/config/UserTestContext.kt @@ -0,0 +1,24 @@ +package com.alleslocker.backend.application.integration.config + +import com.alleslocker.backend.application.common.adapter.Adapter +import com.alleslocker.backend.application.common.factory.UseCaseFactory +import com.alleslocker.backend.application.common.factory.UseCaseFactoryImpl +import com.alleslocker.backend.application.common.gateway.Gateway +import com.alleslocker.backend.application.common.security.PasswordHasher + +data class UserTestContext( + val userGateway: TestUserGatewayAdapter, + val passwordHasher: PasswordHasher, + val logger: TestLogger, + val useCaseFactory: UseCaseFactory, +) + +fun createUserTestContext(): UserTestContext { + val userGateway = TestUserGatewayAdapter() + val passwordHasher = TestPasswordHasher() + val logger = TestLogger() + val gatewayFactory = TestGatewayFactory(listOf(userGateway)) + val adapterFactory = TestAdapterFactory(emptyList()) + val useCaseFactory = UseCaseFactoryImpl(gatewayFactory, adapterFactory, passwordHasher, logger) + return UserTestContext(userGateway, passwordHasher, logger, useCaseFactory) +} diff --git a/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/ActivateUserUseCaseTest.kt b/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/ActivateUserUseCaseTest.kt new file mode 100644 index 00000000..cdcc68b0 --- /dev/null +++ b/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/ActivateUserUseCaseTest.kt @@ -0,0 +1,145 @@ +package com.alleslocker.backend.application.integration.user + +import com.alleslocker.backend.application.common.ErrorResponse +import com.alleslocker.backend.application.integration.config.TestPresenter +import com.alleslocker.backend.application.integration.config.createUserTestContext +import com.alleslocker.backend.application.user.dto.request.ActivateUserRequestDto +import com.alleslocker.backend.application.user.usecase.ActivateUserUseCase +import com.alleslocker.backend.domain.user.PasswordHash +import com.alleslocker.backend.domain.user.User +import com.alleslocker.backend.domain.user.UserEmail +import com.alleslocker.backend.domain.user.UserFirstname +import com.alleslocker.backend.domain.user.UserId +import com.alleslocker.backend.domain.user.UserLastname +import com.alleslocker.backend.domain.user.UserRole +import com.alleslocker.backend.domain.user.Username +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf + +class ActivateUserUseCaseTest : + FreeSpec({ + val ctx = createUserTestContext() + val useCase: ActivateUserUseCase = ctx.useCaseFactory.make(ActivateUserUseCase::class) + + lateinit var adminId: String + lateinit var targetUserId: String + + beforeEach { + ctx.userGateway.deleteAll() + ctx.logger.clear() + val admin = + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.ADMIN, + firstname = UserFirstname("Admin"), + lastname = UserLastname("User"), + username = Username("admin"), + email = UserEmail("admin@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("admin123")), + isActive = true, + mustChangePassword = false, + ), + ) + adminId = admin.id.value + + val targetUser = + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.USER, + firstname = UserFirstname("Max"), + lastname = UserLastname("Mustermann"), + username = Username("mmuster"), + email = UserEmail("max@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("pass123")), + isActive = false, + mustChangePassword = false, + ), + ) + targetUserId = targetUser.id.value + } + + "should activate a deactivated user" { + val presenter = TestPresenter() + + useCase.execute( + ActivateUserRequestDto(requestorId = adminId, userId = targetUserId), + presenter, + ) + + presenter.error shouldBe null + val updatedUser = ctx.userGateway.findById(UserId(targetUserId)) + updatedUser!!.isActive shouldBe true + ctx.logger.auditLogs.size shouldBe 1 + } + + "should reject activating already active user" { + ctx.userGateway.save( + ctx.userGateway.findById(UserId(targetUserId))!!.copy(isActive = true), + ) + + val presenter = TestPresenter() + useCase.execute( + ActivateUserRequestDto(requestorId = adminId, userId = targetUserId), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should reject activation by non-admin" { + val regularUser = + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.USER, + firstname = UserFirstname("Regular"), + lastname = UserLastname("User"), + username = Username("regular"), + email = UserEmail("regular@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("pass123")), + isActive = true, + mustChangePassword = false, + ), + ) + + val presenter = TestPresenter() + useCase.execute( + ActivateUserRequestDto(requestorId = regularUser.id.value, userId = targetUserId), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should reject activation of non-existent user" { + val presenter = TestPresenter() + useCase.execute( + ActivateUserRequestDto(requestorId = adminId, userId = "non-existent-id"), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should reject activation with non-existent requestor" { + val presenter = TestPresenter() + useCase.execute( + ActivateUserRequestDto(requestorId = "non-existent-id", userId = targetUserId), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + }) diff --git a/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/AdminResetPasswordUserUseCaseTest.kt b/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/AdminResetPasswordUserUseCaseTest.kt new file mode 100644 index 00000000..3f59009d --- /dev/null +++ b/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/AdminResetPasswordUserUseCaseTest.kt @@ -0,0 +1,149 @@ +package com.alleslocker.backend.application.integration.user + +import com.alleslocker.backend.application.common.ErrorResponse +import com.alleslocker.backend.application.integration.config.TestPresenter +import com.alleslocker.backend.application.integration.config.createUserTestContext +import com.alleslocker.backend.application.user.dto.request.AdminResetPasswordUserRequestDto +import com.alleslocker.backend.application.user.dto.response.AdminResetPasswordUserResponseDto +import com.alleslocker.backend.application.user.usecase.AdminResetPasswordUserUseCase +import com.alleslocker.backend.domain.user.PasswordHash +import com.alleslocker.backend.domain.user.User +import com.alleslocker.backend.domain.user.UserEmail +import com.alleslocker.backend.domain.user.UserFirstname +import com.alleslocker.backend.domain.user.UserId +import com.alleslocker.backend.domain.user.UserLastname +import com.alleslocker.backend.domain.user.UserRole +import com.alleslocker.backend.domain.user.Username +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.comparables.shouldBeGreaterThan +import io.kotest.matchers.comparables.shouldBeGreaterThanOrEqualTo +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf + +class AdminResetPasswordUserUseCaseTest : + FreeSpec({ + val ctx = createUserTestContext() + val useCase: AdminResetPasswordUserUseCase = ctx.useCaseFactory.make(AdminResetPasswordUserUseCase::class) + + lateinit var adminId: String + lateinit var targetUserId: String + + beforeEach { + ctx.userGateway.deleteAll() + ctx.logger.clear() + val admin = + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.ADMIN, + firstname = UserFirstname("Admin"), + lastname = UserLastname("User"), + username = Username("admin"), + email = UserEmail("admin@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("admin123")), + isActive = true, + mustChangePassword = false, + ), + ) + adminId = admin.id.value + + val targetUser = + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.USER, + firstname = UserFirstname("Max"), + lastname = UserLastname("Mustermann"), + username = Username("mmuster"), + email = UserEmail("max@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("oldPass123")), + isActive = true, + mustChangePassword = false, + ), + ) + targetUserId = targetUser.id.value + } + + "should reset password for user" { + val presenter = TestPresenter() + + useCase.execute( + AdminResetPasswordUserRequestDto(requestorId = adminId, userId = targetUserId), + presenter, + ) + + presenter.error shouldBe null + presenter.response shouldNotBe null + presenter.response!!.userId shouldBe targetUserId + presenter.response!!.password.length shouldBeGreaterThan 0 + + val updatedUser = ctx.userGateway.findById(UserId(targetUserId)) + updatedUser!!.mustChangePassword shouldBe true + ctx.passwordHasher.verify(presenter.response!!.password, updatedUser.passwordHash.value) shouldBe true + + ctx.logger.auditLogs.size shouldBe 1 + } + + "should reject reset by non-admin" { + val regularUser = + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.USER, + firstname = UserFirstname("Regular"), + lastname = UserLastname("User"), + username = Username("regular"), + email = UserEmail("regular@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("pass123")), + isActive = true, + mustChangePassword = false, + ), + ) + + val presenter = TestPresenter() + useCase.execute( + AdminResetPasswordUserRequestDto(requestorId = regularUser.id.value, userId = targetUserId), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should reject reset of non-existent user" { + val presenter = TestPresenter() + useCase.execute( + AdminResetPasswordUserRequestDto(requestorId = adminId, userId = "non-existent-id"), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should reject reset with non-existent requestor" { + val presenter = TestPresenter() + useCase.execute( + AdminResetPasswordUserRequestDto(requestorId = "non-existent-id", userId = targetUserId), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "generated password should be at least 8 characters" { + val presenter = TestPresenter() + useCase.execute( + AdminResetPasswordUserRequestDto(requestorId = adminId, userId = targetUserId), + presenter, + ) + + presenter.error shouldBe null + presenter.response!!.password.length shouldBeGreaterThanOrEqualTo 8 + } + }) diff --git a/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/ChangeUserRoleUseCaseTest.kt b/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/ChangeUserRoleUseCaseTest.kt new file mode 100644 index 00000000..51562491 --- /dev/null +++ b/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/ChangeUserRoleUseCaseTest.kt @@ -0,0 +1,184 @@ +package com.alleslocker.backend.application.integration.user + +import com.alleslocker.backend.application.common.ErrorResponse +import com.alleslocker.backend.application.integration.config.TestPresenter +import com.alleslocker.backend.application.integration.config.createUserTestContext +import com.alleslocker.backend.application.user.dto.UserRoleDto +import com.alleslocker.backend.application.user.dto.request.ChangeUserRoleRequestDto +import com.alleslocker.backend.application.user.usecase.ChangeUserRoleUseCase +import com.alleslocker.backend.domain.user.PasswordHash +import com.alleslocker.backend.domain.user.User +import com.alleslocker.backend.domain.user.UserEmail +import com.alleslocker.backend.domain.user.UserFirstname +import com.alleslocker.backend.domain.user.UserId +import com.alleslocker.backend.domain.user.UserLastname +import com.alleslocker.backend.domain.user.UserRole +import com.alleslocker.backend.domain.user.Username +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.types.shouldBeInstanceOf + +class ChangeUserRoleUseCaseTest : + FreeSpec({ + val ctx = createUserTestContext() + val useCase: ChangeUserRoleUseCase = ctx.useCaseFactory.make(ChangeUserRoleUseCase::class) + + lateinit var adminId: String + lateinit var targetUserId: String + + beforeEach { + ctx.userGateway.deleteAll() + ctx.logger.clear() + val admin = + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.ADMIN, + firstname = UserFirstname("Admin"), + lastname = UserLastname("User"), + username = Username("admin"), + email = UserEmail("admin@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("admin123")), + isActive = true, + mustChangePassword = false, + ), + ) + adminId = admin.id.value + + val targetUser = + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.USER, + firstname = UserFirstname("Max"), + lastname = UserLastname("Mustermann"), + username = Username("mmuster"), + email = UserEmail("max@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("pass123")), + isActive = true, + mustChangePassword = false, + ), + ) + targetUserId = targetUser.id.value + } + + "should change USER to ADMIN" { + val presenter = TestPresenter() + + useCase.execute( + ChangeUserRoleRequestDto( + requestorId = adminId, + userId = targetUserId, + role = UserRoleDto.ADMIN, + ), + presenter, + ) + + presenter.error shouldBe null + val updatedUser = ctx.userGateway.findById(UserId(targetUserId)) + updatedUser!!.role shouldBe UserRole.ADMIN + ctx.logger.auditLogs.size shouldBe 1 + } + + "should change ADMIN to USER" { + ctx.userGateway.save( + ctx.userGateway.findById(UserId(targetUserId))!!.copy(role = UserRole.ADMIN), + ) + + val presenter = TestPresenter() + useCase.execute( + ChangeUserRoleRequestDto( + requestorId = adminId, + userId = targetUserId, + role = UserRoleDto.USER, + ), + presenter, + ) + + presenter.error shouldBe null + val updatedUser = ctx.userGateway.findById(UserId(targetUserId)) + updatedUser!!.role shouldBe UserRole.USER + } + + "should reject setting the same role" { + val presenter = TestPresenter() + useCase.execute( + ChangeUserRoleRequestDto( + requestorId = adminId, + userId = targetUserId, + role = UserRoleDto.USER, + ), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + presenter.error!!.message shouldContain "already this role" + } + + "should reject role change by non-admin" { + val regularUser = + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.USER, + firstname = UserFirstname("Regular"), + lastname = UserLastname("User"), + username = Username("regular"), + email = UserEmail("regular@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("pass123")), + isActive = true, + mustChangePassword = false, + ), + ) + + val presenter = TestPresenter() + useCase.execute( + ChangeUserRoleRequestDto( + requestorId = regularUser.id.value, + userId = targetUserId, + role = UserRoleDto.ADMIN, + ), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should reject role change of non-existent user" { + val presenter = TestPresenter() + useCase.execute( + ChangeUserRoleRequestDto( + requestorId = adminId, + userId = "non-existent-id", + role = UserRoleDto.ADMIN, + ), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should reject role change with non-existent requestor" { + val presenter = TestPresenter() + useCase.execute( + ChangeUserRoleRequestDto( + requestorId = "non-existent-id", + userId = targetUserId, + role = UserRoleDto.ADMIN, + ), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + }) diff --git a/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/CreateUserUseCaseTest.kt b/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/CreateUserUseCaseTest.kt new file mode 100644 index 00000000..2bb0b24f --- /dev/null +++ b/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/CreateUserUseCaseTest.kt @@ -0,0 +1,289 @@ +package com.alleslocker.backend.application.integration.user + +import com.alleslocker.backend.application.common.ErrorResponse +import com.alleslocker.backend.application.integration.config.TestPresenter +import com.alleslocker.backend.application.integration.config.createUserTestContext +import com.alleslocker.backend.application.user.dto.request.CreateUserRequestDto +import com.alleslocker.backend.application.user.dto.response.CreateUserResponseDto +import com.alleslocker.backend.application.user.usecase.CreateUserUseCase +import com.alleslocker.backend.domain.user.PasswordHash +import com.alleslocker.backend.domain.user.User +import com.alleslocker.backend.domain.user.UserEmail +import com.alleslocker.backend.domain.user.UserFirstname +import com.alleslocker.backend.domain.user.UserId +import com.alleslocker.backend.domain.user.UserLastname +import com.alleslocker.backend.domain.user.UserRole +import com.alleslocker.backend.domain.user.Username +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.comparables.shouldBeGreaterThan +import io.kotest.matchers.comparables.shouldBeGreaterThanOrEqualTo +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.types.shouldBeInstanceOf + +class CreateUserUseCaseTest : + FreeSpec({ + val ctx = createUserTestContext() + val useCase: CreateUserUseCase = ctx.useCaseFactory.make(CreateUserUseCase::class) + + lateinit var adminId: String + + beforeEach { + ctx.userGateway.deleteAll() + ctx.logger.clear() + val admin = + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.ADMIN, + firstname = UserFirstname("Admin"), + lastname = UserLastname("User"), + username = Username("admin"), + email = UserEmail("admin@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("admin123")), + isActive = true, + mustChangePassword = false, + ), + ) + adminId = admin.id.value + } + + "should create a user as admin" { + val presenter = TestPresenter() + + useCase.execute( + CreateUserRequestDto( + requestorId = adminId, + firstname = "Max", + lastname = "Mustermann", + username = "mmuster", + email = "max@test.de", + ), + presenter, + ) + + presenter.error shouldBe null + presenter.response shouldNotBe null + presenter.response!!.id shouldNotBe "" + presenter.response!!.password.length shouldBeGreaterThan 0 + + val savedUser = ctx.userGateway.findById(UserId(presenter.response!!.id)) + savedUser shouldNotBe null + savedUser!!.mustChangePassword shouldBe true + savedUser.isActive shouldBe true + savedUser.username.value shouldBe "mmuster" + savedUser.email.value shouldBe "max@test.de" + ctx.passwordHasher.verify(presenter.response!!.password, savedUser.passwordHash.value) shouldBe true + + ctx.logger.auditLogs.size shouldBe 1 + ctx.logger.auditLogs[0] + .message.value shouldContain "mmuster" + } + + "should reject duplicate username" { + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.USER, + firstname = UserFirstname("Existing"), + lastname = UserLastname("User"), + username = Username("mmuster"), + email = UserEmail("other@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("pass123")), + isActive = true, + mustChangePassword = false, + ), + ) + + val presenter = TestPresenter() + useCase.execute( + CreateUserRequestDto( + requestorId = adminId, + firstname = "Max", + lastname = "Mustermann", + username = "mmuster", + email = "max@test.de", + ), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + presenter.error!!.message shouldContain "username" + } + + "should reject duplicate email" { + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.USER, + firstname = UserFirstname("Existing"), + lastname = UserLastname("User"), + username = Username("existing"), + email = UserEmail("max@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("pass123")), + isActive = true, + mustChangePassword = false, + ), + ) + + val presenter = TestPresenter() + useCase.execute( + CreateUserRequestDto( + requestorId = adminId, + firstname = "Max", + lastname = "Mustermann", + username = "mmuster", + email = "max@test.de", + ), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + presenter.error!!.message shouldContain "email" + } + + "should reject non-admin requestor" { + val regularUser = + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.USER, + firstname = UserFirstname("Regular"), + lastname = UserLastname("User"), + username = Username("regular"), + email = UserEmail("regular@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("pass123")), + isActive = true, + mustChangePassword = false, + ), + ) + + val presenter = TestPresenter() + useCase.execute( + CreateUserRequestDto( + requestorId = regularUser.id.value, + firstname = "Max", + lastname = "Mustermann", + username = "mmuster", + email = "max@test.de", + ), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should reject requestor that does not exist" { + val presenter = TestPresenter() + useCase.execute( + CreateUserRequestDto( + requestorId = "non-existent-id", + firstname = "Max", + lastname = "Mustermann", + username = "mmuster", + email = "max@test.de", + ), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should reject invalid firstname (blank)" { + val presenter = TestPresenter() + useCase.execute( + CreateUserRequestDto( + requestorId = adminId, + firstname = "", + lastname = "Mustermann", + username = "mmuster", + email = "max@test.de", + ), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should reject invalid email format" { + val presenter = TestPresenter() + useCase.execute( + CreateUserRequestDto( + requestorId = adminId, + firstname = "Max", + lastname = "Mustermann", + username = "mmuster", + email = "invalid-email", + ), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should reject username shorter than 3 characters" { + val presenter = TestPresenter() + useCase.execute( + CreateUserRequestDto( + requestorId = adminId, + firstname = "Max", + lastname = "Mustermann", + username = "ab", + email = "max@test.de", + ), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should reject username with special characters" { + val presenter = TestPresenter() + useCase.execute( + CreateUserRequestDto( + requestorId = adminId, + firstname = "Max", + lastname = "Mustermann", + username = "user name!", + email = "max@test.de", + ), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "generated password should be at least 8 characters" { + val presenter = TestPresenter() + useCase.execute( + CreateUserRequestDto( + requestorId = adminId, + firstname = "Max", + lastname = "Mustermann", + username = "mmuster", + email = "max@test.de", + ), + presenter, + ) + + presenter.error shouldBe null + presenter.response!!.password.length shouldBeGreaterThanOrEqualTo 8 + } + }) diff --git a/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/DeactivateUserUseCaseTest.kt b/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/DeactivateUserUseCaseTest.kt new file mode 100644 index 00000000..5efcce09 --- /dev/null +++ b/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/DeactivateUserUseCaseTest.kt @@ -0,0 +1,145 @@ +package com.alleslocker.backend.application.integration.user + +import com.alleslocker.backend.application.common.ErrorResponse +import com.alleslocker.backend.application.integration.config.TestPresenter +import com.alleslocker.backend.application.integration.config.createUserTestContext +import com.alleslocker.backend.application.user.dto.request.DeactivateUserRequestDto +import com.alleslocker.backend.application.user.usecase.DeactivateUserUseCase +import com.alleslocker.backend.domain.user.PasswordHash +import com.alleslocker.backend.domain.user.User +import com.alleslocker.backend.domain.user.UserEmail +import com.alleslocker.backend.domain.user.UserFirstname +import com.alleslocker.backend.domain.user.UserId +import com.alleslocker.backend.domain.user.UserLastname +import com.alleslocker.backend.domain.user.UserRole +import com.alleslocker.backend.domain.user.Username +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf + +class DeactivateUserUseCaseTest : + FreeSpec({ + val ctx = createUserTestContext() + val useCase: DeactivateUserUseCase = ctx.useCaseFactory.make(DeactivateUserUseCase::class) + + lateinit var adminId: String + lateinit var targetUserId: String + + beforeEach { + ctx.userGateway.deleteAll() + ctx.logger.clear() + val admin = + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.ADMIN, + firstname = UserFirstname("Admin"), + lastname = UserLastname("User"), + username = Username("admin"), + email = UserEmail("admin@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("admin123")), + isActive = true, + mustChangePassword = false, + ), + ) + adminId = admin.id.value + + val targetUser = + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.USER, + firstname = UserFirstname("Max"), + lastname = UserLastname("Mustermann"), + username = Username("mmuster"), + email = UserEmail("max@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("pass123")), + isActive = true, + mustChangePassword = false, + ), + ) + targetUserId = targetUser.id.value + } + + "should deactivate an active user" { + val presenter = TestPresenter() + + useCase.execute( + DeactivateUserRequestDto(requestorId = adminId, userId = targetUserId), + presenter, + ) + + presenter.error shouldBe null + val updatedUser = ctx.userGateway.findById(UserId(targetUserId)) + updatedUser!!.isActive shouldBe false + ctx.logger.auditLogs.size shouldBe 1 + } + + "should reject deactivating already deactivated user" { + ctx.userGateway.save( + ctx.userGateway.findById(UserId(targetUserId))!!.copy(isActive = false), + ) + + val presenter = TestPresenter() + useCase.execute( + DeactivateUserRequestDto(requestorId = adminId, userId = targetUserId), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should reject deactivation by non-admin" { + val regularUser = + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.USER, + firstname = UserFirstname("Regular"), + lastname = UserLastname("User"), + username = Username("regular"), + email = UserEmail("regular@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("pass123")), + isActive = true, + mustChangePassword = false, + ), + ) + + val presenter = TestPresenter() + useCase.execute( + DeactivateUserRequestDto(requestorId = regularUser.id.value, userId = targetUserId), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should reject deactivation of non-existent user" { + val presenter = TestPresenter() + useCase.execute( + DeactivateUserRequestDto(requestorId = adminId, userId = "non-existent-id"), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should reject deactivation with non-existent requestor" { + val presenter = TestPresenter() + useCase.execute( + DeactivateUserRequestDto(requestorId = "non-existent-id", userId = targetUserId), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + }) diff --git a/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/EditUserUseCaseTest.kt b/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/EditUserUseCaseTest.kt new file mode 100644 index 00000000..82646ccb --- /dev/null +++ b/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/EditUserUseCaseTest.kt @@ -0,0 +1,280 @@ +package com.alleslocker.backend.application.integration.user + +import com.alleslocker.backend.application.common.ErrorResponse +import com.alleslocker.backend.application.integration.config.TestPresenter +import com.alleslocker.backend.application.integration.config.createUserTestContext +import com.alleslocker.backend.application.user.dto.request.EditUserRequestDto +import com.alleslocker.backend.application.user.usecase.EditUserUseCase +import com.alleslocker.backend.domain.user.PasswordHash +import com.alleslocker.backend.domain.user.User +import com.alleslocker.backend.domain.user.UserEmail +import com.alleslocker.backend.domain.user.UserFirstname +import com.alleslocker.backend.domain.user.UserId +import com.alleslocker.backend.domain.user.UserLastname +import com.alleslocker.backend.domain.user.UserRole +import com.alleslocker.backend.domain.user.Username +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.types.shouldBeInstanceOf + +class EditUserUseCaseTest : + FreeSpec({ + val ctx = createUserTestContext() + val useCase: EditUserUseCase = ctx.useCaseFactory.make(EditUserUseCase::class) + + lateinit var adminId: String + lateinit var targetUserId: String + + beforeEach { + ctx.userGateway.deleteAll() + ctx.logger.clear() + val admin = + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.ADMIN, + firstname = UserFirstname("Admin"), + lastname = UserLastname("User"), + username = Username("admin"), + email = UserEmail("admin@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("admin123")), + isActive = true, + mustChangePassword = false, + ), + ) + adminId = admin.id.value + + val targetUser = + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.USER, + firstname = UserFirstname("Max"), + lastname = UserLastname("Mustermann"), + username = Username("mmuster"), + email = UserEmail("max@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("pass123")), + isActive = true, + mustChangePassword = false, + ), + ) + targetUserId = targetUser.id.value + } + + "should update firstname" { + val presenter = TestPresenter() + + useCase.execute( + EditUserRequestDto( + requestorId = adminId, + userId = targetUserId, + firstname = "Maximilian", + ), + presenter, + ) + + presenter.error shouldBe null + val updatedUser = ctx.userGateway.findById(UserId(targetUserId)) + updatedUser!!.firstname.value shouldBe "Maximilian" + updatedUser.lastname.value shouldBe "Mustermann" + updatedUser.username.value shouldBe "mmuster" + updatedUser.email.value shouldBe "max@test.de" + ctx.logger.auditLogs.size shouldBe 1 + } + + "should update multiple fields at once" { + val presenter = TestPresenter() + + useCase.execute( + EditUserRequestDto( + requestorId = adminId, + userId = targetUserId, + firstname = "Maximilian", + lastname = "Muster", + username = "mmuster2", + email = "maximilian@test.de", + ), + presenter, + ) + + presenter.error shouldBe null + val updatedUser = ctx.userGateway.findById(UserId(targetUserId)) + updatedUser!!.firstname.value shouldBe "Maximilian" + updatedUser.lastname.value shouldBe "Muster" + updatedUser.username.value shouldBe "mmuster2" + updatedUser.email.value shouldBe "maximilian@test.de" + } + + "should reject edit by non-admin" { + val regularUser = + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.USER, + firstname = UserFirstname("Regular"), + lastname = UserLastname("User"), + username = Username("regular"), + email = UserEmail("regular@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("pass123")), + isActive = true, + mustChangePassword = false, + ), + ) + + val presenter = TestPresenter() + useCase.execute( + EditUserRequestDto( + requestorId = regularUser.id.value, + userId = targetUserId, + firstname = "Changed", + ), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should reject edit of non-existent user" { + val presenter = TestPresenter() + useCase.execute( + EditUserRequestDto( + requestorId = adminId, + userId = "non-existent-id", + firstname = "Changed", + ), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should reject edit with non-existent requestor" { + val presenter = TestPresenter() + useCase.execute( + EditUserRequestDto( + requestorId = "non-existent-id", + userId = targetUserId, + firstname = "Changed", + ), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should reject duplicate email" { + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.USER, + firstname = UserFirstname("Other"), + lastname = UserLastname("User"), + username = Username("other"), + email = UserEmail("other@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("pass123")), + isActive = true, + mustChangePassword = false, + ), + ) + + val presenter = TestPresenter() + useCase.execute( + EditUserRequestDto( + requestorId = adminId, + userId = targetUserId, + email = "other@test.de", + ), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + presenter.error!!.message shouldContain "email" + } + + "should reject duplicate username" { + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.USER, + firstname = UserFirstname("Other"), + lastname = UserLastname("User"), + username = Username("other"), + email = UserEmail("other@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("pass123")), + isActive = true, + mustChangePassword = false, + ), + ) + + val presenter = TestPresenter() + useCase.execute( + EditUserRequestDto( + requestorId = adminId, + userId = targetUserId, + username = "other", + ), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + presenter.error!!.message shouldContain "username" + } + + "should allow setting own email (no uniqueness conflict)" { + val presenter = TestPresenter() + useCase.execute( + EditUserRequestDto( + requestorId = adminId, + userId = targetUserId, + email = "max@test.de", + ), + presenter, + ) + + presenter.error shouldBe null + } + + "should allow setting own username (no uniqueness conflict)" { + val presenter = TestPresenter() + useCase.execute( + EditUserRequestDto( + requestorId = adminId, + userId = targetUserId, + username = "mmuster", + ), + presenter, + ) + + presenter.error shouldBe null + } + + "should be a no-op when all fields are null" { + val presenter = TestPresenter() + useCase.execute( + EditUserRequestDto( + requestorId = adminId, + userId = targetUserId, + ), + presenter, + ) + + presenter.error shouldBe null + val updatedUser = ctx.userGateway.findById(UserId(targetUserId)) + updatedUser!!.firstname.value shouldBe "Max" + updatedUser.lastname.value shouldBe "Mustermann" + updatedUser.username.value shouldBe "mmuster" + updatedUser.email.value shouldBe "max@test.de" + } + }) diff --git a/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/GetUserUseCaseTest.kt b/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/GetUserUseCaseTest.kt new file mode 100644 index 00000000..f8ebd1a8 --- /dev/null +++ b/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/GetUserUseCaseTest.kt @@ -0,0 +1,83 @@ +package com.alleslocker.backend.application.integration.user + +import com.alleslocker.backend.application.common.ErrorResponse +import com.alleslocker.backend.application.integration.config.TestPresenter +import com.alleslocker.backend.application.integration.config.createUserTestContext +import com.alleslocker.backend.application.user.dto.request.GetUserRequestDto +import com.alleslocker.backend.application.user.dto.response.GetUserResponseDto +import com.alleslocker.backend.application.user.usecase.GetUserUseCase +import com.alleslocker.backend.domain.user.PasswordHash +import com.alleslocker.backend.domain.user.User +import com.alleslocker.backend.domain.user.UserEmail +import com.alleslocker.backend.domain.user.UserFirstname +import com.alleslocker.backend.domain.user.UserId +import com.alleslocker.backend.domain.user.UserLastname +import com.alleslocker.backend.domain.user.UserRole +import com.alleslocker.backend.domain.user.Username +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf + +class GetUserUseCaseTest : + FreeSpec({ + val ctx = createUserTestContext() + val useCase: GetUserUseCase = ctx.useCaseFactory.make(GetUserUseCase::class) + + lateinit var savedUserId: String + + beforeEach { + ctx.userGateway.deleteAll() + val saved = + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.USER, + firstname = UserFirstname("Max"), + lastname = UserLastname("Mustermann"), + username = Username("mmuster"), + email = UserEmail("max@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("secure123")), + isActive = true, + mustChangePassword = false, + ), + ) + savedUserId = saved.id.value + } + + "should return user by id" { + val presenter = TestPresenter() + + useCase.execute(GetUserRequestDto(id = savedUserId), presenter) + + presenter.error shouldBe null + presenter.response shouldNotBe null + presenter.response!!.user.id shouldBe savedUserId + presenter.response!!.user.username shouldBe "mmuster" + presenter.response!!.user.email shouldBe "max@test.de" + presenter.response!!.user.firstname shouldBe "Max" + presenter.response!!.user.lastname shouldBe "Mustermann" + presenter.response!!.user.isActive shouldBe true + presenter.response!!.user.mustChangePassword shouldBe false + } + + "should return not found for non-existent id" { + val presenter = TestPresenter() + + useCase.execute(GetUserRequestDto(id = "non-existent-id"), presenter) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should return bad request for empty id" { + val presenter = TestPresenter() + + useCase.execute(GetUserRequestDto(id = ""), presenter) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + }) diff --git a/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/GetUsersPagedUseCaseTest.kt b/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/GetUsersPagedUseCaseTest.kt new file mode 100644 index 00000000..ff598246 --- /dev/null +++ b/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/GetUsersPagedUseCaseTest.kt @@ -0,0 +1,248 @@ +package com.alleslocker.backend.application.integration.user + +import com.alleslocker.backend.application.common.ErrorResponse +import com.alleslocker.backend.application.integration.config.TestPresenter +import com.alleslocker.backend.application.integration.config.createUserTestContext +import com.alleslocker.backend.application.user.dto.filter.UserFilterDto +import com.alleslocker.backend.application.user.dto.request.GetUsersPagedRequestDto +import com.alleslocker.backend.application.user.dto.response.GetUsersPagedResponseDto +import com.alleslocker.backend.application.user.usecase.GetUsersPagedUseCase +import com.alleslocker.backend.domain.user.PasswordHash +import com.alleslocker.backend.domain.user.User +import com.alleslocker.backend.domain.user.UserEmail +import com.alleslocker.backend.domain.user.UserFirstname +import com.alleslocker.backend.domain.user.UserId +import com.alleslocker.backend.domain.user.UserLastname +import com.alleslocker.backend.domain.user.UserRole +import com.alleslocker.backend.domain.user.Username +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf + +class GetUsersPagedUseCaseTest : + FreeSpec({ + val ctx = createUserTestContext() + val useCase: GetUsersPagedUseCase = ctx.useCaseFactory.make(GetUsersPagedUseCase::class) + + beforeEach { + ctx.userGateway.deleteAll() + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.USER, + firstname = UserFirstname("Max"), + lastname = UserLastname("Mustermann"), + username = Username("mmuster"), + email = UserEmail("max@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("pass123")), + isActive = true, + mustChangePassword = false, + ), + ) + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.ADMIN, + firstname = UserFirstname("Anna"), + lastname = UserLastname("Admin"), + username = Username("aadmin"), + email = UserEmail("anna@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("pass456")), + isActive = true, + mustChangePassword = false, + ), + ) + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.USER, + firstname = UserFirstname("Tom"), + lastname = UserLastname("Test"), + username = Username("ttest"), + email = UserEmail("tom@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("pass789")), + isActive = false, + mustChangePassword = true, + ), + ) + } + + "should return all users on page 0 with size 10" { + val presenter = TestPresenter() + + useCase.execute( + GetUsersPagedRequestDto( + requesterId = "", + filter = UserFilterDto(), + page = 0, + size = 10, + ), + presenter, + ) + + presenter.error shouldBe null + presenter.response shouldNotBe null + presenter.response!! + .page.content.size shouldBe 3 + presenter.response!!.page.totalElements shouldBe 3 + presenter.response!!.page.totalPages shouldBe 1 + } + + "should paginate correctly" { + val presenter = TestPresenter() + + useCase.execute( + GetUsersPagedRequestDto( + requesterId = "", + filter = UserFilterDto(), + page = 0, + size = 1, + ), + presenter, + ) + + presenter.error shouldBe null + presenter.response!! + .page.content.size shouldBe 1 + presenter.response!!.page.totalElements shouldBe 3 + presenter.response!!.page.totalPages shouldBe 3 + presenter.response!!.page.hasNext() shouldBe true + } + + "should filter by search term (firstname)" { + val presenter = TestPresenter() + + useCase.execute( + GetUsersPagedRequestDto( + requesterId = "", + filter = UserFilterDto(search = "Max"), + page = 0, + size = 10, + ), + presenter, + ) + + presenter.error shouldBe null + presenter.response!! + .page.content.size shouldBe 1 + presenter.response!! + .page.content[0] + .username shouldBe "mmuster" + } + + "should filter by search term (email)" { + val presenter = TestPresenter() + + useCase.execute( + GetUsersPagedRequestDto( + requesterId = "", + filter = UserFilterDto(search = "anna@test.de"), + page = 0, + size = 10, + ), + presenter, + ) + + presenter.error shouldBe null + presenter.response!! + .page.content.size shouldBe 1 + presenter.response!! + .page.content[0] + .username shouldBe "aadmin" + } + + "should filter by search term (lastname)" { + val presenter = TestPresenter() + + useCase.execute( + GetUsersPagedRequestDto( + requesterId = "", + filter = UserFilterDto(search = "Mustermann"), + page = 0, + size = 10, + ), + presenter, + ) + + presenter.error shouldBe null + presenter.response!! + .page.content.size shouldBe 1 + presenter.response!! + .page.content[0] + .username shouldBe "mmuster" + } + + "should return empty page when filter matches nothing" { + val presenter = TestPresenter() + + useCase.execute( + GetUsersPagedRequestDto( + requesterId = "", + filter = UserFilterDto(search = "xxxxxxxxx"), + page = 0, + size = 10, + ), + presenter, + ) + + presenter.error shouldBe null + presenter.response!! + .page.content.size shouldBe 0 + presenter.response!!.page.totalElements shouldBe 0 + } + + "should reject negative page" { + val presenter = TestPresenter() + + useCase.execute( + GetUsersPagedRequestDto( + requesterId = "", + filter = UserFilterDto(), + page = -1, + size = 10, + ), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should reject size of 0" { + val presenter = TestPresenter() + + useCase.execute( + GetUsersPagedRequestDto( + requesterId = "", + filter = UserFilterDto(), + page = 0, + size = 0, + ), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should reject size greater than 500" { + val presenter = TestPresenter() + + useCase.execute( + GetUsersPagedRequestDto( + requesterId = "", + filter = UserFilterDto(), + page = 0, + size = 501, + ), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + }) diff --git a/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/LoginUserUseCaseTest.kt b/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/LoginUserUseCaseTest.kt new file mode 100644 index 00000000..7785d11a --- /dev/null +++ b/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/LoginUserUseCaseTest.kt @@ -0,0 +1,84 @@ +package com.alleslocker.backend.application.integration.user + +import com.alleslocker.backend.application.common.ErrorResponse +import com.alleslocker.backend.application.integration.config.TestPresenter +import com.alleslocker.backend.application.integration.config.createUserTestContext +import com.alleslocker.backend.application.user.dto.request.LoginUserRequestDto +import com.alleslocker.backend.application.user.dto.response.LoginUserResponseDto +import com.alleslocker.backend.application.user.usecase.LoginUserUseCase +import com.alleslocker.backend.domain.user.PasswordHash +import com.alleslocker.backend.domain.user.User +import com.alleslocker.backend.domain.user.UserEmail +import com.alleslocker.backend.domain.user.UserFirstname +import com.alleslocker.backend.domain.user.UserId +import com.alleslocker.backend.domain.user.UserLastname +import com.alleslocker.backend.domain.user.UserRole +import com.alleslocker.backend.domain.user.Username +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf + +class LoginUserUseCaseTest : + FreeSpec({ + val ctx = createUserTestContext() + val useCase: LoginUserUseCase = ctx.useCaseFactory.make(LoginUserUseCase::class) + + beforeEach { + ctx.userGateway.deleteAll() + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.USER, + firstname = UserFirstname("Max"), + lastname = UserLastname("Mustermann"), + username = Username("mmuster"), + email = UserEmail("max@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("secure123")), + isActive = true, + mustChangePassword = false, + ), + ) + } + + "should login with correct credentials" { + val presenter = TestPresenter() + + useCase.execute(LoginUserRequestDto(username = "mmuster", password = "secure123"), presenter) + + presenter.error shouldBe null + presenter.response shouldNotBe null + presenter.response!!.userId shouldNotBe "" + } + + "should fail with wrong password" { + val presenter = TestPresenter() + + useCase.execute(LoginUserRequestDto(username = "mmuster", password = "wrong"), presenter) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + presenter.error!!.message shouldBe "Invalid password" + } + + "should fail with non-existent username" { + val presenter = TestPresenter() + + useCase.execute(LoginUserRequestDto(username = "nobody", password = "secure123"), presenter) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + presenter.error!!.message shouldBe "User doesn't exist" + } + + "should fail with empty username" { + val presenter = TestPresenter() + + useCase.execute(LoginUserRequestDto(username = "", password = "secure123"), presenter) + + presenter.response shouldBe null + presenter.error shouldNotBe null + } + }) diff --git a/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/RequestUserPasswordChangeUseCaseTest.kt b/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/RequestUserPasswordChangeUseCaseTest.kt new file mode 100644 index 00000000..f88a44da --- /dev/null +++ b/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/RequestUserPasswordChangeUseCaseTest.kt @@ -0,0 +1,147 @@ +package com.alleslocker.backend.application.integration.user + +import com.alleslocker.backend.application.common.ErrorResponse +import com.alleslocker.backend.application.integration.config.TestPresenter +import com.alleslocker.backend.application.integration.config.createUserTestContext +import com.alleslocker.backend.application.user.dto.request.RequestUserPasswordChangeRequestDto +import com.alleslocker.backend.application.user.usecase.RequestUserPasswordChangeUseCase +import com.alleslocker.backend.domain.user.PasswordHash +import com.alleslocker.backend.domain.user.User +import com.alleslocker.backend.domain.user.UserEmail +import com.alleslocker.backend.domain.user.UserFirstname +import com.alleslocker.backend.domain.user.UserId +import com.alleslocker.backend.domain.user.UserLastname +import com.alleslocker.backend.domain.user.UserRole +import com.alleslocker.backend.domain.user.Username +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.types.shouldBeInstanceOf + +class RequestUserPasswordChangeUseCaseTest : + FreeSpec({ + val ctx = createUserTestContext() + val useCase: RequestUserPasswordChangeUseCase = ctx.useCaseFactory.make(RequestUserPasswordChangeUseCase::class) + + lateinit var adminId: String + lateinit var targetUserId: String + + beforeEach { + ctx.userGateway.deleteAll() + ctx.logger.clear() + val admin = + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.ADMIN, + firstname = UserFirstname("Admin"), + lastname = UserLastname("User"), + username = Username("admin"), + email = UserEmail("admin@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("admin123")), + isActive = true, + mustChangePassword = false, + ), + ) + adminId = admin.id.value + + val targetUser = + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.USER, + firstname = UserFirstname("Max"), + lastname = UserLastname("Mustermann"), + username = Username("mmuster"), + email = UserEmail("max@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("pass123")), + isActive = true, + mustChangePassword = false, + ), + ) + targetUserId = targetUser.id.value + } + + "should request password change for user" { + val presenter = TestPresenter() + + useCase.execute( + RequestUserPasswordChangeRequestDto(requestorId = adminId, userId = targetUserId), + presenter, + ) + + presenter.error shouldBe null + val updatedUser = ctx.userGateway.findById(UserId(targetUserId)) + updatedUser!!.mustChangePassword shouldBe true + ctx.logger.auditLogs.size shouldBe 1 + } + + "should reject if user already requested to change password" { + ctx.userGateway.save( + ctx.userGateway.findById(UserId(targetUserId))!!.copy(mustChangePassword = true), + ) + + val presenter = TestPresenter() + useCase.execute( + RequestUserPasswordChangeRequestDto(requestorId = adminId, userId = targetUserId), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + presenter.error!!.message shouldContain "already requested" + } + + "should reject request by non-admin" { + val regularUser = + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.USER, + firstname = UserFirstname("Regular"), + lastname = UserLastname("User"), + username = Username("regular"), + email = UserEmail("regular@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("pass123")), + isActive = true, + mustChangePassword = false, + ), + ) + + val presenter = TestPresenter() + useCase.execute( + RequestUserPasswordChangeRequestDto(requestorId = regularUser.id.value, userId = targetUserId), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should reject request for non-existent user" { + val presenter = TestPresenter() + useCase.execute( + RequestUserPasswordChangeRequestDto(requestorId = adminId, userId = "non-existent-id"), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + + "should reject request with non-existent requestor" { + val presenter = TestPresenter() + useCase.execute( + RequestUserPasswordChangeRequestDto(requestorId = "non-existent-id", userId = targetUserId), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + }) diff --git a/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/ResetPasswordUserUseCaseTest.kt b/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/ResetPasswordUserUseCaseTest.kt new file mode 100644 index 00000000..be4df8e7 --- /dev/null +++ b/application/src/test/kotlin/com/alleslocker/backend/application/integration/user/ResetPasswordUserUseCaseTest.kt @@ -0,0 +1,104 @@ +package com.alleslocker.backend.application.integration.user + +import com.alleslocker.backend.application.common.ErrorResponse +import com.alleslocker.backend.application.integration.config.TestPresenter +import com.alleslocker.backend.application.integration.config.createUserTestContext +import com.alleslocker.backend.application.user.dto.request.ResetPasswordUserRequestDto +import com.alleslocker.backend.application.user.dto.response.ResetPasswordUserResponseDto +import com.alleslocker.backend.application.user.usecase.ResetPasswordUserUseCase +import com.alleslocker.backend.domain.user.PasswordHash +import com.alleslocker.backend.domain.user.User +import com.alleslocker.backend.domain.user.UserEmail +import com.alleslocker.backend.domain.user.UserFirstname +import com.alleslocker.backend.domain.user.UserId +import com.alleslocker.backend.domain.user.UserLastname +import com.alleslocker.backend.domain.user.UserRole +import com.alleslocker.backend.domain.user.Username +import io.kotest.core.spec.style.FreeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.types.shouldBeInstanceOf + +class ResetPasswordUserUseCaseTest : + FreeSpec({ + val ctx = createUserTestContext() + val useCase: ResetPasswordUserUseCase = ctx.useCaseFactory.make(ResetPasswordUserUseCase::class) + + lateinit var userId: String + + beforeEach { + ctx.userGateway.deleteAll() + val saved = + ctx.userGateway.save( + User( + id = UserId.generate(), + role = UserRole.USER, + firstname = UserFirstname("Max"), + lastname = UserLastname("Mustermann"), + username = Username("mmuster"), + email = UserEmail("max@test.de"), + passwordHash = PasswordHash(ctx.passwordHasher.hash("oldPass123")), + isActive = true, + mustChangePassword = false, + ), + ) + userId = saved.id.value + } + + "should reset password with correct old password" { + val presenter = TestPresenter() + + useCase.execute( + ResetPasswordUserRequestDto( + requestorId = userId, + oldPassword = "oldPass123", + newPassword = "newPass456", + ), + presenter, + ) + + presenter.error shouldBe null + presenter.response shouldNotBe null + presenter.response!!.userId shouldBe userId + + val updatedUser = ctx.userGateway.findById(UserId(userId)) + updatedUser!!.mustChangePassword shouldBe false + ctx.passwordHasher.verify("newPass456", updatedUser.passwordHash.value) shouldBe true + ctx.passwordHasher.verify("oldPass123", updatedUser.passwordHash.value) shouldBe false + } + + "should reject wrong old password" { + val presenter = TestPresenter() + + useCase.execute( + ResetPasswordUserRequestDto( + requestorId = userId, + oldPassword = "wrongPassword", + newPassword = "newPass456", + ), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + presenter.error!!.message shouldBe "Invalid old password" + } + + "should reject reset for non-existent user" { + val presenter = TestPresenter() + + useCase.execute( + ResetPasswordUserRequestDto( + requestorId = "non-existent-id", + oldPassword = "oldPass123", + newPassword = "newPass456", + ), + presenter, + ) + + presenter.response shouldBe null + presenter.error shouldNotBe null + presenter.error!!.shouldBeInstanceOf() + } + }) diff --git a/bootstrap/build.gradle b/bootstrap/build.gradle index e75334a1..37f9a9a4 100644 --- a/bootstrap/build.gradle +++ b/bootstrap/build.gradle @@ -15,9 +15,5 @@ dependencies { implementation(libs.spring.boot.jpa) implementation(libs.mariadb.java.client) - testImplementation(libs.konsist) - testImplementation(libs.kotest.engine) - testImplementation(libs.kotest.assertions) - testImplementation(libs.kotest.runner) testImplementation(libs.kotest.spring) } diff --git a/web/src/main/kotlin/com/alleslocker/backend/web/user/controller/UserController.kt b/web/src/main/kotlin/com/alleslocker/backend/web/user/controller/UserController.kt index 4168bba2..ed69453b 100644 --- a/web/src/main/kotlin/com/alleslocker/backend/web/user/controller/UserController.kt +++ b/web/src/main/kotlin/com/alleslocker/backend/web/user/controller/UserController.kt @@ -5,7 +5,6 @@ import com.alleslocker.backend.application.common.factory.UseCaseFactory import com.alleslocker.backend.application.user.usecase.ActivateUserUseCase import com.alleslocker.backend.application.user.usecase.AdminResetPasswordUserUseCase import com.alleslocker.backend.application.user.usecase.ChangeUserRoleUseCase -import com.alleslocker.backend.application.user.usecase.ChangeUserRoleUseCaseImpl import com.alleslocker.backend.application.user.usecase.CreateUserUseCase import com.alleslocker.backend.application.user.usecase.DeactivateUserUseCase import com.alleslocker.backend.application.user.usecase.EditUserUseCase