Files
recipe/server/src/test/kotlin/dev/ulfrx/recipe/auth/AuthJwtTest.kt

119 lines
4.5 KiB
Kotlin

package dev.ulfrx.recipe.auth
import io.ktor.client.request.get
import io.ktor.client.request.header
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.auth.authenticate
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Negative + positive JWT validation coverage for the `/api/v1/me` boundary
* (AUTH-03, D-21..D-23). Tests run against a hand-mounted protected route under
* `authenticate("authentik")` so they exercise the same Ktor verifier path as
* production without depending on Postgres or Flyway (those land in
* `MeRouteTest` per Plan 02-02 Task 3).
*/
class AuthJwtTest {
private fun authConfigFor(support: JwtTestSupport): AuthConfig =
AuthConfig(
issuer = support.issuer,
audience = support.audience,
// jwksUrl is unused when a [jwkProvider] override is supplied — the value
// just needs to be a syntactically-valid URL so AuthConfig invariants hold.
jwksUrl = "https://test-authentik.invalid/application/o/jwks/",
leewaySeconds = 30,
)
private fun configure(
block: io.ktor.server.application.Application.(JwtTestSupport) -> Unit,
): Pair<JwtTestSupport, io.ktor.server.application.Application.() -> Unit> {
val support = JwtTestSupport()
return support to {
install(ContentNegotiation) { json() }
configureAuthentication(authConfigFor(support), support.provider)
routing {
authenticate("authentik") {
get("/protected") { call.respondText("ok") }
}
}
block(support)
}
}
@Test
fun `no Authorization header returns 401`() =
testApplication {
val (_, mod) = configure { }
application(mod)
val response = client.get("/protected")
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun `expired token returns 401`() =
testApplication {
val (support, mod) = configure { }
application(mod)
val token = support.mint(expiresInSeconds = -60)
val response =
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun `wrong issuer returns 401`() =
testApplication {
val (support, mod) = configure { }
application(mod)
val token = support.mint(iss = "https://attacker.invalid/")
val response =
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun `wrong audience returns 401`() =
testApplication {
val (support, mod) = configure { }
application(mod)
val token = support.mint(aud = "some-other-app")
val response =
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun `blank sub returns 401`() =
testApplication {
val (support, mod) = configure { }
application(mod)
// JWT spec disallows an empty `sub` claim (auth0/java-jwt rejects ""),
// so we omit the claim entirely — the validate block must reject both
// null and blank as required by D-21.
val token = support.mint(sub = null)
val response =
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun `valid RS256 token returns 200 from a protected test route`() =
testApplication {
val (support, mod) = configure { }
application(mod)
val token = support.mint()
val response =
client.get("/protected") { header(HttpHeaders.Authorization, "Bearer $token") }
assertEquals(HttpStatusCode.OK, response.status)
}
}