diff --git a/encryptedlogging/build.gradle.kts b/encryptedlogging/build.gradle.kts index c1bdcde..7e210f8 100644 --- a/encryptedlogging/build.gradle.kts +++ b/encryptedlogging/build.gradle.kts @@ -72,11 +72,11 @@ dependencies { ksp(libs.androidx.room.compiler) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.core) - implementation(libs.volley) testImplementation(libs.androidx.test.core.ktx) testImplementation(libs.assertj.core) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockwebserver) testImplementation(libs.robolectric) androidTestImplementation(libs.assertj.core) androidTestImplementation(libs.androidx.test.ext.junit) diff --git a/encryptedlogging/src/main/kotlin/com/automattic/encryptedlogging/AutomatticEncryptedLogging.kt b/encryptedlogging/src/main/kotlin/com/automattic/encryptedlogging/AutomatticEncryptedLogging.kt index 5d1c0e6..4af3136 100644 --- a/encryptedlogging/src/main/kotlin/com/automattic/encryptedlogging/AutomatticEncryptedLogging.kt +++ b/encryptedlogging/src/main/kotlin/com/automattic/encryptedlogging/AutomatticEncryptedLogging.kt @@ -2,12 +2,9 @@ package com.automattic.encryptedlogging import android.content.Context import android.util.Base64 -import com.android.volley.RequestQueue -import com.android.volley.toolbox.BasicNetwork -import com.android.volley.toolbox.DiskBasedCache -import com.android.volley.toolbox.HurlStack import com.automattic.encryptedlogging.model.encryptedlogging.EncryptedLoggingKey import com.automattic.encryptedlogging.model.encryptedlogging.LogEncrypter +import com.automattic.encryptedlogging.network.EncryptedLogHttpClient import com.automattic.encryptedlogging.network.rest.wpcom.encryptedlog.EncryptedLogRestClient import com.automattic.encryptedlogging.persistence.EncryptedLogDatabase import com.automattic.encryptedlogging.store.EncryptedLogStore @@ -23,22 +20,11 @@ internal class AutomatticEncryptedLogging( encryptedLoggingKey: String, clientSecret: String, ) : EncryptedLogging { - private companion object { - private const val MAX_CACHE_SIZE_IN_BYTES = 1024 * 1024 * 10 - } - private val encryptedLogStore: EncryptedLogStore init { - val cache = DiskBasedCache( - File.createTempFile("tempcache", null), - MAX_CACHE_SIZE_IN_BYTES - ) - val network = BasicNetwork(HurlStack()) - val requestQueue = RequestQueue(cache, network).apply { - start() - } - val encryptedLogRestClient = EncryptedLogRestClient(requestQueue, clientSecret) + val httpClient = EncryptedLogHttpClient(clientSecret) + val encryptedLogRestClient = EncryptedLogRestClient(httpClient) val database = EncryptedLogDatabase.getInstance(context) val logEncrypter = LogEncrypter( EncryptedLoggingKey(Key.fromBytes(Base64.decode(encryptedLoggingKey, Base64.DEFAULT))) diff --git a/encryptedlogging/src/main/kotlin/com/automattic/encryptedlogging/network/EncryptedLogHttpClient.kt b/encryptedlogging/src/main/kotlin/com/automattic/encryptedlogging/network/EncryptedLogHttpClient.kt new file mode 100644 index 0000000..4359223 --- /dev/null +++ b/encryptedlogging/src/main/kotlin/com/automattic/encryptedlogging/network/EncryptedLogHttpClient.kt @@ -0,0 +1,51 @@ +package com.automattic.encryptedlogging.network + +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL + +private const val AUTHORIZATION_HEADER = "Authorization" +private const val CONTENT_TYPE_HEADER = "Content-Type" +private const val CONTENT_TYPE_JSON = "application/json" +private const val UUID_HEADER = "log-uuid" +private const val DEFAULT_UPLOAD_URL = "https://public-api.wordpress.com/rest/v1.1/encrypted-logging/" + +internal class EncryptedLogHttpClient( + private val clientSecret: String, + private val uploadUrl: String = DEFAULT_UPLOAD_URL +) { + @Throws(IOException::class) + fun uploadLog(logUuid: String, contents: String): HttpResponse { + val url = URL(uploadUrl) + val connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + doOutput = true + setRequestProperty(CONTENT_TYPE_HEADER, CONTENT_TYPE_JSON) + setRequestProperty(AUTHORIZATION_HEADER, clientSecret) + setRequestProperty(UUID_HEADER, logUuid) + } + + try { + connection.outputStream.use { outputStream -> + outputStream.write(contents.toByteArray()) + outputStream.flush() + } + + val statusCode = connection.responseCode + val responseBody = if (statusCode in 200..299) { + connection.inputStream.bufferedReader().use { it.readText() } + } else { + connection.errorStream?.bufferedReader()?.use { it.readText() } ?: "" + } + + return HttpResponse(statusCode, responseBody) + } finally { + connection.disconnect() + } + } +} + +internal data class HttpResponse( + val statusCode: Int, + val body: String +) diff --git a/encryptedlogging/src/main/kotlin/com/automattic/encryptedlogging/network/EncryptedLogUploadRequest.kt b/encryptedlogging/src/main/kotlin/com/automattic/encryptedlogging/network/EncryptedLogUploadRequest.kt deleted file mode 100644 index 8799a85..0000000 --- a/encryptedlogging/src/main/kotlin/com/automattic/encryptedlogging/network/EncryptedLogUploadRequest.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.automattic.encryptedlogging.network - -import com.android.volley.NetworkResponse -import com.android.volley.ParseError -import com.android.volley.Request -import com.android.volley.Response -import com.android.volley.Response.ErrorListener -import com.android.volley.VolleyError -import com.android.volley.toolbox.HttpHeaderParser -import org.json.JSONObject - -private const val AUTHORIZATION_HEADER = "Authorization" -private const val CONTENT_TYPE_HEADER = "Content-Type" -private const val CONTENT_TYPE_JSON = "application/json" -private const val UUID_HEADER = "log-uuid" - -internal class EncryptedLogUploadRequest( - private val logUuid: String, - private val contents: String, - private val clientSecret: String, - private val successListener: Response.Listener, - errorListener: ErrorListener -) : Request(Method.POST, "https://public-api.wordpress.com/rest/v1.1/encrypted-logging/", errorListener) { - override fun getHeaders(): Map { - return mapOf( - CONTENT_TYPE_HEADER to CONTENT_TYPE_JSON, - AUTHORIZATION_HEADER to clientSecret, - UUID_HEADER to logUuid - ) - } - - @Suppress("ForbiddenComment") - override fun getBody(): ByteArray { - // TODO: Max file size is 10MB - maybe we should just handle that in the error callback? - return contents.toByteArray() - } - - @Suppress("TooGenericExceptionCaught", "SwallowedException") - override fun parseNetworkResponse(response: NetworkResponse?): Response { - return try { - Response.success(response, HttpHeaderParser.parseCacheHeaders(response)) - } catch (e: Exception) { - try { - val json = JSONObject(response.toString()) - val errorMessage = json.getString("message") - Response.error(VolleyError(errorMessage)) - } catch (jsonParsingError: Throwable) { - Response.error(ParseError(jsonParsingError)) - } - } - } - - override fun deliverResponse(response: NetworkResponse) { - successListener.onResponse(response) - } -} diff --git a/encryptedlogging/src/main/kotlin/com/automattic/encryptedlogging/network/rest/wpcom/encryptedlog/EncryptedLogRestClient.kt b/encryptedlogging/src/main/kotlin/com/automattic/encryptedlogging/network/rest/wpcom/encryptedlog/EncryptedLogRestClient.kt index 5a1a115..684788b 100644 --- a/encryptedlogging/src/main/kotlin/com/automattic/encryptedlogging/network/rest/wpcom/encryptedlog/EncryptedLogRestClient.kt +++ b/encryptedlogging/src/main/kotlin/com/automattic/encryptedlogging/network/rest/wpcom/encryptedlog/EncryptedLogRestClient.kt @@ -1,70 +1,52 @@ package com.automattic.encryptedlogging.network.rest.wpcom.encryptedlog import android.util.Log -import com.android.volley.NoConnectionError -import com.android.volley.RequestQueue -import com.android.volley.VolleyError -import kotlinx.coroutines.suspendCancellableCoroutine +import com.automattic.encryptedlogging.network.EncryptedLogHttpClient +import com.automattic.encryptedlogging.store.UploadEncryptedLogError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.json.JSONException import org.json.JSONObject -import com.automattic.encryptedlogging.network.EncryptedLogUploadRequest -import com.automattic.encryptedlogging.store.UploadEncryptedLogError -import kotlin.coroutines.resume +import java.io.IOException private const val INVALID_REQUEST = "invalid-request" private const val TOO_MANY_REQUESTS = "too_many_requests" internal class EncryptedLogRestClient( - private val requestQueue: RequestQueue, - private val clientSecret: String, + private val httpClient: EncryptedLogHttpClient, ) { suspend fun uploadLog(logUuid: String, contents: String): UploadEncryptedLogResult { - return suspendCancellableCoroutine { cont -> - val request = EncryptedLogUploadRequest(logUuid, contents, clientSecret, { - cont.resume(UploadEncryptedLogResult.LogUploaded) - }, { error -> - cont.resume(UploadEncryptedLogResult.LogUploadFailed(mapError(error))) - }) - cont.invokeOnCancellation { request.cancel() } - requestQueue.add(request) + return withContext(Dispatchers.IO) { + try { + val response = httpClient.uploadLog(logUuid, contents) + if (response.statusCode in 200..299) { + UploadEncryptedLogResult.LogUploaded + } else { + UploadEncryptedLogResult.LogUploadFailed(mapError(response.statusCode, response.body)) + } + } catch (e: IOException) { + UploadEncryptedLogResult.LogUploadFailed(UploadEncryptedLogError.NoConnection) + } } } - /** - * { - * "error":"too_many_requests", - * "message":"You're sending too many messages. Please slow down." - * } - * { - * "error":"invalid-request", - * "message":"Invalid UUID: uuids must only contain letters, numbers, dashes, and curly brackets" - * } - */ @Suppress("ReturnCount") - private fun mapError(error: VolleyError): UploadEncryptedLogError { - if (error is NoConnectionError) { - return UploadEncryptedLogError.NoConnection + private fun mapError(statusCode: Int, responseBody: String): UploadEncryptedLogError { + val json = try { + JSONObject(responseBody) + } catch (jsonException: JSONException) { + Log.e(TAG, "Received response not in JSON format: " + jsonException.message) + return UploadEncryptedLogError.Unknown(statusCode = statusCode, message = responseBody) } - error.networkResponse?.let { networkResponse -> - val statusCode = networkResponse.statusCode - val dataString = String(networkResponse.data) - val json = try { - JSONObject(dataString) - } catch (jsonException: JSONException) { - Log.e(TAG, "Received response not in JSON format: " + jsonException.message) - return UploadEncryptedLogError.Unknown(message = dataString) - } - val errorMessage = json.getString("message") - json.getString("error").let { errorType -> - if (errorType == INVALID_REQUEST) { - return UploadEncryptedLogError.InvalidRequest - } else if (errorType == TOO_MANY_REQUESTS) { - return UploadEncryptedLogError.TooManyRequests - } + val errorMessage = json.getString("message") + json.getString("error").let { errorType -> + if (errorType == INVALID_REQUEST) { + return UploadEncryptedLogError.InvalidRequest + } else if (errorType == TOO_MANY_REQUESTS) { + return UploadEncryptedLogError.TooManyRequests } - return UploadEncryptedLogError.Unknown(statusCode, errorMessage) } - return UploadEncryptedLogError.Unknown() + return UploadEncryptedLogError.Unknown(statusCode, errorMessage) } companion object { diff --git a/encryptedlogging/src/test/kotlin/com/automattic/encryptedlogging/network/rest/wpcom/encryptedlog/EncryptedLogRestClientTest.kt b/encryptedlogging/src/test/kotlin/com/automattic/encryptedlogging/network/rest/wpcom/encryptedlog/EncryptedLogRestClientTest.kt new file mode 100644 index 0000000..9a743c7 --- /dev/null +++ b/encryptedlogging/src/test/kotlin/com/automattic/encryptedlogging/network/rest/wpcom/encryptedlog/EncryptedLogRestClientTest.kt @@ -0,0 +1,150 @@ +package com.automattic.encryptedlogging.network.rest.wpcom.encryptedlog + +import com.automattic.encryptedlogging.network.EncryptedLogHttpClient +import com.automattic.encryptedlogging.store.UploadEncryptedLogError +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class EncryptedLogRestClientTest { + private lateinit var mockWebServer: MockWebServer + private lateinit var restClient: EncryptedLogRestClient + + private val testClientSecret = "test-secret" + private val testLogUuid = "test-uuid-123" + private val testContents = "encrypted log contents" + + @Before + fun setup() { + mockWebServer = MockWebServer() + mockWebServer.start() + + val httpClient = EncryptedLogHttpClient( + testClientSecret, + mockWebServer.url("/rest/v1.1/encrypted-logging/").toString() + ) + restClient = EncryptedLogRestClient(httpClient) + } + + @After + fun tearDown() { + mockWebServer.shutdown() + } + + @Test + fun `uploadLog sends correct request with headers and body`() { + mockWebServer.enqueue(MockResponse().setResponseCode(200)) + + runBlocking { + restClient.uploadLog(testLogUuid, testContents) + } + + mockWebServer.takeRequest().let { request -> + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).isEqualTo("/rest/v1.1/encrypted-logging/") + assertThat(request.getHeader("Content-Type")).isEqualTo("application/json") + assertThat(request.getHeader("Authorization")).isEqualTo(testClientSecret) + assertThat(request.getHeader("log-uuid")).isEqualTo(testLogUuid) + assertThat(request.body.readUtf8()).isEqualTo(testContents) + } + } + + @Test + fun `uploadLog returns LogUploaded on success`() { + mockWebServer.enqueue(MockResponse().setResponseCode(200)) + + val result = runBlocking { + restClient.uploadLog(testLogUuid, testContents) + } + + assertThat(result).isInstanceOf(UploadEncryptedLogResult.LogUploaded::class.java) + } + + @Test + fun `uploadLog returns InvalidRequest error for invalid-request error type`() { + mockWebServer.enqueue(MockResponse().setResponseCode(400).setBody(""" + { + "error": "invalid-request", + "message": "Invalid UUID: uuids must only contain letters, numbers, dashes, and curly brackets" + } + """.trimIndent())) + + val result = runBlocking { + restClient.uploadLog(testLogUuid, testContents) + } + + assertThat((result as UploadEncryptedLogResult.LogUploadFailed).error) + .isInstanceOf(UploadEncryptedLogError.InvalidRequest::class.java) + } + + @Test + fun `uploadLog returns TooManyRequests error for too_many_requests error type`() { + mockWebServer.enqueue(MockResponse().setResponseCode(429).setBody(""" + { + "error": "too_many_requests", + "message": "You're sending too many messages. Please slow down." + } + """.trimIndent())) + + val result = runBlocking { + restClient.uploadLog(testLogUuid, testContents) + } + + assertThat((result as UploadEncryptedLogResult.LogUploadFailed).error) + .isInstanceOf(UploadEncryptedLogError.TooManyRequests::class.java) + } + + @Test + fun `uploadLog returns Unknown error for unrecognized error type`() { + mockWebServer.enqueue(MockResponse().setResponseCode(500).setBody(""" + { + "error": "some-other-error", + "message": "Some other error occurred" + } + """.trimIndent())) + + val result = runBlocking { + restClient.uploadLog(testLogUuid, testContents) + } + + ((result as UploadEncryptedLogResult.LogUploadFailed).error as UploadEncryptedLogError.Unknown).let { error -> + assertThat(error.statusCode).isEqualTo(500) + assertThat(error.message).isEqualTo("Some other error occurred") + } + } + + @Test + fun `uploadLog returns Unknown error with statusCode and message for non-JSON error response`() { + mockWebServer.enqueue(MockResponse().setResponseCode(500).setBody("Plain text error message")) + + val result = runBlocking { + restClient.uploadLog(testLogUuid, testContents) + } + + ((result as UploadEncryptedLogResult.LogUploadFailed).error as UploadEncryptedLogError.Unknown).let { error -> + assertThat(error.statusCode).isEqualTo(500) + assertThat(error.message).isEqualTo("Plain text error message") + } + } + + @Test + fun `uploadLog returns NoConnection error when IOException occurs`() { + mockWebServer.shutdown() + + val result = runBlocking { + restClient.uploadLog(testLogUuid, testContents) + } + + assertThat((result as UploadEncryptedLogResult.LogUploadFailed).error) + .isInstanceOf(UploadEncryptedLogError.NoConnection::class.java) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9872479..e9e7b67 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,10 +15,10 @@ kotlin = "2.1.21" kotlinx-binary-compatibility-validator = "0.18.0" kotlinx-coroutines = '1.10.2' ksp = "2.1.21-2.0.2" +mockwebserver = "3.2.0" robolectric = "4.14.1" terl-lazysodium-android = "5.2.0@aar" turbine = "1.2.1" -volley = "1.2.1" [libraries] androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } @@ -36,10 +36,10 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "mockwebserver" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } terl-lazysodium-android = { module = "com.goterl:lazysodium-android", version.ref = "terl-lazysodium-android" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } -volley = { group = "com.android.volley", name = "volley", version.ref = "volley" } [plugins] android-library = { id = "com.android.library", version.ref = "agp" }