diff --git a/buildSrc/src/main/kotlin/DependencyVersions.kt b/buildSrc/src/main/kotlin/DependencyVersions.kt index eb73bd2..1c9b58c 100644 --- a/buildSrc/src/main/kotlin/DependencyVersions.kt +++ b/buildSrc/src/main/kotlin/DependencyVersions.kt @@ -1,4 +1,5 @@ object DependencyVersions { + const val KTFMT = "0.15.1" const val KTOR = "2.3.12" const val JACKSON = "2.16.1" const val LOGBACK = "1.5.8" diff --git a/client/build.gradle.kts b/client/build.gradle.kts index 4e04632..c038a5d 100644 --- a/client/build.gradle.kts +++ b/client/build.gradle.kts @@ -3,7 +3,7 @@ import org.gradle.api.tasks.testing.logging.TestLogEvent plugins { id("Entropy.kotlin-common-conventions") - id("com.ncorti.ktfmt.gradle") version "0.15.1" + id("com.ncorti.ktfmt.gradle") version DependencyVersions.KTFMT application } diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 1ba6e4c..59670e7 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -3,7 +3,7 @@ import org.gradle.api.tasks.testing.logging.TestLogEvent plugins { id("Entropy.kotlin-common-conventions") - id("com.ncorti.ktfmt.gradle") version "0.15.1" + id("com.ncorti.ktfmt.gradle") version DependencyVersions.KTFMT `java-library` } diff --git a/core/src/main/kotlin/http/ClientErrorCodes.kt b/core/src/main/kotlin/http/ClientErrorCodes.kt index 4d81e10..2057b10 100644 --- a/core/src/main/kotlin/http/ClientErrorCodes.kt +++ b/core/src/main/kotlin/http/ClientErrorCodes.kt @@ -5,3 +5,4 @@ val UPDATE_REQUIRED = ClientErrorCode("updateRequired") val INVALID_API_VERSION = ClientErrorCode("invalidApiVersion") val EMPTY_NAME = ClientErrorCode("emptyName") val INVALID_ACHIEVEMENT_COUNT = ClientErrorCode("invalidAchievementCount") +val INVALID_SESSION = ClientErrorCode("invalidSession") diff --git a/core/src/main/kotlin/http/CustomHeader.kt b/core/src/main/kotlin/http/CustomHeader.kt new file mode 100644 index 0000000..289ef6c --- /dev/null +++ b/core/src/main/kotlin/http/CustomHeader.kt @@ -0,0 +1,5 @@ +package http + +object CustomHeader { + const val SESSION_ID = "session-id" +} diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 004b363..b02dff0 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -3,7 +3,7 @@ import org.gradle.api.tasks.testing.logging.TestLogEvent plugins { id("Entropy.kotlin-common-conventions") - id("com.ncorti.ktfmt.gradle") version "0.15.1" + id("com.ncorti.ktfmt.gradle") version DependencyVersions.KTFMT id("io.ktor.plugin") version DependencyVersions.KTOR application } diff --git a/server/src/main/kotlin/routes/RoutingUtils.kt b/server/src/main/kotlin/routes/RoutingUtils.kt new file mode 100644 index 0000000..79565c2 --- /dev/null +++ b/server/src/main/kotlin/routes/RoutingUtils.kt @@ -0,0 +1,42 @@ +package routes + +import auth.Session +import http.CustomHeader +import http.INVALID_SESSION +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import java.util.* +import util.ServerGlobals + +suspend fun requiresSession(call: ApplicationCall, fn: suspend (session: Session) -> Unit) { + val sessionIdStr = + call.request.header(CustomHeader.SESSION_ID) + ?: throw ClientException( + HttpStatusCode.Unauthorized, + INVALID_SESSION, + "Missing session id", + ) + + val sessionId = + try { + UUID.fromString(sessionIdStr) + } catch (e: IllegalArgumentException) { + throw ClientException( + HttpStatusCode.BadRequest, + INVALID_SESSION, + "Session ID was not a valid UUID", + e, + ) + } + + val session = + ServerGlobals.sessionStore.find(sessionId) + ?: throw ClientException( + HttpStatusCode.Unauthorized, + INVALID_SESSION, + "No session found for ID $sessionId", + ) + + fn(session) +} diff --git a/server/src/test/kotlin/plugins/RoutingTest.kt b/server/src/test/kotlin/plugins/RoutingTest.kt index eae0125..cacf6dc 100644 --- a/server/src/test/kotlin/plugins/RoutingTest.kt +++ b/server/src/test/kotlin/plugins/RoutingTest.kt @@ -1,34 +1,37 @@ package plugins import http.ClientErrorCode +import http.CustomHeader import io.kotest.matchers.longs.shouldBeGreaterThan import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain import io.kotest.matchers.types.shouldBeInstanceOf -import io.ktor.client.request.get -import io.ktor.client.request.header -import io.ktor.client.statement.bodyAsText -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode -import io.ktor.server.application.Application -import io.ktor.server.routing.get -import io.ktor.server.routing.routing -import io.ktor.server.testing.testApplication -import java.util.UUID +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* +import java.util.* import logging.KEY_DURATION import logging.KEY_REQUEST_ID import logging.KEY_ROUTE import logging.findLogField import org.junit.jupiter.api.Test import routes.ClientException +import routes.requiresSession import testCore.AbstractTest import testCore.shouldContainKeyValues import testCore.shouldMatchJson +import util.ServerGlobals +import util.makeSession class RoutingTest : AbstractTest() { @Test fun `Should handle client errors`() = testApplication { - application { ErrorThrowingController.installRoutes(this) } + application { TestingController.installRoutes(this) } val response = client.get("/client-error") response.status shouldBe HttpStatusCode.Conflict @@ -42,7 +45,7 @@ class RoutingTest : AbstractTest() { @Test fun `Should handle unexpected errors`() = testApplication { - application { ErrorThrowingController.installRoutes(this) } + application { TestingController.installRoutes(this) } val response = client.get("/internal-error") response.status shouldBe HttpStatusCode.InternalServerError @@ -100,9 +103,50 @@ class RoutingTest : AbstractTest() { val requestId = receivedLog.findLogField(KEY_REQUEST_ID) requestId.shouldNotBeNull() } + + @Test + fun `Should reject a call with no session ID`() = testApplication { + application { TestingController.installRoutes(this) } + + val response = client.get("/username") + response.status shouldBe HttpStatusCode.Unauthorized + response.bodyAsText().shouldContain("Missing session id") + } + + @Test + fun `Should reject a call with a malformed session ID`() = testApplication { + application { TestingController.installRoutes(this) } + + val response = client.get("/username") { header(CustomHeader.SESSION_ID, "foo") } + response.status shouldBe HttpStatusCode.BadRequest + response.bodyAsText().shouldContain("Session ID was not a valid UUID") + } + + @Test + fun `Should reject a call with a nonexistent session ID`() = testApplication { + application { TestingController.installRoutes(this) } + + val sessionId = UUID.randomUUID() + + val response = client.get("/username") { header(CustomHeader.SESSION_ID, sessionId) } + response.status shouldBe HttpStatusCode.Unauthorized + response.bodyAsText().shouldContain("No session found for ID $sessionId") + } + + @Test + fun `Should successfully extract a session that exists`() = testApplication { + application { TestingController.installRoutes(this) } + + val session = makeSession() + ServerGlobals.sessionStore.put(session) + + val response = client.get("/username") { header(CustomHeader.SESSION_ID, session.id) } + response.status shouldBe HttpStatusCode.OK + response.bodyAsText().shouldBe(session.name) + } } -private object ErrorThrowingController { +private object TestingController { fun installRoutes(application: Application) { application.routing { get("/internal-error") { throw NullPointerException("Test error") } @@ -113,6 +157,7 @@ private object ErrorThrowingController { "Entity conflicts with another", ) } + get("/username") { requiresSession(call) { call.respond(it.name) } } } } } diff --git a/server/src/test/kotlin/util/TestFactory.kt b/server/src/test/kotlin/util/TestFactory.kt index d931769..99d78f7 100644 --- a/server/src/test/kotlin/util/TestFactory.kt +++ b/server/src/test/kotlin/util/TestFactory.kt @@ -9,8 +9,9 @@ fun makeSession( id: UUID = UUID.randomUUID(), name: String = "Alyssa", ip: String = "1.2.3.4", + achievementCount: Int = 4, apiVersion: Int = OnlineConstants.API_VERSION -) = Session(id, name, ip, apiVersion) +) = Session(id, name, ip, achievementCount, apiVersion) fun makeGameSettings( mode: GameMode = GameMode.Entropy, diff --git a/test-core/build.gradle.kts b/test-core/build.gradle.kts index d48e09c..06e6791 100644 --- a/test-core/build.gradle.kts +++ b/test-core/build.gradle.kts @@ -1,6 +1,6 @@ plugins { id("Entropy.kotlin-common-conventions") - id("com.ncorti.ktfmt.gradle") version "0.15.1" + id("com.ncorti.ktfmt.gradle") version DependencyVersions.KTFMT `java-library` }