1 Commits

Author SHA1 Message Date
Rocky 505515f746 test(auth): cover refresh route operation id forwarding
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Add AuthRoutesTest to lock in the contract for /v1/auth/refresh:
- forwards a valid refreshOperationId UUID into SessionService.refresh
- returns 400 with invalid_request when the operation id is malformed

These tests back the idempotent session refresh behaviour introduced in
4c9e5fe ('Make session refresh retries idempotent').
2026-08-30 12:10:56 +08:00
@@ -0,0 +1,99 @@
package com.osglab.account.features.auth
import com.osglab.account.common.api.installApiStatusPages
import com.osglab.account.common.security.SESSION_AUTH_NAME
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
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.auth.Authentication
import io.ktor.server.auth.bearer
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import java.time.Instant
import java.util.UUID
import kotlinx.serialization.json.Json
import kotlin.test.Test
class AuthRoutesTest {
@Test
fun `refresh route forwards the stable operation identifier`() = testApplication {
val operationId = UUID.fromString("10000000-0000-0000-0000-000000000029")
val accountId = UUID.fromString("20000000-0000-0000-0000-000000000029")
val service = mockk<SessionService>()
coEvery { service.refresh("refresh-from-ios", operationId) } returns
SessionTokens(
accountId = accountId,
accessToken = "access-replacement",
accessTokenExpiresAt = Instant.ofEpochSecond(2_000_000_000),
refreshToken = "refresh-replacement",
refreshTokenExpiresAt = Instant.ofEpochSecond(2_100_000_000),
)
application {
installTestAuthRoutes(service)
}
val response = client.post("/v1/auth/refresh") {
contentType(ContentType.Application.Json)
setBody(
"""
{
"refreshToken": "refresh-from-ios",
"refreshOperationId": "$operationId"
}
""".trimIndent(),
)
}
response.status shouldBe HttpStatusCode.OK
response.bodyAsText() shouldContain """"refreshToken":"refresh-replacement""""
coVerify(exactly = 1) { service.refresh("refresh-from-ios", operationId) }
}
@Test
fun `refresh route rejects a malformed operation identifier`() = testApplication {
val service = mockk<SessionService>(relaxed = true)
application {
installTestAuthRoutes(service)
}
val response = client.post("/v1/auth/refresh") {
contentType(ContentType.Application.Json)
setBody(
"""
{
"refreshToken": "refresh-from-ios",
"refreshOperationId": "not-a-uuid"
}
""".trimIndent(),
)
}
response.status shouldBe HttpStatusCode.BadRequest
response.bodyAsText() shouldContain """"code":"invalid_request""""
coVerify(exactly = 0) { service.refresh(any(), any()) }
}
}
private fun io.ktor.server.application.Application.installTestAuthRoutes(
service: SessionService,
) {
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
installApiStatusPages()
install(Authentication) {
bearer(SESSION_AUTH_NAME) {
authenticate { null }
}
}
routing { authRoutes(service) }
}