style: fix missing assertion imports in ServerTest
Some checks failed
CI Pipeline / lint-and-test (pull_request) Failing after 43s

This commit is contained in:
Oliver Wallisch 2026-09-11 22:38:56 +02:00
parent 809d559562
commit 0d8fdec26a
10 changed files with 450 additions and 373 deletions

4
.editorconfig Normal file
View file

@ -0,0 +1,4 @@
[*.{kt,kts}]
# Erlaubt Wildcard-Imports für das gesamte Projekt:
ij_kotlin_allow_trailing_comma = true
disabled_rules = no-wildcard-imports

View file

@ -1,12 +1,13 @@
import com.oliver.kidio.backend.plugins.configureRouting
import com.oliver.kidio.backend.data.database.DatabaseFactory import com.oliver.kidio.backend.data.database.DatabaseFactory
import com.oliver.kidio.backend.data.database.RedisFactory import com.oliver.kidio.backend.data.database.RedisFactory
import com.oliver.kidio.backend.domain.repository.ExposedKidioRepository import com.oliver.kidio.backend.domain.repository.ExposedKidioRepository
import com.oliver.kidio.backend.plugins.configureRouting
import com.oliver.kidio.backend.plugins.configureSecurity import com.oliver.kidio.backend.plugins.configureSecurity
import com.oliver.kidio.backend.security.JwtService import com.oliver.kidio.backend.security.JwtService
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.* import io.ktor.server.application.Application
import io.ktor.server.plugins.contentnegotiation.* import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
fun Application.module() { fun Application.module() {
// Datenbank initialisieren // Datenbank initialisieren
@ -25,6 +26,6 @@ fun Application.module() {
configureRouting( configureRouting(
repository = repository, repository = repository,
jwtService = jwtService jwtService = jwtService,
) )
} }

View file

@ -22,6 +22,7 @@ object ScreentimeTable : Table("user_screentime") {
override val primaryKey = PrimaryKey(userId) override val primaryKey = PrimaryKey(userId)
} }
object UserTable : Table("users") { object UserTable : Table("users") {
val id = varchar("id", 36) val id = varchar("id", 36)
val email = varchar("email", 255).uniqueIndex() val email = varchar("email", 255).uniqueIndex()
@ -30,7 +31,6 @@ object UserTable : Table("users") {
val childAge = integer("child_age") val childAge = integer("child_age")
val parentPin = varchar("parent_pin", 4) val parentPin = varchar("parent_pin", 4)
override val primaryKey = PrimaryKey(id) override val primaryKey = PrimaryKey(id)
} }
@ -46,12 +46,13 @@ object DatabaseFactory {
val jdbcURL = "jdbc:postgresql://$host:$port/$dbName" val jdbcURL = "jdbc:postgresql://$host:$port/$dbName"
val database = Database.connect( val database =
url = jdbcURL, Database.connect(
driver = driverClassName, url = jdbcURL,
user = dbUsername, driver = driverClassName,
password = password user = dbUsername,
) password = password,
)
transaction(database) { transaction(database) {
SchemaUtils.create(TasksTable, ScreentimeTable, UserTable) SchemaUtils.create(TasksTable, ScreentimeTable, UserTable)
@ -93,6 +94,5 @@ object DatabaseFactory {
} }
} }
suspend fun <T> dbQuery(block: suspend () -> T): T = suspend fun <T> dbQuery(block: suspend () -> T): T = newSuspendedTransaction(Dispatchers.IO) { block() }
newSuspendedTransaction(Dispatchers.IO) { block() }
} }

View file

@ -1,5 +1,6 @@
package com.oliver.kidio.backend.data.database package com.oliver.kidio.backend.data.database
import io.ktor.server.config.* import io.ktor.server.config.ApplicationConfig
import io.ktor.server.routing.get
import redis.clients.jedis.JedisPool import redis.clients.jedis.JedisPool
import redis.clients.jedis.JedisPoolConfig import redis.clients.jedis.JedisPoolConfig
@ -7,22 +8,28 @@ object RedisFactory {
private var jedisPool: JedisPool? = null private var jedisPool: JedisPool? = null
fun init(config: ApplicationConfig) { fun init(config: ApplicationConfig) {
val host = System.getenv("REDIS_HOST") val host =
?: config.propertyOrNull("storage.redisHost")?.getString() System.getenv("REDIS_HOST")
?: "localhost" ?: config.propertyOrNull("storage.redisHost")?.getString()
?: "localhost"
val port = (System.getenv("REDIS_PORT") val port =
?: config.propertyOrNull("storage.redisPort")?.getString() (
?: "6379").toInt() System.getenv("REDIS_PORT")
?: config.propertyOrNull("storage.redisPort")?.getString()
?: "6379"
).toInt()
val poolConfig = JedisPoolConfig().apply { val poolConfig =
maxTotal = 32 JedisPoolConfig().apply {
maxIdle = 8 maxTotal = 32
minIdle = 2 maxIdle = 8
} minIdle = 2
}
jedisPool = JedisPool(poolConfig, host, port) jedisPool = JedisPool(poolConfig, host, port)
} }
fun getResource() = jedisPool?.resource fun getResource() =
?: throw IllegalStateException("Redis Verbindungspool ist nicht initzialisiert") jedisPool?.resource
?: throw IllegalStateException("Redis Verbindungspool ist nicht initzialisiert")
} }

View file

@ -8,14 +8,14 @@ data class Task(
val id: String, val id: String,
val question: String, val question: String,
val options: List<String>, val options: List<String>,
val rewardMinutes: Int // Wie viel Belohnung es gibt val rewardMinutes: Int,
) )
// Anfrage des Kindes, wenn es eine Aufgabe löst // Anfrage des Kindes, wenn es eine Aufgabe löst
@Serializable @Serializable
data class TaskVerifyRequest( data class TaskVerifyRequest(
val taskId: String, val taskId: String,
val selectedAnswer: String val selectedAnswer: String,
) )
// Antwort vom Server nach der Aufgaben-Prüfung // Antwort vom Server nach der Aufgaben-Prüfung
@ -23,20 +23,20 @@ data class TaskVerifyRequest(
data class TaskVerifyResponse( data class TaskVerifyResponse(
val isCorrect: Boolean, val isCorrect: Boolean,
val earnedMinutes: Int, val earnedMinutes: Int,
val message: String val message: String,
) )
// Datenabgleich für die Bildschirmzeit (Sync) // Datenabgleich für die Bildschirmzeit (Sync)
@Serializable @Serializable
data class SyncRequest( data class SyncRequest(
val userId: String, val userId: String,
val usedSecondsSinceLastSync: Int val usedSecondsSinceLastSync: Int,
) )
@Serializable @Serializable
data class SyncResponse( data class SyncResponse(
val remainingSeconds: Int, val remainingSeconds: Int,
val isBlocked: Boolean val isBlocked: Boolean,
) )
// --- NEU: AUTHENTIFIZIERUNG --- // --- NEU: AUTHENTIFIZIERUNG ---
@ -47,21 +47,21 @@ data class RegisterRequest(
val passwordPlain: String, val passwordPlain: String,
val familyName: String, val familyName: String,
val childAge: Int, val childAge: Int,
val parentPin: String val parentPin: String,
) )
@Serializable @Serializable
data class LoginRequest( data class LoginRequest(
val email: String, val email: String,
val passwordPlain: String val passwordPlain: String,
) )
@Serializable @Serializable
data class AuthResponse( data class AuthResponse(
val token: String, // Der digitale "Ausweis" (JWT) val token: String,
val userId: String, // Wichtig für die App, um Aufgaben zuzuordnen val userId: String,
val familyName: String, // Für "Hallo Familie Müller!" val familyName: String,
val childAge: Int val childAge: Int,
) )
// Interne Backend-Klasse (Domain Model). // Interne Backend-Klasse (Domain Model).
@ -71,5 +71,5 @@ data class User(
val passwordHash: String, val passwordHash: String,
val familyName: String, val familyName: String,
val childAge: Int, val childAge: Int,
val parentPin: String val parentPin: String,
) )

View file

@ -12,53 +12,68 @@ import org.mindrot.jbcrypt.BCrypt
interface KidioRepository { interface KidioRepository {
suspend fun getAllTasks(): List<Task> suspend fun getAllTasks(): List<Task>
suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>?
suspend fun getScreentime(userId: String): Int suspend fun getScreentime(userId: String): Int
suspend fun updateScreentime(userId: String, seconds: Int)
suspend fun updateScreentime(
userId: String,
seconds: Int,
)
suspend fun findByEmail(email: String): User? suspend fun findByEmail(email: String): User?
suspend fun verifyPassword(password: String, passwordHash: String): Boolean
suspend fun verifyPassword(
password: String,
passwordHash: String,
): Boolean
suspend fun registerUser(user: User): Boolean suspend fun registerUser(user: User): Boolean
} }
class ExposedKidioRepository : KidioRepository { class ExposedKidioRepository : KidioRepository {
override suspend fun getAllTasks(): List<Task> = DatabaseFactory.dbQuery { override suspend fun getAllTasks(): List<Task> =
TasksTable.selectAll().map { DatabaseFactory.dbQuery {
Task( TasksTable.selectAll().map {
id = it[TasksTable.id], Task(
question = it[TasksTable.question], id = it[TasksTable.id],
options = it[TasksTable.options].split(",").map { opt -> opt.trim() }, question = it[TasksTable.question],
rewardMinutes = it[TasksTable.rewardMinutes] options = it[TasksTable.options].split(",").map { opt -> opt.trim() },
) rewardMinutes = it[TasksTable.rewardMinutes],
} )
}
override suspend fun registerUser(user: User): Boolean = DatabaseFactory.dbQuery {
try {
UserTable.insert {
it[id] = user.id
it[email] = user.email
it[passwordHash] = user.passwordHash
it[familyName] = user.familyName
it[childAge] = user.childAge
it[parentPin] = user.parentPin
} }
ScreentimeTable.insert {
it[userId] = user.id
it[remainingSeconds] = 1800 // Startguthaben: 30 Minuten
}
true
} catch (e: Exception) {
println("FEHLER bei Registrierung: ${e.message}")
false
} }
}
override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? = DatabaseFactory.dbQuery { override suspend fun registerUser(user: User): Boolean =
TasksTable.selectAll() DatabaseFactory.dbQuery {
.where { TasksTable.id eq taskId } try {
.map { it[TasksTable.correctAnswer] to it[TasksTable.rewardMinutes] } UserTable.insert {
.singleOrNull() it[id] = user.id
} it[email] = user.email
it[passwordHash] = user.passwordHash
it[familyName] = user.familyName
it[childAge] = user.childAge
it[parentPin] = user.parentPin
}
ScreentimeTable.insert {
it[userId] = user.id
it[remainingSeconds] = 1800 // Startguthaben: 30 Minuten
}
true
} catch (e: Exception) {
println("FEHLER bei Registrierung: ${e.message}")
false
}
}
override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? =
DatabaseFactory.dbQuery {
TasksTable.selectAll()
.where { TasksTable.id eq taskId }
.map { it[TasksTable.correctAnswer] to it[TasksTable.rewardMinutes] }
.singleOrNull()
}
override suspend fun getScreentime(userId: String): Int { override suspend fun getScreentime(userId: String): Int {
val redisKey = "screentime:$userId" val redisKey = "screentime:$userId"
@ -75,12 +90,13 @@ class ExposedKidioRepository : KidioRepository {
println("WARNUNG: Konnte nicht aus Redis lesen (Postgres-Fallback genutzt): ${e.message}") println("WARNUNG: Konnte nicht aus Redis lesen (Postgres-Fallback genutzt): ${e.message}")
} }
val dbSeconds = DatabaseFactory.dbQuery { val dbSeconds =
ScreentimeTable.selectAll() DatabaseFactory.dbQuery {
.where { ScreentimeTable.userId eq userId } ScreentimeTable.selectAll()
.map { it[ScreentimeTable.remainingSeconds] } .where { ScreentimeTable.userId eq userId }
.singleOrNull() ?: 1800 .map { it[ScreentimeTable.remainingSeconds] }
} .singleOrNull() ?: 1800
}
try { try {
RedisFactory.getResource().use { jedis -> RedisFactory.getResource().use { jedis ->
@ -93,11 +109,15 @@ class ExposedKidioRepository : KidioRepository {
return dbSeconds return dbSeconds
} }
override suspend fun updateScreentime(userId: String, seconds: Int) { override suspend fun updateScreentime(
userId: String,
seconds: Int,
) {
DatabaseFactory.dbQuery { DatabaseFactory.dbQuery {
val rowsUpdated = ScreentimeTable.update({ ScreentimeTable.userId eq userId }) { val rowsUpdated =
it[remainingSeconds] = seconds ScreentimeTable.update({ ScreentimeTable.userId eq userId }) {
} it[remainingSeconds] = seconds
}
if (rowsUpdated == 0) { if (rowsUpdated == 0) {
ScreentimeTable.insert { ScreentimeTable.insert {
it[ScreentimeTable.userId] = userId it[ScreentimeTable.userId] = userId
@ -116,39 +136,46 @@ class ExposedKidioRepository : KidioRepository {
} }
} }
override suspend fun findByEmail(email: String): User? = DatabaseFactory.dbQuery { override suspend fun findByEmail(email: String): User? =
UserTable.selectAll() DatabaseFactory.dbQuery {
.where { UserTable.email eq email } UserTable.selectAll()
.map { .where { UserTable.email eq email }
User( .map {
id = it[UserTable.id], User(
email = it[UserTable.email], id = it[UserTable.id],
passwordHash = it[UserTable.passwordHash], email = it[UserTable.email],
familyName = it[UserTable.familyName], passwordHash = it[UserTable.passwordHash],
childAge = it[UserTable.childAge], familyName = it[UserTable.familyName],
parentPin = it[UserTable.parentPin] childAge = it[UserTable.childAge],
) parentPin = it[UserTable.parentPin],
} )
.singleOrNull() }
} .singleOrNull()
}
override suspend fun verifyPassword(password: String, passwordHash: String): Boolean { override suspend fun verifyPassword(
password: String,
passwordHash: String,
): Boolean {
return BCrypt.checkpw(password, passwordHash) return BCrypt.checkpw(password, passwordHash)
} }
} }
class InMemoryKidioRepository : KidioRepository { class InMemoryKidioRepository : KidioRepository {
private val tasks = mutableListOf( private val tasks =
Task("t1", "Was ist 12 + 15?", listOf("25", "27", "30", "22"), 10), mutableListOf(
Task("t2", "Was ist 8 x 7?", listOf("54", "56", "64", "48"), 15) Task("t1", "Was ist 12 + 15?", listOf("25", "27", "30", "22"), 10),
) Task("t2", "Was ist 8 x 7?", listOf("54", "56", "64", "48"), 15),
private val answers = mutableMapOf( )
"t1" to ("27" to 10), private val answers =
"t2" to ("56" to 15) mutableMapOf(
) "t1" to ("27" to 10),
private val screentimes = mutableMapOf( "t2" to ("56" to 15),
"default_user" to 1800 )
) private val screentimes =
mutableMapOf(
"default_user" to 1800,
)
private val users = mutableListOf<User>() private val users = mutableListOf<User>()
override suspend fun getAllTasks(): List<Task> = tasks override suspend fun getAllTasks(): List<Task> = tasks
@ -157,7 +184,10 @@ class InMemoryKidioRepository : KidioRepository {
override suspend fun getScreentime(userId: String): Int = screentimes[userId] ?: 1800 override suspend fun getScreentime(userId: String): Int = screentimes[userId] ?: 1800
override suspend fun updateScreentime(userId: String, seconds: Int) { override suspend fun updateScreentime(
userId: String,
seconds: Int,
) {
screentimes[userId] = seconds screentimes[userId] = seconds
} }
@ -165,7 +195,10 @@ class InMemoryKidioRepository : KidioRepository {
return users.find { it.email == email } return users.find { it.email == email }
} }
override suspend fun verifyPassword(password: String, passwordHash: String): Boolean { override suspend fun verifyPassword(
password: String,
passwordHash: String,
): Boolean {
return BCrypt.checkpw(password, passwordHash) return BCrypt.checkpw(password, passwordHash)
} }

View file

@ -13,7 +13,10 @@ import io.ktor.server.routing.*
import org.mindrot.jbcrypt.BCrypt import org.mindrot.jbcrypt.BCrypt
import java.util.UUID import java.util.UUID
fun Application.configureRouting(repository: KidioRepository, jwtService : JwtService) { fun Application.configureRouting(
repository: KidioRepository,
jwtService: JwtService,
) {
routing { routing {
post("/api/v1/auth/register") { post("/api/v1/auth/register") {
val registerRequest = call.receive<RegisterRequest>() val registerRequest = call.receive<RegisterRequest>()
@ -24,7 +27,7 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe
if (registerRequest.parentPin.length != 4) { if (registerRequest.parentPin.length != 4) {
call.respond(HttpStatusCode.BadRequest, "Die Eltern-PIN muss genau 4 Ziffern lang sein") call.respond(HttpStatusCode.BadRequest, "Die Eltern-PIN muss genau 4 Ziffern lang sein")
return@post return@post
} }
val existingUser = repository.findByEmail(registerRequest.email) val existingUser = repository.findByEmail(registerRequest.email)
if (existingUser != null) { if (existingUser != null) {
call.respond(HttpStatusCode.Conflict, "Ein Benutzer mit dieser E-Mail-Adresse existiert bereits") call.respond(HttpStatusCode.Conflict, "Ein Benutzer mit dieser E-Mail-Adresse existiert bereits")
@ -35,8 +38,17 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe
val userId = UUID.randomUUID().toString() val userId = UUID.randomUUID().toString()
val passwordHash = BCrypt.hashpw(registerRequest.passwordPlain, BCrypt.gensalt()) val passwordHash = BCrypt.hashpw(registerRequest.passwordPlain, BCrypt.gensalt())
//datenbank insert hinzufügen // datenbank insert hinzufügen
repository.registerUser(User(userId, registerRequest.email, passwordHash, registerRequest.familyName, registerRequest.childAge, registerRequest.parentPin)) repository.registerUser(
User(
userId,
registerRequest.email,
passwordHash,
registerRequest.familyName,
registerRequest.childAge,
registerRequest.parentPin,
),
)
val token = jwtService.generateToken(userId) val token = jwtService.generateToken(userId)
@ -45,8 +57,8 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe
mapOf( mapOf(
"token" to token, "token" to token,
"userId" to userId, "userId" to userId,
"message" to "Registrierung erfolgreich" "message" to "Registrierung erfolgreich",
) ),
) )
} }
// Health-Check // Health-Check
@ -54,33 +66,31 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe
call.respond(mapOf("status" to "OK", "message" to "Kidio Backend läuft!")) call.respond(mapOf("status" to "OK", "message" to "Kidio Backend läuft!"))
} }
post ("/api/v1/auth/login" ) { post("/api/v1/auth/login") {
val loginRequest = call.receive<LoginRequest>() val loginRequest = call.receive<LoginRequest>()
if(loginRequest.email.isBlank() || loginRequest.passwordPlain.isBlank()){ if (loginRequest.email.isBlank() || loginRequest.passwordPlain.isBlank()) {
call.respond(HttpStatusCode.BadRequest, "Bitte füllen Sie alle Felder aus") call.respond(HttpStatusCode.BadRequest, "Bitte füllen Sie alle Felder aus")
return@post return@post
} }
val getUser = repository.findByEmail(loginRequest.email) val getUser = repository.findByEmail(loginRequest.email)
if (getUser == null ||!repository.verifyPassword(loginRequest.passwordPlain, getUser.passwordHash)){ if (getUser == null || !repository.verifyPassword(loginRequest.passwordPlain, getUser.passwordHash)) {
call.respond(HttpStatusCode.Unauthorized, "Ungültige Anmeldedaten") call.respond(HttpStatusCode.Unauthorized, "Ungültige Anmeldedaten")
return@post return@post
} }
val token = jwtService.generateToken(getUser.id) val token = jwtService.generateToken(getUser.id)
call.respond(HttpStatusCode.OK, AuthResponse( call.respond(
token, HttpStatusCode.OK,
userId = getUser.id, AuthResponse(
familyName = getUser.familyName, token,
childAge = getUser.childAge userId = getUser.id,
)) familyName = getUser.familyName,
childAge = getUser.childAge,
),
)
return@post return@post
} }
authenticate("auth-jwt") { authenticate("auth-jwt") {
// 1. Alle verfügbaren Aufgaben abrufen // 1. Alle verfügbaren Aufgaben abrufen
get("/api/v1/tasks") { get("/api/v1/tasks") {
val tasks = repository.getAllTasks() val tasks = repository.getAllTasks()
@ -88,67 +98,67 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe
} }
// 2. Antwort auf eine Aufgabe überprüfen // 2. Antwort auf eine Aufgabe überprüfen
post("/api/v1/tasks/verify") { post("/api/v1/tasks/verify") {
val request = call.receive<TaskVerifyRequest>() val request = call.receive<TaskVerifyRequest>()
val principal = call.principal<UserPrincipal>() val principal = call.principal<UserPrincipal>()
val userId = principal?.userId val userId = principal?.userId
if (userId == null){ if (userId == null) {
call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "Ungültiges oder abgelaufenes Token")) call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "Ungültiges oder abgelaufenes Token"))
return@post return@post
}
val taskInfo = repository.getTaskAnswerAndReward(request.taskId)
if (taskInfo == null) {
call.respond(
TaskVerifyResponse(
isCorrect = false,
earnedMinutes = 0,
message = "Aufgabe nicht gefunden!"
)
)
return@post
}
val (correctAnswer, reward) = taskInfo
val isCorrect = request.selectedAnswer.trim() == correctAnswer.trim()
if (isCorrect) {
val currentScreentime = repository.getScreentime(userId)
val newSeconds = currentScreentime + (reward * 60)
repository.updateScreentime(userId, newSeconds)
call.respond(
TaskVerifyResponse(
isCorrect = true,
earnedMinutes = reward,
message = "Super gemacht! Du hast $reward Minuten gewonnen."
)
)
} else {
call.respond(
TaskVerifyResponse(
isCorrect = false,
earnedMinutes = 0,
message = "Schade, das war leider falsch. Versuche es nochmal!"
)
)
}
} }
val taskInfo = repository.getTaskAnswerAndReward(request.taskId)
if (taskInfo == null) {
call.respond(
TaskVerifyResponse(
isCorrect = false,
earnedMinutes = 0,
message = "Aufgabe nicht gefunden!",
),
)
return@post
}
val (correctAnswer, reward) = taskInfo
val isCorrect = request.selectedAnswer.trim() == correctAnswer.trim()
if (isCorrect) {
val currentScreentime = repository.getScreentime(userId)
val newSeconds = currentScreentime + (reward * 60)
repository.updateScreentime(userId, newSeconds)
call.respond(
TaskVerifyResponse(
isCorrect = true,
earnedMinutes = reward,
message = "Super gemacht! Du hast $reward Minuten gewonnen.",
),
)
} else {
call.respond(
TaskVerifyResponse(
isCorrect = false,
earnedMinutes = 0,
message = "Schade, das war leider falsch. Versuche es nochmal!",
),
)
}
}
get("/api/v1/screentime") { get("/api/v1/screentime") {
val principal = call.principal<UserPrincipal>() val principal = call.principal<UserPrincipal>()
val userid = principal?.userId?: run { val userid =
call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "Ungültiger Token")) principal?.userId
return@get ?: run {
} call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "Ungültiger Token"))
return@get
}
val remainingSeconds = repository.getScreentime(userid) val remainingSeconds = repository.getScreentime(userid)
call.respond( call.respond(
HttpStatusCode.OK, HttpStatusCode.OK,
SyncResponse( SyncResponse(
remainingSeconds = remainingSeconds, remainingSeconds = remainingSeconds,
isBlocked = remainingSeconds <= 0 isBlocked = remainingSeconds <= 0,
) ),
) )
} }
// 3. Bildschirmzeit synchronisieren // 3. Bildschirmzeit synchronisieren
@ -168,11 +178,10 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe
call.respond( call.respond(
SyncResponse( SyncResponse(
remainingSeconds = newSeconds, remainingSeconds = newSeconds,
isBlocked = newSeconds <= 0 isBlocked = newSeconds <= 0,
) ),
) )
} }
} }
} }
} }

View file

@ -9,14 +9,14 @@ class JwtService(
private val secret: String = System.getenv("JWT_SECRET") ?: "super-geheimes-secret-fuer-dev", private val secret: String = System.getenv("JWT_SECRET") ?: "super-geheimes-secret-fuer-dev",
private val issuer: String = "https://kidio.oliver.com/", private val issuer: String = "https://kidio.oliver.com/",
private val audience: String = "kidio-users", private val audience: String = "kidio-users",
private val expirationInMs: Long = java.util.concurrent.TimeUnit.DAYS.toMillis(30) // 30 Tage private val expirationInMs: Long = java.util.concurrent.TimeUnit.DAYS.toMillis(30),
) { ) {
val verifier: JWTVerifier =
val verifier: JWTVerifier = JWT JWT
.require(Algorithm.HMAC256(secret)) .require(Algorithm.HMAC256(secret))
.withAudience(audience) .withAudience(audience)
.withIssuer(issuer) .withIssuer(issuer)
.build() .build()
/** /**
* Generiert ein JWT-Token für einen bestimmten Benutzer. * Generiert ein JWT-Token für einen bestimmten Benutzer.

View file

@ -4,220 +4,243 @@ import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository
import com.oliver.kidio.backend.plugins.configureRouting import com.oliver.kidio.backend.plugins.configureRouting
import com.oliver.kidio.backend.plugins.configureSecurity import com.oliver.kidio.backend.plugins.configureSecurity
import com.oliver.kidio.backend.security.JwtService import com.oliver.kidio.backend.security.JwtService
import io.ktor.client.request.* import io.ktor.client.request.get
import io.ktor.client.statement.* import io.ktor.client.request.header
import io.ktor.http.* import io.ktor.client.request.post
import io.ktor.serialization.kotlinx.json.* import io.ktor.client.request.setBody
import io.ktor.server.application.* import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.testing.testApplication
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation
import io.ktor.server.testing.*
import kotlinx.serialization.json.*
import kotlin.test.*
class ServerTest { class ServerTest {
@Test @Test
fun `test health endpoint`() = testApplication { fun `test health endpoint`() =
val jwtService = JwtService() testApplication {
val repository = InMemoryKidioRepository() val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application { application {
install(ServerContentNegotiation) { install(ServerContentNegotiation) {
json() json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
} }
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
val response = client.get("/api/v1/health") val response = client.get("/api/v1/health")
assertEquals(HttpStatusCode.OK, response.status) assertEquals(HttpStatusCode.OK, response.status)
assertTrue(response.bodyAsText().contains("Kidio Backend läuft!")) assertTrue(response.bodyAsText().contains("Kidio Backend läuft!"))
} }
@Test @Test
fun `test get screentime returns current status`() = testApplication { fun `test get screentime returns current status`() =
val jwtService = JwtService() testApplication {
val repository = InMemoryKidioRepository() val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application { application {
install(ServerContentNegotiation) { json() } install(ServerContentNegotiation) { json() }
configureSecurity(jwtService) configureSecurity(jwtService)
configureRouting(repository, jwtService) configureRouting(repository, jwtService)
}
val token = jwtService.generateToken("default_user")
// Ohne Token -> 401 Unauthorized
val unauthResponse = client.get("/api/v1/screentime")
assertEquals(HttpStatusCode.Unauthorized, unauthResponse.status)
// Mit Token -> 200 OK
val response = client.get("/api/v1/screentime") {
header(HttpHeaders.Authorization, "Bearer $token")
}
assertEquals(HttpStatusCode.OK, response.status)
val json = Json.decodeFromString<SyncResponse>(response.bodyAsText())
assertEquals(1800, json.remainingSeconds)
assertFalse(json.isBlocked)
}
@Test
fun `test protected endpoints reject unauthorized requests`() = testApplication {
val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application {
install(ServerContentNegotiation) {
json()
} }
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
// Ohne Token muss 401 Unauthorized kommen val token = jwtService.generateToken("default_user")
val tasksResponse = client.get("/api/v1/tasks")
assertEquals(HttpStatusCode.Unauthorized, tasksResponse.status) // Ohne Token -> 401 Unauthorized
} val unauthResponse = client.get("/api/v1/screentime")
assertEquals(HttpStatusCode.Unauthorized, unauthResponse.status)
// Mit Token -> 200 OK
val response =
client.get("/api/v1/screentime") {
header(HttpHeaders.Authorization, "Bearer $token")
}
assertEquals(HttpStatusCode.OK, response.status)
val json = Json.decodeFromString<SyncResponse>(response.bodyAsText())
assertEquals(1800, json.remainingSeconds)
assertFalse(json.isBlocked)
}
@Test @Test
fun `test registration and login flow`() = testApplication { fun `test protected endpoints reject unauthorized requests`() =
val jwtService = JwtService() testApplication {
val repository = InMemoryKidioRepository() val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application { application {
install(ServerContentNegotiation) { install(ServerContentNegotiation) {
json() json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
} }
configureSecurity(jwtService)
configureRouting(repository, jwtService) // Ohne Token muss 401 Unauthorized kommen
val tasksResponse = client.get("/api/v1/tasks")
assertEquals(HttpStatusCode.Unauthorized, tasksResponse.status)
} }
// 1. Registrierung @Test
val registerResponse = client.post("/api/v1/auth/register") { fun `test registration and login flow`() =
header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) testApplication {
setBody( val jwtService = JwtService()
"""{ val repository = InMemoryKidioRepository()
application {
install(ServerContentNegotiation) {
json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
// 1. Registrierung
val registerResponse =
client.post("/api/v1/auth/register") {
header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody(
"""{
"email": "test@family.de", "email": "test@family.de",
"passwordPlain": "strengGeheim1", "passwordPlain": "strengGeheim1",
"familyName": "Familie Schmidt", "familyName": "Familie Schmidt",
"childAge": 8, "childAge": 8,
"parentPin": "4321" "parentPin": "4321"
}""" }""",
) )
} }
assertEquals(HttpStatusCode.Created, registerResponse.status) assertEquals(HttpStatusCode.Created, registerResponse.status)
assertTrue(registerResponse.bodyAsText().contains("token")) assertTrue(registerResponse.bodyAsText().contains("token"))
// 2. Login mit denselben Daten // 2. Login mit denselben Daten
val loginResponse = client.post("/api/v1/auth/login") { val loginResponse =
header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) client.post("/api/v1/auth/login") {
setBody( header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
"""{ setBody(
"""{
"email": "test@family.de", "email": "test@family.de",
"passwordPlain": "strengGeheim1" "passwordPlain": "strengGeheim1"
}""" }""",
) )
}
assertEquals(HttpStatusCode.OK, loginResponse.status)
val loginJson = Json.decodeFromString<AuthResponse>(loginResponse.bodyAsText())
assertNotNull(loginJson.token)
assertEquals("Familie Schmidt", loginJson.familyName)
} }
assertEquals(HttpStatusCode.OK, loginResponse.status)
val loginJson = Json.decodeFromString<AuthResponse>(loginResponse.bodyAsText())
assertNotNull(loginJson.token)
assertEquals("Familie Schmidt", loginJson.familyName)
}
@Test @Test
fun `test tasks endpoint and verify with jwt`() = testApplication { fun `test tasks endpoint and verify with jwt`() =
val jwtService = JwtService() testApplication {
val repository = InMemoryKidioRepository() val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application { application {
install(ServerContentNegotiation) { install(ServerContentNegotiation) {
json() json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
} }
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
val token = jwtService.generateToken("default_user") val token = jwtService.generateToken("default_user")
// 1. Get Tasks mit Token // 1. Get Tasks mit Token
val tasksResponse = client.get("/api/v1/tasks") { val tasksResponse =
header(HttpHeaders.Authorization, "Bearer $token") client.get("/api/v1/tasks") {
} header(HttpHeaders.Authorization, "Bearer $token")
assertEquals(HttpStatusCode.OK, tasksResponse.status) }
val tasksJson = Json.decodeFromString<List<Task>>(tasksResponse.bodyAsText()) assertEquals(HttpStatusCode.OK, tasksResponse.status)
assertEquals(2, tasksJson.size) val tasksJson = Json.decodeFromString<List<Task>>(tasksResponse.bodyAsText())
assertEquals("t1", tasksJson[0].id) assertEquals(2, tasksJson.size)
assertEquals("Was ist 12 + 15?", tasksJson[0].question) assertEquals("t1", tasksJson[0].id)
assertEquals("Was ist 12 + 15?", tasksJson[0].question)
// 2. Verify Task correct answer // 2. Verify Task correct answer
val verifyResponse = client.post("/api/v1/tasks/verify") { val verifyResponse =
header(HttpHeaders.Authorization, "Bearer $token") client.post("/api/v1/tasks/verify") {
header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) header(HttpHeaders.Authorization, "Bearer $token")
setBody("""{"taskId":"t1","selectedAnswer":"27"}""") header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
} setBody("""{"taskId":"t1","selectedAnswer":"27"}""")
assertEquals(HttpStatusCode.OK, verifyResponse.status) }
val verifyJson = Json.decodeFromString<TaskVerifyResponse>(verifyResponse.bodyAsText()) assertEquals(HttpStatusCode.OK, verifyResponse.status)
assertTrue(verifyJson.isCorrect) val verifyJson = Json.decodeFromString<TaskVerifyResponse>(verifyResponse.bodyAsText())
assertEquals(10, verifyJson.earnedMinutes) assertTrue(verifyJson.isCorrect)
assertEquals(10, verifyJson.earnedMinutes)
// 3. Verify Task incorrect answer // 3. Verify Task incorrect answer
val verifyWrongResponse = client.post("/api/v1/tasks/verify") { val verifyWrongResponse =
header(HttpHeaders.Authorization, "Bearer $token") client.post("/api/v1/tasks/verify") {
header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) header(HttpHeaders.Authorization, "Bearer $token")
setBody("""{"taskId":"t1","selectedAnswer":"99"}""") header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"taskId":"t1","selectedAnswer":"99"}""")
}
assertEquals(HttpStatusCode.OK, verifyWrongResponse.status)
val verifyWrongJson = Json.decodeFromString<TaskVerifyResponse>(verifyWrongResponse.bodyAsText())
assertFalse(verifyWrongJson.isCorrect)
assertEquals(0, verifyWrongJson.earnedMinutes)
} }
assertEquals(HttpStatusCode.OK, verifyWrongResponse.status)
val verifyWrongJson = Json.decodeFromString<TaskVerifyResponse>(verifyWrongResponse.bodyAsText())
assertFalse(verifyWrongJson.isCorrect)
assertEquals(0, verifyWrongJson.earnedMinutes)
}
@Test @Test
fun `test screentime sync deducts time repeatedly`() = testApplication { fun `test screentime sync deducts time repeatedly`() =
val jwtService = JwtService() testApplication {
val repository = InMemoryKidioRepository() val jwtService = JwtService()
val repository = InMemoryKidioRepository()
application { application {
install(ServerContentNegotiation) { install(ServerContentNegotiation) {
json() json()
}
configureSecurity(jwtService)
configureRouting(repository, jwtService)
} }
configureSecurity(jwtService)
configureRouting(repository, jwtService)
}
val token = jwtService.generateToken("default_user") val token = jwtService.generateToken("default_user")
// 1. Sync: 200 Sekunden abziehen (1800 -> 1600) // 1. Sync: 200 Sekunden abziehen (1800 -> 1600)
val syncResponse1 = client.post("/api/v1/screentime/sync") { val syncResponse1 =
header(HttpHeaders.Authorization, "Bearer $token") client.post("/api/v1/screentime/sync") {
header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) header(HttpHeaders.Authorization, "Bearer $token")
setBody("""{"userId":"default_user","usedSecondsSinceLastSync":200}""") header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
} setBody("""{"userId":"default_user","usedSecondsSinceLastSync":200}""")
assertEquals(HttpStatusCode.OK, syncResponse1.status) }
val syncJson1 = Json.decodeFromString<SyncResponse>(syncResponse1.bodyAsText()) assertEquals(HttpStatusCode.OK, syncResponse1.status)
assertEquals(1600, syncJson1.remainingSeconds) val syncJson1 = Json.decodeFromString<SyncResponse>(syncResponse1.bodyAsText())
assertFalse(syncJson1.isBlocked) assertEquals(1600, syncJson1.remainingSeconds)
assertFalse(syncJson1.isBlocked)
// 2. Erneuter Sync: Weitere 300 Sekunden abziehen (1600 -> 1300) - darf nicht auf 1800 zurückspringen! // 2. Erneuter Sync: Weitere 300 Sekunden abziehen (1600 -> 1300) - darf nicht auf 1800 zurückspringen!
val syncResponse2 = client.post("/api/v1/screentime/sync") { val syncResponse2 =
header(HttpHeaders.Authorization, "Bearer $token") client.post("/api/v1/screentime/sync") {
header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) header(HttpHeaders.Authorization, "Bearer $token")
setBody("""{"userId":"default_user","usedSecondsSinceLastSync":300}""") header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
} setBody("""{"userId":"default_user","usedSecondsSinceLastSync":300}""")
assertEquals(HttpStatusCode.OK, syncResponse2.status) }
val syncJson2 = Json.decodeFromString<SyncResponse>(syncResponse2.bodyAsText()) assertEquals(HttpStatusCode.OK, syncResponse2.status)
assertEquals(1300, syncJson2.remainingSeconds) val syncJson2 = Json.decodeFromString<SyncResponse>(syncResponse2.bodyAsText())
assertFalse(syncJson2.isBlocked) assertEquals(1300, syncJson2.remainingSeconds)
assertFalse(syncJson2.isBlocked)
// 3. Sync bis zur Sperre (weitere 1500 Sekunden abziehen -> 0) // 3. Sync bis zur Sperre (weitere 1500 Sekunden abziehen -> 0)
val syncResponse3 = client.post("/api/v1/screentime/sync") { val syncResponse3 =
header(HttpHeaders.Authorization, "Bearer $token") client.post("/api/v1/screentime/sync") {
header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) header(HttpHeaders.Authorization, "Bearer $token")
setBody("""{"userId":"default_user","usedSecondsSinceLastSync":1500}""") header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
setBody("""{"userId":"default_user","usedSecondsSinceLastSync":1500}""")
}
assertEquals(HttpStatusCode.OK, syncResponse3.status)
val syncJson3 = Json.decodeFromString<SyncResponse>(syncResponse3.bodyAsText())
assertEquals(0, syncJson3.remainingSeconds)
assertTrue(syncJson3.isBlocked)
} }
assertEquals(HttpStatusCode.OK, syncResponse3.status)
val syncJson3 = Json.decodeFromString<SyncResponse>(syncResponse3.bodyAsText())
assertEquals(0, syncJson3.remainingSeconds)
assertTrue(syncJson3.isBlocked)
}
} }