diff --git a/api/rest/build.gradle.kts b/api/rest/build.gradle.kts index a7f75e5..028e304 100644 --- a/api/rest/build.gradle.kts +++ b/api/rest/build.gradle.kts @@ -2,8 +2,10 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.androidMultiplatformLibrary) + alias(libs.plugins.kotest) alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.ksp) } kotlin { @@ -30,12 +32,28 @@ kotlin { } commonTest.dependencies { implementation(libs.kotlin.test) + implementation(libs.kotest.assertions) + implementation(libs.kotest.framework) + implementation(libs.kotest.property) + implementation(libs.ktor.client.mock) } jvmMain.dependencies { implementation(libs.ktor.client.okhttp) } + jvmTest.dependencies { + implementation(libs.kotest.runner.junit5) + } androidMain.dependencies { implementation(libs.ktor.client.okhttp) } } } + +tasks.named("jvmTest") { + useJUnitPlatform() +} + +//tasks.withType().configureEach { +// logger.lifecycle("UP-TO-DATE check for $name is disabled, forcing it to run.") +// outputs.upToDateWhen { false } +//} diff --git a/api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiClient.kt b/api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiClient.kt index bdad65b..ffbdc86 100644 --- a/api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiClient.kt +++ b/api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiClient.kt @@ -3,6 +3,7 @@ package moe.lava.neon.api import co.touchlab.kermit.Logger import io.ktor.client.HttpClient import io.ktor.client.call.body +import io.ktor.client.engine.HttpClientEngine import io.ktor.client.plugins.HttpSend import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.cookies.HttpCookies @@ -52,6 +53,7 @@ class ApiClient internal constructor( plugin(HttpSend).intercept { req -> logger.d { "Intercepting ${req.url.buildString()}" } val call = execute(req) + logger.d { "recv ${call.response.bodyAsText()}" } if (call.response.status.value != 400) return@intercept call logger.d { "Found 400 response: ${call.response.bodyAsText()}" } val captchaRequest = runCatching { call.response.body() } 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 new file mode 100644 index 0000000..4bf32d8 --- /dev/null +++ b/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/DiscordApiMock.kt @@ -0,0 +1,70 @@ +package moe.lava.neon.tests.api + +import io.kotest.property.Arb +import io.kotest.property.arbitrary.long +import io.kotest.property.arbitrary.next +import io.kotest.property.arbitrary.single +import io.kotest.property.arbitrary.string +import io.kotest.property.arbitrary.stringPattern +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.MockRequestHandleScope +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.respondError +import io.ktor.client.request.HttpResponseData +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.TextContent +import io.ktor.http.headersOf +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import moe.lava.neon.tests.api.mock.AuthResponse + +private val JsonHeader = headersOf(HttpHeaders.ContentType, "application/json") + +val idArb = Arb.long(1e18.toLong(), 1e20.toLong()) +// Good enough +val tokenArb = Arb.stringPattern("(mfa\\.[a-zA-Z0-9_-]{20,})|([a-zA-Z0-9_-]{23,28}\\.[a-zA-Z0-9_-]{6,7}\\.[a-zA-Z0-9_-]{38})") + +class DiscordApiMock { + private val fingerprints = mutableListOf() + private val logins = mutableMapOf() + + fun createLogin(email: String, password: String) { + logins[email] = password + } + + val engine = MockEngine { req -> + if (!req.url.toString().startsWith("https://discord.com/api/v9")) { + return@MockEngine respondError(HttpStatusCode.NotFound) + } + 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) + } + "/auth/login" -> { + val body = req.body as? TextContent + ?: return@MockEngine badReq("No body") + val json = Json.parseToJsonElement(body.text).jsonObject + val login = json["login"]?.jsonPrimitive?.content + ?: return@MockEngine badReq("No login") + val password = json["password"]?.jsonPrimitive?.content + ?: return@MockEngine badReq("No password") + + if (logins[login] != password) { + return@MockEngine badReq("Unknown credentials") + } + + respond(AuthResponse.Login(idArb.next(), tokenArb.next()), headers = JsonHeader) + } + else -> respondError(HttpStatusCode.NotFound) + } + } + + @Suppress("NOTHING_TO_INLINE") + inline fun MockRequestHandleScope.badReq(msg: String): HttpResponseData = + respondError(HttpStatusCode.BadRequest, content = "[Neon] $msg") +} diff --git a/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/EndpointTestFactory.kt b/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/EndpointTestFactory.kt new file mode 100644 index 0000000..f5cf9ee --- /dev/null +++ b/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/EndpointTestFactory.kt @@ -0,0 +1,84 @@ +package moe.lava.neon.tests.api + +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.withClue +import io.kotest.core.spec.style.FunSpec +import io.kotest.core.spec.style.funSpec +import io.kotest.core.spec.style.scopes.FunSpecContainerScope +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.collections.shouldBeOneOf +import io.kotest.matchers.collections.shouldContainAllInAnyOrder +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.ktor.client.statement.request +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import moe.lava.neon.api.ApiResponse +import kotlin.io.encoding.Base64 + +fun endpointTestFactory( + endpoint: String, + testName: String?, + response: suspend () -> ApiResponse, + also: suspend FunSpecContainerScope.(response: ApiResponse) -> Unit, +) = funSpec { + context(testName ?: endpoint) { + val res = response() + val req = res.response.request + + test("has correct base url") { + req.url.toString().startsWith("https://discord.com/api/v9/") shouldBe true + } + test("uses correct endpoint") { + req.url.encodedPath.replace("/api/v9/", "") shouldBe endpoint + } + test("has valid super props") { + val props = req.headers["X-Super-Properties"] + props.shouldNotBeNull() + val decoded = withClue("should be decodable") { + shouldNotThrowAny { + Base64.decode(props).decodeToString() + } + } + val parsed = withClue("should be parsable") { + shouldNotThrowAny { + Json.parseToJsonElement(decoded).jsonObject + } + } + withClue("has props") { + parsed.keys shouldContainAllInAnyOrder setOf( + "os", + "browser", + "browser_user_agent", + "browser_version", + "client_build_number", + "release_channel", + "system_locale", + ) + } + val userAgent = withClue("has valid user agent") { + val agent = parsed["browser_user_agent"]?.jsonPrimitive + agent?.isString.shouldBeTrue() + agent.content + } + withClue("has matching user agent") { + userAgent shouldBeOneOf setOf(req.headers["User-Agent"], "") + } + } + test("has correct body") { + shouldNotThrowAny { res.body() } + } + also(res) + } +} + +fun FunSpec.withFactory( + testName: String? = null, + endpoint: String, + response: suspend () -> ApiResponse, + also: suspend FunSpecContainerScope.(response: ApiResponse) -> Unit = {}, +) { + include(endpointTestFactory(endpoint, testName, response, also)) +} + diff --git a/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/endpoints/AuthTest.kt b/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/endpoints/AuthTest.kt new file mode 100644 index 0000000..760c244 --- /dev/null +++ b/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/endpoints/AuthTest.kt @@ -0,0 +1,54 @@ +package moe.lava.neon.tests.api.endpoints + +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.ktor.client.statement.request +import io.ktor.http.content.TextContent +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import moe.lava.neon.api.ApiClient +import moe.lava.neon.api.endpoints.getExperiments +import moe.lava.neon.api.endpoints.login +import moe.lava.neon.tests.api.DiscordApiMock +import moe.lava.neon.tests.api.withFactory + +class AuthTest : FunSpec({ + val mock = DiscordApiMock() + val client = ApiClient(mock.engine, false) + + var fp: String? = null + withFactory( + testName = "get experiments", + endpoint = "experiments", + response = client::getExperiments + ) { res -> + val body = res.body() + fp = body.fingerprint + } + + val email = "hello@example.com" + val password = "supersecurepassword" + mock.createLogin(email, password) + withFactory( + testName = "login with real creds", + endpoint = "auth/login", + response = { client.login(email, password, fp!!) } + ) { res -> + val req = res.response.request + val headers = req.headers + test("has correct fingerprint") { + headers["X-Fingerprint"] shouldBe fp + } + context("has correct body") { + val body = shouldNotThrowAny { Json.parseToJsonElement((req.content as TextContent).text).jsonObject } + test("has correct login") { + body["login"]?.jsonPrimitive?.content shouldBe email + } + test("has correct password") { + body["password"]?.jsonPrimitive?.content shouldBe password + } + } + } +}) diff --git a/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/mock/AuthResponse.kt b/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/mock/AuthResponse.kt new file mode 100644 index 0000000..5ec810d --- /dev/null +++ b/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/mock/AuthResponse.kt @@ -0,0 +1,6 @@ +package moe.lava.neon.tests.api.mock + +object AuthResponse { + fun Experiments(fp: String) = """{"fingerprint":"$fp","assignments":[[3643362751,0,1,-1,0,4342,0,0,null,null,null],[1428438599,0,1,-1,2,3832,0,0,null,null,null],[1567199723,0,1,-1,1,1775,0,0,null,null,null],[4179344527,3,1,-1,0,3584,0,0,null,null,null],[1814483290,0,1,-1,0,6281,0,0,null,null,null],[4221006726,0,1,-1,0,4318,0,0,null,null,null],[1398673921,1,1,-1,0,4649,0,0,null,null,null],[1034661306,0,1,-1,0,1792,0,0,null,null,null],[3124003316,1,1,-1,0,1427,0,0,null,null,null],[2676348506,0,1,-1,0,4412,0,0,null,null,null],[4136574802,0,1,-1,1,4337,0,0,null,null,null],[4049571159,0,1,-1,0,504,0,0,null,null,null],[2539540256,0,1,-1,3,25,0,0,null,null,null],[1549543958,2,1,-1,0,6992,0,0,null,null,null],[1333727,0,1,-1,0,4978,0,0,null,null,null],[3029387945,1,1,-1,0,8670,0,0,null,null,null],[738080167,0,1,-1,1,9441,0,0,null,null,null],[3283745071,0,1,-1,1,9135,0,0,null,null,null],[373531156,0,3,-1,0,7954,0,0,null,null,null],[1617749743,0,1,-1,0,8281,0,0,null,null,null],[288968706,0,1,-1,3,7867,0,0,null,null,null],[2091202574,0,1,-1,0,8443,0,0,null,null,null],[4265918989,0,1,-1,0,2580,0,0,null,null,null],[1083932689,0,1,-1,0,6167,0,0,null,null,null],[1884426471,0,1,-1,0,5258,0,0,null,null,null],[2180379513,0,1,-1,4,5727,0,0,null,null,null],[759064140,0,1,-1,0,6050,0,0,null,null,null],[1680860120,0,1,-1,1,6633,0,0,null,null,null],[151550492,0,1,-1,2,102,0,1,null,null,null],[2054293512,0,0,-1,0,193,0,1,null,null,null],[2848826960,0,1,-1,0,7093,0,0,null,null,null],[3775594731,3,1,-1,0,1418,0,0,null,null,null],[878040044,1,1,-1,0,6738,0,1,null,null,null],[2990331215,0,1,-1,0,2786,0,0,null,null,null],[996399186,2,1,-1,0,5255,0,0,null,null,null],[3173338335,0,0,-1,0,178,0,1,null,null,null],[640084831,3,1,-1,0,5284,0,0,null,null,null],[1112953678,0,1,-1,0,3640,0,0,null,null,null],[4285324985,0,1,-1,0,8618,0,0,null,null,null],[4206392105,4,1,-1,0,5758,0,0,null,null,null],[1714347921,0,1,-1,0,2255,0,0,null,null,null],[3936291300,3,1,-1,0,3408,0,0,null,null,null],[2660711063,0,1,-1,0,8317,0,0,null,null,null],[1644303758,0,1,-1,0,2365,0,1,null,null,null],[114771571,0,1,-1,0,796,0,0,null,null,null],[437074334,5,1,-1,2,2482,0,1,null,null,null],[1978990512,3,1,-1,0,9101,0,0,null,null,null],[3378028029,3,2,-1,0,1997,0,1,null,null,null],[1046173986,0,1,-1,0,9264,0,0,null,null,null],[1757800499,1,1,-1,0,9654,0,0,null,null,null],[2849514387,0,1,-1,0,275,0,1,null,null,null],[2613104049,0,1,-1,0,8339,0,1,null,null,null],[2482010813,0,1,-1,0,4372,0,1,null,null,null],[1778984745,0,1,-1,0,1685,0,1,null,null,null],[2870923171,0,1,-1,0,101,0,0,null,null,null],[1598219105,1,2,-1,0,3159,0,1,null,null,null],[641666131,1,1,-1,0,2501,0,0,null,null,null]]}""" + fun Login(userId: Long, token: String) = """{"user_id":"$userId","token":"$token","user_settings":{"locale":"en-US","theme":"dark"}}""" +} diff --git a/api/shared/src/commonMain/kotlin/moe/lava/neon/api/ApiConstants.kt b/api/shared/src/commonMain/kotlin/moe/lava/neon/api/ApiConstants.kt index 36258d9..dbbe8f2 100644 --- a/api/shared/src/commonMain/kotlin/moe/lava/neon/api/ApiConstants.kt +++ b/api/shared/src/commonMain/kotlin/moe/lava/neon/api/ApiConstants.kt @@ -24,7 +24,14 @@ internal data class PlatformProps( internal expect val platformSuperProps: PlatformProps object ApiConstants { - val superProps = Base64.encode(Json.encodeToString(SuperProperties()).encodeToByteArray()) + @OptIn(ExperimentalSerializationApi::class) + val json = Json { + namingStrategy = JsonNamingStrategy.SnakeCase + ignoreUnknownKeys = true + encodeDefaults = true + } + + val superProps = Base64.encode(json.encodeToString(SuperProperties()).encodeToByteArray()) val baseHeaders = mapOf( "X-Debug-Options" to "bugReporterEnabled", "X-Discord-Locale" to "en-US", @@ -34,13 +41,6 @@ object ApiConstants { const val userAgent = "Discord-Android/311020;RNA" const val gatewayUserAgent = "okhttp/4.12.0" - @OptIn(ExperimentalSerializationApi::class) - val json = Json { - namingStrategy = JsonNamingStrategy.SnakeCase - ignoreUnknownKeys = true - encodeDefaults = true - } - // TODO: Desktop uses separate properties @Suppress("PropertyName") @Serializable diff --git a/build.gradle.kts b/build.gradle.kts index d225e2b..62884ba 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,9 @@ plugins { alias(libs.plugins.composeMultiplatform) apply false alias(libs.plugins.composeCompiler) apply false alias(libs.plugins.koinCompiler) apply false + alias(libs.plugins.kotest) apply false alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.kotlinSerialization) apply false + alias(libs.plugins.ksp) apply false alias(libs.plugins.sqldelight) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dca678f..189f3c9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,9 +21,11 @@ junit = "4.13.2" kermit = "2.0.8" koin-bom = "4.2.0-RC1" koin-plugin = "0.3.0" +kotest = "6.1.2" kotlin = "2.3.0" kotlinx-coroutines = "1.10.2" kotlinx-serialization = "1.10.0" +ksp = "2.3.4" ktor = "3.4.0" material3 = "1.11.0-alpha02" material3-adaptive = "1.3.0-alpha04" @@ -62,6 +64,10 @@ koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" } koin-compose-navigation3 = { module = "io.insert-koin:koin-compose-navigation3" } koin-core = { module = "io.insert-koin:koin-core" } koin-test = { module = "io.insert-koin:koin-test" } +kotest-assertions = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" } +kotest-framework = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" } +kotest-property = { module = "io.kotest:kotest-property", version.ref = "kotest" } +kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } @@ -69,6 +75,7 @@ kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serializa kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } @@ -81,6 +88,8 @@ composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "k composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "compose-hot-reload" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } koinCompiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" } +kotest = { id = "io.kotest", version.ref = "kotest" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }