(fix) Umbau zu SCS

This commit is contained in:
stefan
2025-07-21 12:08:20 +02:00
parent 83d0d81193
commit 62b5e71427
34 changed files with 3403 additions and 20 deletions
+1
View File
@@ -17,6 +17,7 @@ kotlin {
implementation(project(":master-data"))
implementation(project(":member-management"))
implementation(project(":horse-registry"))
implementation(project(":event-management"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
@@ -0,0 +1,330 @@
package at.mocode.gateway.config
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.server.response.*
import io.ktor.http.*
import io.ktor.server.routing.*
import io.ktor.util.pipeline.*
import at.mocode.enums.RolleE
import at.mocode.enums.BerechtigungE
/**
* Authorization configuration and middleware for role-based access control.
*
* Provides utilities for checking user roles and permissions on protected endpoints.
*/
/**
* Enum representing user roles in the system.
*/
enum class UserRole {
ADMIN,
VEREINS_ADMIN,
FUNKTIONAER,
REITER,
TRAINER,
RICHTER,
TIERARZT,
ZUSCHAUER,
GAST
}
/**
* Enum representing permissions in the system.
*/
enum class Permission {
// Person management
PERSON_READ,
PERSON_CREATE,
PERSON_UPDATE,
PERSON_DELETE,
// Club management
VEREIN_READ,
VEREIN_CREATE,
VEREIN_UPDATE,
VEREIN_DELETE,
// Event management
VERANSTALTUNG_READ,
VERANSTALTUNG_CREATE,
VERANSTALTUNG_UPDATE,
VERANSTALTUNG_DELETE,
// Horse management
PFERD_READ,
PFERD_CREATE,
PFERD_UPDATE,
PFERD_DELETE,
// Master data management
STAMMDATEN_READ,
STAMMDATEN_UPDATE,
// System administration
SYSTEM_ADMIN,
BENUTZER_VERWALTEN,
ROLLEN_VERWALTEN
}
/**
* Data class representing user authorization context.
*/
data class UserAuthContext(
val userId: String,
val username: String,
val roles: List<UserRole>,
val permissions: List<Permission>
)
/**
* Maps domain role enum to authorization role enum.
*/
private fun mapDomainRoleToUserRole(domainRole: RolleE): UserRole {
return when (domainRole) {
RolleE.ADMIN -> UserRole.ADMIN
RolleE.VEREINS_ADMIN -> UserRole.VEREINS_ADMIN
RolleE.FUNKTIONAER -> UserRole.FUNKTIONAER
RolleE.REITER -> UserRole.REITER
RolleE.TRAINER -> UserRole.TRAINER
RolleE.RICHTER -> UserRole.RICHTER
RolleE.TIERARZT -> UserRole.TIERARZT
RolleE.ZUSCHAUER -> UserRole.ZUSCHAUER
RolleE.GAST -> UserRole.GAST
}
}
/**
* Maps domain permission enum to authorization permission enum.
*/
private fun mapDomainPermissionToPermission(domainPermission: BerechtigungE): Permission {
return when (domainPermission) {
BerechtigungE.PERSON_READ -> Permission.PERSON_READ
BerechtigungE.PERSON_CREATE -> Permission.PERSON_CREATE
BerechtigungE.PERSON_UPDATE -> Permission.PERSON_UPDATE
BerechtigungE.PERSON_DELETE -> Permission.PERSON_DELETE
BerechtigungE.VEREIN_READ -> Permission.VEREIN_READ
BerechtigungE.VEREIN_CREATE -> Permission.VEREIN_CREATE
BerechtigungE.VEREIN_UPDATE -> Permission.VEREIN_UPDATE
BerechtigungE.VEREIN_DELETE -> Permission.VEREIN_DELETE
BerechtigungE.VERANSTALTUNG_READ -> Permission.VERANSTALTUNG_READ
BerechtigungE.VERANSTALTUNG_CREATE -> Permission.VERANSTALTUNG_CREATE
BerechtigungE.VERANSTALTUNG_UPDATE -> Permission.VERANSTALTUNG_UPDATE
BerechtigungE.VERANSTALTUNG_DELETE -> Permission.VERANSTALTUNG_DELETE
BerechtigungE.PFERD_READ -> Permission.PFERD_READ
BerechtigungE.PFERD_CREATE -> Permission.PFERD_CREATE
BerechtigungE.PFERD_UPDATE -> Permission.PFERD_UPDATE
BerechtigungE.PFERD_DELETE -> Permission.PFERD_DELETE
BerechtigungE.STAMMDATEN_READ -> Permission.STAMMDATEN_READ
BerechtigungE.STAMMDATEN_UPDATE -> Permission.STAMMDATEN_UPDATE
BerechtigungE.SYSTEM_ADMIN -> Permission.SYSTEM_ADMIN
BerechtigungE.BENUTZER_VERWALTEN -> Permission.BENUTZER_VERWALTEN
BerechtigungE.ROLLEN_VERWALTEN -> Permission.ROLLEN_VERWALTEN
}
}
/**
* Extension function to get user authorization context from JWT principal.
*/
fun JWTPrincipal.getUserAuthContext(): UserAuthContext? {
val userId = getClaim("userId", String::class) ?: return null
val username = getClaim("username", String::class) ?: return null
// Get roles and permissions from JWT token
val domainRoles = getClaim("roles", Array<RolleE>::class)?.toList() ?: emptyList()
val domainPermissions = getClaim("permissions", Array<BerechtigungE>::class)?.toList() ?: emptyList()
// Map domain enums to authorization enums
val roles = domainRoles.map { mapDomainRoleToUserRole(it) }
val permissions = domainPermissions.map { mapDomainPermissionToPermission(it) }
return UserAuthContext(
userId = userId,
username = username,
roles = roles,
permissions = permissions
)
}
/**
* Maps roles to their corresponding permissions.
*/
private fun getRolePermissions(roles: List<UserRole>): List<Permission> {
val permissions = mutableSetOf<Permission>()
roles.forEach { role ->
when (role) {
UserRole.ADMIN -> {
permissions.addAll(Permission.values())
}
UserRole.VEREINS_ADMIN -> {
permissions.addAll(listOf(
Permission.PERSON_READ, Permission.PERSON_CREATE, Permission.PERSON_UPDATE,
Permission.VEREIN_READ, Permission.VEREIN_UPDATE,
Permission.PFERD_READ, Permission.PFERD_CREATE, Permission.PFERD_UPDATE,
Permission.STAMMDATEN_READ
))
}
UserRole.FUNKTIONAER -> {
permissions.addAll(listOf(
Permission.PERSON_READ,
Permission.VEREIN_READ,
Permission.VERANSTALTUNG_READ, Permission.VERANSTALTUNG_CREATE, Permission.VERANSTALTUNG_UPDATE,
Permission.PFERD_READ,
Permission.STAMMDATEN_READ
))
}
UserRole.TRAINER -> {
permissions.addAll(listOf(
Permission.PERSON_READ,
Permission.VEREIN_READ,
Permission.VERANSTALTUNG_READ,
Permission.PFERD_READ,
Permission.STAMMDATEN_READ
))
}
UserRole.REITER -> {
permissions.addAll(listOf(
Permission.PERSON_READ,
Permission.VEREIN_READ,
Permission.VERANSTALTUNG_READ,
Permission.PFERD_READ,
Permission.STAMMDATEN_READ
))
}
UserRole.RICHTER -> {
permissions.addAll(listOf(
Permission.PERSON_READ,
Permission.VEREIN_READ,
Permission.VERANSTALTUNG_READ,
Permission.PFERD_READ,
Permission.STAMMDATEN_READ
))
}
UserRole.TIERARZT -> {
permissions.addAll(listOf(
Permission.PERSON_READ,
Permission.PFERD_READ,
Permission.STAMMDATEN_READ
))
}
UserRole.ZUSCHAUER -> {
permissions.addAll(listOf(
Permission.VERANSTALTUNG_READ,
Permission.STAMMDATEN_READ
))
}
UserRole.GAST -> {
permissions.addAll(listOf(
Permission.STAMMDATEN_READ
))
}
}
}
return permissions.toList()
}
/**
* Route extension function to require specific roles.
*/
fun Route.requireRoles(vararg roles: UserRole, build: Route.() -> Unit): Route {
val route = createChild(object : RouteSelector() {
override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation {
return RouteSelectorEvaluation.Constant
}
override fun toString(): String = "requireRoles(${roles.joinToString()})"
})
route.intercept(ApplicationCallPipeline.Call) {
val principal = call.principal<JWTPrincipal>()
val authContext = principal?.getUserAuthContext()
if (authContext == null) {
call.respond(HttpStatusCode.Unauthorized, "Authentication required")
finish()
return@intercept
}
val hasRequiredRole = roles.any { requiredRole ->
authContext.roles.contains(requiredRole)
}
if (!hasRequiredRole) {
call.respond(
HttpStatusCode.Forbidden,
"Access denied. Required roles: ${roles.joinToString()}"
)
finish()
return@intercept
}
}
route.build()
return route
}
/**
* Route extension function to require specific permissions.
*/
fun Route.requirePermissions(vararg permissions: Permission, build: Route.() -> Unit): Route {
val route = createChild(object : RouteSelector() {
override suspend fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation {
return RouteSelectorEvaluation.Constant
}
override fun toString(): String = "requirePermissions(${permissions.joinToString()})"
})
route.intercept(ApplicationCallPipeline.Call) {
val principal = call.principal<JWTPrincipal>()
val authContext = principal?.getUserAuthContext()
if (authContext == null) {
call.respond(HttpStatusCode.Unauthorized, "Authentication required")
finish()
return@intercept
}
val hasAllPermissions = permissions.all { requiredPermission ->
authContext.permissions.contains(requiredPermission)
}
if (!hasAllPermissions) {
call.respond(
HttpStatusCode.Forbidden,
"Access denied. Required permissions: ${permissions.joinToString()}"
)
finish()
return@intercept
}
}
route.build()
return route
}
/**
* Pipeline context extension to get current user authorization context.
*/
val PipelineContext<Unit, ApplicationCall>.userAuthContext: UserAuthContext?
get() = call.principal<JWTPrincipal>()?.getUserAuthContext()
/**
* Application call extension to check if user has specific role.
*/
fun ApplicationCall.hasRole(role: UserRole): Boolean {
val authContext = principal<JWTPrincipal>()?.getUserAuthContext()
return authContext?.roles?.contains(role) == true
}
/**
* Application call extension to check if user has specific permission.
*/
fun ApplicationCall.hasPermission(permission: Permission): Boolean {
val authContext = principal<JWTPrincipal>()?.getUserAuthContext()
return authContext?.permissions?.contains(permission) == true
}
@@ -0,0 +1,62 @@
package at.mocode.gateway.config
import io.ktor.server.application.*
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory
/**
* Database configuration for the API Gateway.
*
* Sets up database connections and schema initialization for all bounded contexts.
*/
fun Application.configureDatabase() {
val log = LoggerFactory.getLogger("DatabaseConfig")
val databaseUrl = environment.config.propertyOrNull("database.url")?.getString()
?: "jdbc:postgresql://localhost:5432/meldestelle"
val databaseUser = environment.config.propertyOrNull("database.user")?.getString()
?: "meldestelle_user"
val databasePassword = environment.config.propertyOrNull("database.password")?.getString()
?: "meldestelle_password"
// Initialize database connection
Database.connect(
url = databaseUrl,
driver = "org.postgresql.Driver",
user = databaseUser,
password = databasePassword
)
// Initialize database schemas for all contexts
transaction {
// Import table definitions from all contexts
try {
// Master Data Context tables
SchemaUtils.createMissingTablesAndColumns(
at.mocode.masterdata.infrastructure.repository.LandTable
)
// Member Management Context tables
SchemaUtils.createMissingTablesAndColumns(
at.mocode.members.infrastructure.repository.PersonTable,
at.mocode.members.infrastructure.repository.VereinTable
)
// Horse Registry Context tables
SchemaUtils.createMissingTablesAndColumns(
at.mocode.horses.infrastructure.repository.HorseTable
)
// Event Management Context tables
SchemaUtils.createMissingTablesAndColumns(
at.mocode.events.infrastructure.repository.VeranstaltungTable
)
log.info("Database schemas initialized successfully")
} catch (e: Exception) {
log.error("Failed to initialize database schemas: ${e.message}")
// In production, you might want to fail fast here
}
}
}
@@ -0,0 +1,59 @@
package at.mocode.gateway.config
import io.ktor.server.application.*
import io.ktor.server.plugins.calllogging.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.request.*
import io.ktor.http.*
import io.ktor.server.response.*
import at.mocode.dto.base.ApiResponse
import org.slf4j.event.Level
/**
* Monitoring and logging configuration for the API Gateway.
*
* Configures request logging, error handling, and status pages.
*/
fun Application.configureMonitoring() {
install(CallLogging) {
level = Level.INFO
filter { call -> call.request.path().startsWith("/api") }
format { call ->
val status = call.response.status()
val httpMethod = call.request.httpMethod.value
val userAgent = call.request.headers["User-Agent"]
"$status: $httpMethod ${call.request.path()} - $userAgent"
}
}
install(StatusPages) {
exception<Throwable> { call, cause ->
call.application.log.error("Unhandled exception", cause)
call.respond(
HttpStatusCode.InternalServerError,
ApiResponse.error<Any>("Internal server error: ${cause.message}")
)
}
status(HttpStatusCode.NotFound) { call, status ->
call.respond(
status,
ApiResponse.error<Any>("Endpoint not found: ${call.request.path()}")
)
}
status(HttpStatusCode.Unauthorized) { call, status ->
call.respond(
status,
ApiResponse.error<Any>("Authentication required")
)
}
status(HttpStatusCode.Forbidden) { call, status ->
call.respond(
status,
ApiResponse.error<Any>("Access forbidden")
)
}
}
}
@@ -0,0 +1,35 @@
package at.mocode.gateway.config
import io.ktor.server.application.*
import io.ktor.server.plugins.openapi.*
import io.ktor.server.plugins.swagger.*
import io.ktor.server.routing.*
/**
* Configuration for OpenAPI/Swagger documentation.
*
* This module configures the OpenAPI specification generation and Swagger UI
* for the API Gateway, providing comprehensive API documentation.
*/
fun Application.configureOpenApi() {
// Configure OpenAPI using a static file
routing {
// Serve the OpenAPI specification from a file
openAPI(path = "openapi", swaggerFile = "openapi/documentation.yaml") {
// Additional configuration can be added here if needed
}
}
}
/**
* Configuration for Swagger UI.
*
* Provides an interactive web interface for exploring and testing the API.
*/
fun Application.configureSwagger() {
routing {
swaggerUI(path = "swagger", swaggerFile = "openapi/documentation.yaml") {
version = "4.15.5"
}
}
}
@@ -0,0 +1,85 @@
package at.mocode.gateway.config
import io.ktor.server.application.*
import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.http.*
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.server.response.respond
/**
* Security configuration for the API Gateway.
*
* Configures CORS, JWT authentication, and other security-related settings.
*/
fun Application.configureSecurity() {
install(CORS) {
allowMethod(HttpMethod.Options)
allowMethod(HttpMethod.Put)
allowMethod(HttpMethod.Delete)
allowMethod(HttpMethod.Patch)
allowHeader(HttpHeaders.Authorization)
allowHeader(HttpHeaders.ContentType)
allowHeader("X-Requested-With")
// Allow requests from common development origins
allowHost("localhost:3000")
allowHost("localhost:8080")
allowHost("127.0.0.1:3000")
allowHost("127.0.0.1:8080")
// In production, configure specific allowed origins
anyHost() // This should be restricted in production
}
// JWT Configuration
val jwtConfig = JwtConfig.fromEnvironment()
install(Authentication) {
jwt("auth-jwt") {
realm = jwtConfig.realm
verifier(
JWT
.require(Algorithm.HMAC512(jwtConfig.secret))
.withAudience(jwtConfig.audience)
.withIssuer(jwtConfig.issuer)
.build()
)
validate { credential ->
if (credential.payload.getClaim("userId").asString() != null) {
JWTPrincipal(credential.payload)
} else {
null
}
}
challenge { defaultScheme, realm ->
call.respond(HttpStatusCode.Unauthorized, "Token is not valid or has expired")
}
}
}
}
/**
* JWT Configuration data class.
*/
data class JwtConfig(
val secret: String,
val issuer: String,
val audience: String,
val realm: String,
val expirationTime: Long = 3600000L // 1 hour in milliseconds
) {
companion object {
fun fromEnvironment(): JwtConfig {
return JwtConfig(
secret = System.getenv("JWT_SECRET") ?: "default-secret-key-change-in-production",
issuer = System.getenv("JWT_ISSUER") ?: "meldestelle-api",
audience = System.getenv("JWT_AUDIENCE") ?: "meldestelle-users",
realm = System.getenv("JWT_REALM") ?: "Meldestelle API",
expirationTime = System.getenv("JWT_EXPIRATION")?.toLongOrNull() ?: 3600000L
)
}
}
}
@@ -0,0 +1,23 @@
package at.mocode.gateway.config
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import kotlinx.serialization.json.Json
/**
* Serialization configuration for the API Gateway.
*
* Configures JSON serialization settings that are consistent across all bounded contexts.
*/
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
encodeDefaults = true
explicitNulls = false
})
}
}
@@ -0,0 +1,276 @@
package at.mocode.gateway.test
import kotlinx.coroutines.runBlocking
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.net.URI
import java.time.Duration
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Test class for authentication and authorization functionality.
*
* This test verifies the complete authentication and authorization flow:
* 1. User registration
* 2. User login
* 3. Access to protected endpoints
* 4. Token refresh
* 5. Password change
* 6. Logout
*/
class AuthenticationAuthorizationTest {
private val baseUrl = "http://localhost:8080"
private val client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build()
@Test
fun testAuthenticationFlow() = runBlocking {
println("🚀 Starting Authentication and Authorization Tests")
println("=" * 60)
try {
// Test 1: Health Check
println("\n📋 Test 1: API Health Check")
testHealthCheck()
// Test 2: User Registration
println("\n📝 Test 2: User Registration")
testUserRegistration()
// Test 3: User Login
println("\n🔐 Test 3: User Login")
val token = testUserLogin()
if (token != null) {
// Test 4: Access Protected Profile Endpoint
println("\n👤 Test 4: Access Protected Profile")
testProtectedProfile(token)
// Test 5: Token Refresh
println("\n🔄 Test 5: Token Refresh")
val newToken = testTokenRefresh(token)
// Test 6: Change Password
println("\n🔑 Test 6: Change Password")
testChangePassword(newToken ?: token)
// Test 7: Logout
println("\n👋 Test 7: Logout")
testLogout(newToken ?: token)
}
println("\n✅ All tests completed!")
} catch (e: Exception) {
println("\n❌ Test failed with error: ${e.message}")
e.printStackTrace()
throw e
}
}
private suspend fun testHealthCheck() {
val request = HttpRequest.newBuilder()
.uri(URI.create("$baseUrl/health"))
.GET()
.build()
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
if (response.statusCode() == 200) {
println("✅ Health check passed")
println(" Response: ${response.body()}")
assertEquals(200, response.statusCode(), "Health check should return 200 OK")
} else {
println("❌ Health check failed: ${response.statusCode()}")
println(" Response: ${response.body()}")
assertEquals(200, response.statusCode(), "Health check should return 200 OK")
}
}
private suspend fun testUserRegistration() {
val registrationData = """
{
"personId": "550e8400-e29b-41d4-a716-446655440000",
"username": "testuser_${System.currentTimeMillis()}",
"email": "test_${System.currentTimeMillis()}@example.com",
"password": "SecurePassword123!"
}
""".trimIndent()
val request = HttpRequest.newBuilder()
.uri(URI.create("$baseUrl/auth/register"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(registrationData))
.build()
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
if (response.statusCode() == 201) {
println("✅ User registration successful")
println(" Response: ${response.body()}")
assertEquals(201, response.statusCode(), "User registration should return 201 Created")
} else {
println("⚠️ User registration response: ${response.statusCode()}")
println(" Response: ${response.body()}")
println(" Note: This might be expected if registration requires existing person ID")
// Don't assert here as registration might fail for valid reasons in test environment
}
}
private suspend fun testUserLogin(): String? {
// Try to login with a test user (this assumes there's already a user in the system)
val loginData = """
{
"usernameOrEmail": "admin",
"password": "admin123"
}
""".trimIndent()
val request = HttpRequest.newBuilder()
.uri(URI.create("$baseUrl/auth/login"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(loginData))
.build()
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
if (response.statusCode() == 200) {
println("✅ User login successful")
println(" Response: ${response.body()}")
// Extract token from response (simplified - in real scenario, parse JSON)
val responseBody = response.body()
val tokenStart = responseBody.indexOf("\"token\":\"") + 9
val tokenEnd = responseBody.indexOf("\"", tokenStart)
return if (tokenStart > 8 && tokenEnd > tokenStart) {
val token = responseBody.substring(tokenStart, tokenEnd)
println(" Token extracted: ${token.take(20)}...")
assertNotNull(token, "Token should not be null")
assertTrue(token.isNotEmpty(), "Token should not be empty")
token
} else {
println(" Could not extract token from response")
null
}
} else {
println("⚠️ User login failed: ${response.statusCode()}")
println(" Response: ${response.body()}")
println(" Note: This is expected if no test user exists in the database")
return null
}
}
private suspend fun testProtectedProfile(token: String) {
val request = HttpRequest.newBuilder()
.uri(URI.create("$baseUrl/auth/profile"))
.header("Authorization", "Bearer $token")
.GET()
.build()
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
if (response.statusCode() == 200) {
println("✅ Protected profile access successful")
println(" Response: ${response.body()}")
assertEquals(200, response.statusCode(), "Protected profile access should return 200 OK")
} else {
println("❌ Protected profile access failed: ${response.statusCode()}")
println(" Response: ${response.body()}")
assertEquals(200, response.statusCode(), "Protected profile access should return 200 OK")
}
}
private suspend fun testTokenRefresh(token: String): String? {
val request = HttpRequest.newBuilder()
.uri(URI.create("$baseUrl/auth/refresh"))
.header("Authorization", "Bearer $token")
.POST(HttpRequest.BodyPublishers.noBody())
.build()
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
if (response.statusCode() == 200) {
println("✅ Token refresh successful")
println(" Response: ${response.body()}")
// Extract new token from response (simplified)
val responseBody = response.body()
val tokenStart = responseBody.indexOf("\"token\":\"") + 9
val tokenEnd = responseBody.indexOf("\"", tokenStart)
return if (tokenStart > 8 && tokenEnd > tokenStart) {
val newToken = responseBody.substring(tokenStart, tokenEnd)
println(" New token extracted: ${newToken.take(20)}...")
assertNotNull(newToken, "New token should not be null")
assertTrue(newToken.isNotEmpty(), "New token should not be empty")
newToken
} else {
println(" Could not extract new token from response")
null
}
} else {
println("❌ Token refresh failed: ${response.statusCode()}")
println(" Response: ${response.body()}")
return null
}
}
private suspend fun testChangePassword(token: String) {
val changePasswordData = """
{
"currentPassword": "admin123",
"newPassword": "NewSecurePassword123!"
}
""".trimIndent()
val request = HttpRequest.newBuilder()
.uri(URI.create("$baseUrl/auth/change-password"))
.header("Authorization", "Bearer $token")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(changePasswordData))
.build()
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
if (response.statusCode() == 200) {
println("✅ Password change successful")
println(" Response: ${response.body()}")
assertEquals(200, response.statusCode(), "Password change should return 200 OK")
} else {
println("⚠️ Password change response: ${response.statusCode()}")
println(" Response: ${response.body()}")
println(" Note: This might fail if current password is incorrect")
// Don't assert here as password change might fail for valid reasons in test environment
}
}
private suspend fun testLogout(token: String) {
val request = HttpRequest.newBuilder()
.uri(URI.create("$baseUrl/auth/logout"))
.header("Authorization", "Bearer $token")
.POST(HttpRequest.BodyPublishers.noBody())
.build()
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
if (response.statusCode() == 200) {
println("✅ Logout successful")
println(" Response: ${response.body()}")
assertEquals(200, response.statusCode(), "Logout should return 200 OK")
} else {
println("❌ Logout failed: ${response.statusCode()}")
println(" Response: ${response.body()}")
assertEquals(200, response.statusCode(), "Logout should return 200 OK")
}
}
}
// Extension function for string repetition
operator fun String.times(n: Int): String = this.repeat(n)