diff --git a/services/notification-service/src/main/kotlin/com/otterworks/notification/model/NotificationEvent.kt b/services/notification-service/src/main/kotlin/com/otterworks/notification/model/NotificationEvent.kt index eeebd8744..5a6a399ae 100644 --- a/services/notification-service/src/main/kotlin/com/otterworks/notification/model/NotificationEvent.kt +++ b/services/notification-service/src/main/kotlin/com/otterworks/notification/model/NotificationEvent.kt @@ -92,7 +92,7 @@ data class UnreadCountResponse( @Serializable data class NotificationPreferenceRequest( - val userId: String, + val userId: String? = null, val eventType: String, val channels: List, ) diff --git a/services/notification-service/src/main/kotlin/com/otterworks/notification/routes/Routes.kt b/services/notification-service/src/main/kotlin/com/otterworks/notification/routes/Routes.kt index 2d265b602..38323a054 100644 --- a/services/notification-service/src/main/kotlin/com/otterworks/notification/routes/Routes.kt +++ b/services/notification-service/src/main/kotlin/com/otterworks/notification/routes/Routes.kt @@ -152,9 +152,20 @@ fun Application.configureRouting(prometheusRegistry: PrometheusMeterRegistry) { } put { + val userId = call.request.headers["X-User-ID"] + if (userId.isNullOrBlank()) { + call.respond(HttpStatusCode.Unauthorized, ErrorResponse("X-User-ID header is required")) + return@put + } + val request = call.receive() + if (request.userId != null && request.userId != userId) { + call.respond(HttpStatusCode.Forbidden, ErrorResponse("Cannot update preferences for another user")) + return@put + } + notificationService.updatePreferences( - userId = request.userId, + userId = userId, eventType = request.eventType, channels = request.channels, ) diff --git a/services/notification-service/src/test/kotlin/com/otterworks/notification/routes/PreferencesRouteTest.kt b/services/notification-service/src/test/kotlin/com/otterworks/notification/routes/PreferencesRouteTest.kt new file mode 100644 index 000000000..05f810669 --- /dev/null +++ b/services/notification-service/src/test/kotlin/com/otterworks/notification/routes/PreferencesRouteTest.kt @@ -0,0 +1,110 @@ +package com.otterworks.notification.routes + +import com.otterworks.notification.model.DeliveryChannel +import com.otterworks.notification.service.NotificationService +import com.otterworks.notification.websocket.WebSocketManager +import io.ktor.client.request.header +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.install +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.testing.ApplicationTestBuilder +import io.ktor.server.testing.testApplication +import io.ktor.server.websocket.WebSockets +import io.micrometer.prometheus.PrometheusConfig +import io.micrometer.prometheus.PrometheusMeterRegistry +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.serialization.json.Json +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import org.koin.ktor.plugin.Koin +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class PreferencesRouteTest { + + private val notificationService = mockk(relaxed = true) + private val webSocketManager = mockk(relaxed = true) + + @AfterTest + fun tearDown() { + stopKoin() + } + + private fun ApplicationTestBuilder.setupApp() { + application { + install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } + install(WebSockets) + install(Koin) { + modules( + module { + single { notificationService } + single { webSocketManager } + } + ) + } + configureRouting(PrometheusMeterRegistry(PrometheusConfig.DEFAULT)) + } + } + + @Test + fun `PUT preferences uses the authenticated X-User-ID, not the body userId`() = testApplication { + setupApp() + + val response = client.put("/api/v1/preferences") { + header("X-User-ID", "alice") + contentType(ContentType.Application.Json) + setBody("""{"eventType":"file_shared","channels":["IN_APP"]}""") + } + + assertEquals(HttpStatusCode.NoContent, response.status) + coVerify(exactly = 1) { notificationService.updatePreferences("alice", "file_shared", listOf(DeliveryChannel.IN_APP)) } + } + + @Test + fun `PUT preferences rejects a body userId that differs from the caller`() = testApplication { + setupApp() + + val response = client.put("/api/v1/preferences") { + header("X-User-ID", "attacker") + contentType(ContentType.Application.Json) + setBody("""{"userId":"victim","eventType":"file_shared","channels":[]}""") + } + + assertEquals(HttpStatusCode.Forbidden, response.status) + coVerify(exactly = 0) { notificationService.updatePreferences(any(), any(), any()) } + } + + @Test + fun `PUT preferences accepts a body userId that matches the caller`() = testApplication { + setupApp() + + val response = client.put("/api/v1/preferences") { + header("X-User-ID", "alice") + contentType(ContentType.Application.Json) + setBody("""{"userId":"alice","eventType":"comment_added","channels":["EMAIL"]}""") + } + + assertEquals(HttpStatusCode.NoContent, response.status) + coVerify(exactly = 1) { notificationService.updatePreferences("alice", "comment_added", listOf(DeliveryChannel.EMAIL)) } + } + + @Test + fun `PUT preferences without an authenticated identity is rejected`() = testApplication { + setupApp() + + val response = client.put("/api/v1/preferences") { + contentType(ContentType.Application.Json) + setBody("""{"userId":"victim","eventType":"file_shared","channels":[]}""") + } + + assertEquals(HttpStatusCode.Unauthorized, response.status) + coVerify(exactly = 0) { notificationService.updatePreferences(any(), any(), any()) } + } +}