From 17f04d71a432f4511fd27d971103c773f1687878 Mon Sep 17 00:00:00 2001 From: Oliver Wallisch Date: Wed, 9 Sep 2026 23:23:23 +0200 Subject: [PATCH 1/2] fixes for jwt token (now 30 days) screentime sync and redis --- .../domain/repository/KidioRepository.kt | 29 +++++++++++++------ .../oliver/kidio/backend/plugins/Routing.kt | 14 +++++---- .../kidio/backend/security/JwtService.kt | 2 +- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt index e989fb1..a15c87e 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt @@ -78,7 +78,7 @@ class ExposedKidioRepository : KidioRepository { val dbSeconds = DatabaseFactory.dbQuery { ScreentimeTable.selectAll() .where { ScreentimeTable.userId eq userId } - .map { it[ScreentimeTable.remainingSeconds] } // Korrigiert: Abfrage der Sekunden! + .map { it[ScreentimeTable.remainingSeconds] } .singleOrNull() ?: 1800 } @@ -93,15 +93,26 @@ class ExposedKidioRepository : KidioRepository { return dbSeconds } - override suspend fun updateScreentime(userId: String, seconds: Int) = DatabaseFactory.dbQuery { - val rowsUpdated = ScreentimeTable.update({ ScreentimeTable.userId eq userId }) { - it[remainingSeconds] = seconds - } - if (rowsUpdated == 0) { - ScreentimeTable.insert { - it[ScreentimeTable.userId] = userId - it[ScreentimeTable.remainingSeconds] = seconds // Korrigiert: Wert wird wieder mitgegeben! + override suspend fun updateScreentime(userId: String, seconds: Int) { + DatabaseFactory.dbQuery { + val rowsUpdated = ScreentimeTable.update({ ScreentimeTable.userId eq userId }) { + it[remainingSeconds] = seconds } + if (rowsUpdated == 0) { + ScreentimeTable.insert { + it[ScreentimeTable.userId] = userId + it[ScreentimeTable.remainingSeconds] = seconds + } + } + } + val redisKey = "screentime:$userId" + try { + RedisFactory.getResource().use { jedis -> + jedis.setex(redisKey, 3600, seconds.toString()) + println("DEBUG: Bildschirmzeit für $userId in REDIS aktualisiert auf $seconds Sek") + } + } catch (e: Exception) { + println("WARNUNG: Konnte Redis nicht aktualisieren: ${e.message}") } } diff --git a/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt b/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt index 0ac4953..0654cc5 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt @@ -6,7 +6,6 @@ import com.oliver.kidio.backend.security.JwtService import io.ktor.http.HttpStatusCode import io.ktor.server.application.* import io.ktor.server.auth.authenticate -import io.ktor.server.auth.jwt.JWTPrincipal import io.ktor.server.auth.principal import io.ktor.server.request.* import io.ktor.server.response.* @@ -91,9 +90,8 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe // 2. Antwort auf eine Aufgabe überprüfen post("/api/v1/tasks/verify") { val request = call.receive() - val principal = call.principal() - val userId = principal?.payload?.getClaim("userId")?.asString() - + val principal = call.principal() + val userId = principal?.userId if (userId == null){ call.respond(HttpStatusCode.Unauthorized, mapOf("error" to "Ungültiges oder abgelaufenes Token")) return@post @@ -140,11 +138,15 @@ fun Application.configureRouting(repository: KidioRepository, jwtService : JwtSe post("/api/v1/screentime/sync") { val request = call.receive() - val currentSeconds = repository.getScreentime(request.userId) + // Wir holen die echte, fälschungssichere userId aus dem Login-Token: + val principal = call.principal() + val userId = principal?.userId ?: request.userId // Fallback auf request.userId, falls token noch fehlt + + val currentSeconds = repository.getScreentime(userId) var newSeconds = currentSeconds - request.usedSecondsSinceLastSync if (newSeconds < 0) newSeconds = 0 - repository.updateScreentime(request.userId, newSeconds) + repository.updateScreentime(userId, newSeconds) call.respond( SyncResponse( diff --git a/src/main/kotlin/com/oliver/kidio/backend/security/JwtService.kt b/src/main/kotlin/com/oliver/kidio/backend/security/JwtService.kt index f6c780c..e5faf17 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/security/JwtService.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/security/JwtService.kt @@ -9,7 +9,7 @@ class JwtService( private val secret: String = System.getenv("JWT_SECRET") ?: "super-geheimes-secret-fuer-dev", private val issuer: String = "https://kidio.oliver.com/", private val audience: String = "kidio-users", - private val expirationInMs: Long = 36_000_000 // 10 Stunden + private val expirationInMs: Long = java.util.concurrent.TimeUnit.DAYS.toMillis(30) // 30 Tage ) { val verifier: JWTVerifier = JWT From 263f182bab983ca6e3034a3bc7920f70f8dc665b Mon Sep 17 00:00:00 2001 From: Oliver Wallisch Date: Wed, 9 Sep 2026 23:31:19 +0200 Subject: [PATCH 2/2] fix: added new tests with gemini --- src/test/kotlin/ServerTest.kt | 136 +++++++++++++++++++++++++++++----- 1 file changed, 117 insertions(+), 19 deletions(-) diff --git a/src/test/kotlin/ServerTest.kt b/src/test/kotlin/ServerTest.kt index 4771973..0acddd7 100644 --- a/src/test/kotlin/ServerTest.kt +++ b/src/test/kotlin/ServerTest.kt @@ -1,14 +1,16 @@ package com.oliver.kidio.backend +import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository +import com.oliver.kidio.backend.plugins.configureRouting +import com.oliver.kidio.backend.plugins.configureSecurity +import com.oliver.kidio.backend.security.JwtService import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* -import io.ktor.server.application.* -import io.ktor.server.testing.* -import com.oliver.kidio.backend.plugins.configureRouting -import com.oliver.kidio.backend.domain.repository.InMemoryKidioRepository -import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation +import io.ktor.server.testing.* import kotlinx.serialization.json.* import kotlin.test.* @@ -16,11 +18,15 @@ class ServerTest { @Test fun `test health endpoint`() = testApplication { + val jwtService = JwtService() + val repository = InMemoryKidioRepository() + application { - this.install(ServerContentNegotiation) { + install(ServerContentNegotiation) { json() } - configureRouting(InMemoryKidioRepository()) + configureSecurity(jwtService) + configureRouting(repository, jwtService) } val response = client.get("/api/v1/health") @@ -29,16 +35,87 @@ class ServerTest { } @Test - fun `test tasks endpoint and verify`() = testApplication { + fun `test protected endpoints reject unauthorized requests`() = testApplication { + val jwtService = JwtService() + val repository = InMemoryKidioRepository() + application { - this.install(ServerContentNegotiation) { + install(ServerContentNegotiation) { json() } - configureRouting(InMemoryKidioRepository()) + configureSecurity(jwtService) + configureRouting(repository, jwtService) } - // 1. Get Tasks + // Ohne Token muss 401 Unauthorized kommen val tasksResponse = client.get("/api/v1/tasks") + assertEquals(HttpStatusCode.Unauthorized, tasksResponse.status) + } + + @Test + fun `test registration and login flow`() = testApplication { + 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", + "passwordPlain": "strengGeheim1", + "familyName": "Familie Schmidt", + "childAge": 8, + "parentPin": "4321" + }""" + ) + } + assertEquals(HttpStatusCode.Created, registerResponse.status) + assertTrue(registerResponse.bodyAsText().contains("token")) + + // 2. Login mit denselben Daten + val loginResponse = client.post("/api/v1/auth/login") { + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody( + """{ + "email": "test@family.de", + "passwordPlain": "strengGeheim1" + }""" + ) + } + assertEquals(HttpStatusCode.OK, loginResponse.status) + val loginJson = Json.decodeFromString(loginResponse.bodyAsText()) + assertNotNull(loginJson.token) + assertEquals("Familie Schmidt", loginJson.familyName) + } + + @Test + fun `test tasks endpoint and verify with jwt`() = testApplication { + val jwtService = JwtService() + val repository = InMemoryKidioRepository() + + application { + install(ServerContentNegotiation) { + json() + } + configureSecurity(jwtService) + configureRouting(repository, jwtService) + } + + val token = jwtService.generateToken("default_user") + + // 1. Get Tasks mit Token + val tasksResponse = client.get("/api/v1/tasks") { + header(HttpHeaders.Authorization, "Bearer $token") + } assertEquals(HttpStatusCode.OK, tasksResponse.status) val tasksJson = Json.decodeFromString>(tasksResponse.bodyAsText()) assertEquals(2, tasksJson.size) @@ -47,6 +124,7 @@ class ServerTest { // 2. Verify Task correct answer val verifyResponse = client.post("/api/v1/tasks/verify") { + header(HttpHeaders.Authorization, "Bearer $token") header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) setBody("""{"taskId":"t1","selectedAnswer":"27"}""") } @@ -57,6 +135,7 @@ class ServerTest { // 3. Verify Task incorrect answer val verifyWrongResponse = client.post("/api/v1/tasks/verify") { + header(HttpHeaders.Authorization, "Bearer $token") header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) setBody("""{"taskId":"t1","selectedAnswer":"99"}""") } @@ -67,16 +146,23 @@ class ServerTest { } @Test - fun `test screentime sync`() = testApplication { + fun `test screentime sync deducts time repeatedly`() = testApplication { + val jwtService = JwtService() + val repository = InMemoryKidioRepository() + application { - this.install(ServerContentNegotiation) { + install(ServerContentNegotiation) { json() } - configureRouting(InMemoryKidioRepository()) + configureSecurity(jwtService) + configureRouting(repository, jwtService) } - // Sync first time (user default_user has 1800s seeded) + val token = jwtService.generateToken("default_user") + + // 1. Sync: 200 Sekunden abziehen (1800 -> 1600) val syncResponse1 = client.post("/api/v1/screentime/sync") { + header(HttpHeaders.Authorization, "Bearer $token") header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) setBody("""{"userId":"default_user","usedSecondsSinceLastSync":200}""") } @@ -85,14 +171,26 @@ class ServerTest { assertEquals(1600, syncJson1.remainingSeconds) assertFalse(syncJson1.isBlocked) - // Sync and block + // 2. Erneuter Sync: Weitere 300 Sekunden abziehen (1600 -> 1300) - darf nicht auf 1800 zurückspringen! val syncResponse2 = client.post("/api/v1/screentime/sync") { + header(HttpHeaders.Authorization, "Bearer $token") header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) - setBody("""{"userId":"default_user","usedSecondsSinceLastSync":2000}""") + setBody("""{"userId":"default_user","usedSecondsSinceLastSync":300}""") } assertEquals(HttpStatusCode.OK, syncResponse2.status) val syncJson2 = Json.decodeFromString(syncResponse2.bodyAsText()) - assertEquals(0, syncJson2.remainingSeconds) - assertTrue(syncJson2.isBlocked) + assertEquals(1300, syncJson2.remainingSeconds) + assertFalse(syncJson2.isBlocked) + + // 3. Sync bis zur Sperre (weitere 1500 Sekunden abziehen -> 0) + val syncResponse3 = client.post("/api/v1/screentime/sync") { + header(HttpHeaders.Authorization, "Bearer $token") + header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + setBody("""{"userId":"default_user","usedSecondsSinceLastSync":1500}""") + } + assertEquals(HttpStatusCode.OK, syncResponse3.status) + val syncJson3 = Json.decodeFromString(syncResponse3.bodyAsText()) + assertEquals(0, syncJson3.remainingSeconds) + assertTrue(syncJson3.isBlocked) } }