test(api/rest): add captcha tests

This commit is contained in:
Cilly Leang 2026-02-16 22:32:43 +11:00
parent 46218aa3c7
commit 053b24a614
Signed by: cilly
GPG key ID: 6500251E087653C9
2 changed files with 68 additions and 3 deletions

View file

@ -0,0 +1,30 @@
package moe.lava.neon.tests.api
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import moe.lava.neon.api.ApiClient
import moe.lava.neon.api.endpoints.getExperiments
import moe.lava.neon.common.captcha.CaptchaResponse
class CaptchaTest : FunSpec({
val mock = DiscordApiMock()
val client = ApiClient(mock.engine, false)
val (captchaReq, captchaRes) = mock.generateCaptcha()
mock.isCaptchaEnabled = true
test("captcha should not be handled") {
val res = client.getExperiments().response
res.status.value shouldBe 400
}
test("captcha should be handled") {
client.setCaptchaHandler { req ->
captchaRes
.takeIf { req == captchaReq }
?: CaptchaResponse.Failed(Throwable())
}
val res = client.getExperiments().response
res.status.value shouldBe 200
}
})

View file

@ -15,11 +15,19 @@ import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.http.content.TextContent import io.ktor.http.content.TextContent
import io.ktor.http.headersOf import io.ktor.http.headersOf
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import moe.lava.neon.common.captcha.CaptchaRequest
import moe.lava.neon.common.captcha.CaptchaResponse
import moe.lava.neon.tests.api.mock.AuthResponse import moe.lava.neon.tests.api.mock.AuthResponse
@OptIn(ExperimentalSerializationApi::class)
private val JsonWithSnakecase = Json {
namingStrategy = JsonNamingStrategy.SnakeCase
}
private val JsonHeader = headersOf(HttpHeaders.ContentType, "application/json") private val JsonHeader = headersOf(HttpHeaders.ContentType, "application/json")
val idArb = Arb.long(1e18.toLong(), 1e20.toLong()) val idArb = Arb.long(1e18.toLong(), 1e20.toLong())
@ -29,21 +37,44 @@ val tokenArb = Arb.stringPattern("(mfa\\.[a-zA-Z0-9_-]{20,})|([a-zA-Z0-9_-]{23,2
class DiscordApiMock { class DiscordApiMock {
private val fingerprints = mutableListOf<String>() private val fingerprints = mutableListOf<String>()
private val logins = mutableMapOf<String, String>() private val logins = mutableMapOf<String, String>()
private var captcha: Pair<CaptchaRequest, CaptchaResponse.Success> = generateCaptcha()
var isCaptchaEnabled = false
fun createLogin(email: String, password: String) { fun createLogin(email: String, password: String) {
logins[email] = password logins[email] = password
} }
fun generateCaptcha(): Pair<CaptchaRequest, CaptchaResponse.Success> {
val req = CaptchaRequest(
listOf(Arb.string().single()),
Arb.string().single(),
Arb.string().single(),
Arb.string().single(),
Arb.string().single(),
Arb.string().single(),
true,
)
val res = CaptchaResponse.Success(Arb.string().single())
captcha = req to res
return req to res
}
val engine = MockEngine { req -> val engine = MockEngine { req ->
if (!req.url.toString().startsWith("https://discord.com/api/v9")) { if (!req.url.toString().startsWith("https://discord.com/api/v9")) {
return@MockEngine respondError(HttpStatusCode.NotFound) return@MockEngine respondError(HttpStatusCode.NotFound)
} }
if (isCaptchaEnabled) {
if (req.headers["X-Captcha-Key"] != captcha.second.token) {
return@MockEngine respondJson(JsonWithSnakecase.encodeToString(captcha.first), HttpStatusCode.BadRequest)
}
}
val path = req.url.encodedPath.replaceFirst("/api/v9", "") val path = req.url.encodedPath.replaceFirst("/api/v9", "")
return@MockEngine when (path) { return@MockEngine when (path) {
"/experiments" -> { "/experiments" -> {
val fp = Arb.string(18..20, "123456789").single() val fp = Arb.string(18..20, "123456789").single()
fingerprints.add(fp) fingerprints.add(fp)
respond(AuthResponse.Experiments(fp), headers = JsonHeader) respondJson(AuthResponse.Experiments(fp))
} }
"/auth/login" -> { "/auth/login" -> {
val body = req.body as? TextContent val body = req.body as? TextContent
@ -58,13 +89,17 @@ class DiscordApiMock {
return@MockEngine badReq("Unknown credentials") return@MockEngine badReq("Unknown credentials")
} }
respond(AuthResponse.Login(idArb.next(), tokenArb.next()), headers = JsonHeader) respondJson(AuthResponse.Login(idArb.next(), tokenArb.next()))
} }
else -> respondError(HttpStatusCode.NotFound) else -> respondError(HttpStatusCode.NotFound)
} }
} }
@Suppress("NOTHING_TO_INLINE") @Suppress("NOTHING_TO_INLINE")
inline fun MockRequestHandleScope.badReq(msg: String): HttpResponseData = private inline fun MockRequestHandleScope.badReq(msg: String): HttpResponseData =
respondError(HttpStatusCode.BadRequest, content = "[Neon] $msg") respondError(HttpStatusCode.BadRequest, content = "[Neon] $msg")
@Suppress("NOTHING_TO_INLINE")
private inline fun MockRequestHandleScope.respondJson(content: String, status: HttpStatusCode = HttpStatusCode.OK): HttpResponseData =
respond(content = content, status = status, headers = JsonHeader)
} }