diff --git a/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/CaptchaTest.kt b/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/CaptchaTest.kt new file mode 100644 index 0000000..5c01a25 --- /dev/null +++ b/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/CaptchaTest.kt @@ -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 + } +}) diff --git a/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/DiscordApiMock.kt b/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/DiscordApiMock.kt index 4bf32d8..ba65bc1 100644 --- a/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/DiscordApiMock.kt +++ b/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/DiscordApiMock.kt @@ -15,11 +15,19 @@ import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.content.TextContent import io.ktor.http.headersOf +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNamingStrategy import kotlinx.serialization.json.jsonObject 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 +@OptIn(ExperimentalSerializationApi::class) +private val JsonWithSnakecase = Json { + namingStrategy = JsonNamingStrategy.SnakeCase +} private val JsonHeader = headersOf(HttpHeaders.ContentType, "application/json") 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 { private val fingerprints = mutableListOf() private val logins = mutableMapOf() + private var captcha: Pair = generateCaptcha() + + var isCaptchaEnabled = false fun createLogin(email: String, password: String) { logins[email] = password } + fun generateCaptcha(): Pair { + 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 -> if (!req.url.toString().startsWith("https://discord.com/api/v9")) { 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", "") return@MockEngine when (path) { "/experiments" -> { val fp = Arb.string(18..20, "123456789").single() fingerprints.add(fp) - respond(AuthResponse.Experiments(fp), headers = JsonHeader) + respondJson(AuthResponse.Experiments(fp)) } "/auth/login" -> { val body = req.body as? TextContent @@ -58,13 +89,17 @@ class DiscordApiMock { 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) } } @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") + + @Suppress("NOTHING_TO_INLINE") + private inline fun MockRequestHandleScope.respondJson(content: String, status: HttpStatusCode = HttpStatusCode.OK): HttpResponseData = + respond(content = content, status = status, headers = JsonHeader) }