From 0da091162e7cf8abe29cf77046cb6eab4462d2ff Mon Sep 17 00:00:00 2001 From: adalpari Date: Mon, 8 Jun 2026 16:05:09 +0200 Subject: [PATCH] Fix NPE when application password response is missing password or UUID The Gson-deserialized ApplicationPasswordCreationResponse declared its fields as non-null, but Gson populates objects via reflection and bypasses Kotlin's null checks. When the API returns a 200 response missing the `password` or `uuid` field, those nulls flowed into the non-null ApplicationPasswordCreationPayload constructor, crashing the app (in the minified release build the parameter null-check compiles to a bare getClass() call, producing the reported NPE). Make the response fields nullable and validate them, returning the existing error payload instead of crashing. Applied to both the Jetpack and WP-API clients, which share the response type. Crash report: https://a8c.sentry.io/issues/7522022287/ Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ApplicationPasswordsApiResponses.kt | 6 +- .../JetpackApplicationPasswordsRestClient.kt | 22 ++-- .../WPApiApplicationPasswordsRestClient.kt | 18 ++- ...tpackApplicationPasswordsRestClientTest.kt | 118 +++++++++++++++++ ...WPApiApplicationPasswordsRestClientTest.kt | 121 ++++++++++++++++++ 5 files changed, 266 insertions(+), 19 deletions(-) create mode 100644 libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/JetpackApplicationPasswordsRestClientTest.kt create mode 100644 libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/WPApiApplicationPasswordsRestClientTest.kt diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsApiResponses.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsApiResponses.kt index 09f03aa9e59c..116cc952a7b8 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsApiResponses.kt +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsApiResponses.kt @@ -3,9 +3,9 @@ package org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords import com.google.gson.annotations.SerializedName internal data class ApplicationPasswordCreationResponse( - @SerializedName("uuid") val uuid: ApplicationPasswordUUID, - @SerializedName("name") val name: String, - @SerializedName("password") val password: String + @SerializedName("uuid") val uuid: ApplicationPasswordUUID?, + @SerializedName("name") val name: String?, + @SerializedName("password") val password: String? ) internal data class ApplicationPasswordsFetchResponse( diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/JetpackApplicationPasswordsRestClient.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/JetpackApplicationPasswordsRestClient.kt index ddc716922edf..c9cd016def1e 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/JetpackApplicationPasswordsRestClient.kt +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/JetpackApplicationPasswordsRestClient.kt @@ -47,14 +47,18 @@ internal class JetpackApplicationPasswordsRestClient @Inject constructor( return when (response) { is JetpackSuccess -> { - response.data?.let { - ApplicationPasswordCreationPayload(it.password, it.uuid) - } ?: ApplicationPasswordCreationPayload( - BaseNetworkError( - GenericErrorType.UNKNOWN, - "Password missing from response" + val password = response.data?.password + val uuid = response.data?.uuid + if (password != null && uuid != null) { + ApplicationPasswordCreationPayload(password, uuid) + } else { + ApplicationPasswordCreationPayload( + BaseNetworkError( + GenericErrorType.UNKNOWN, + "Password or UUID missing from response" + ) ) - ) + } } is JetpackError -> ApplicationPasswordCreationPayload(response.error) @@ -77,8 +81,8 @@ internal class JetpackApplicationPasswordsRestClient @Inject constructor( return when (response) { is JetpackSuccess -> { - response.data?.firstOrNull { it.name == applicationName }?.let { - ApplicationPasswordUUIDFetchPayload(it.uuid) + response.data?.firstOrNull { it.name == applicationName }?.uuid?.let { + ApplicationPasswordUUIDFetchPayload(it) } ?: ApplicationPasswordUUIDFetchPayload( BaseNetworkError( GenericErrorType.UNKNOWN, diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/WPApiApplicationPasswordsRestClient.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/WPApiApplicationPasswordsRestClient.kt index 5c9b3f3f077a..891cd5ad732f 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/WPApiApplicationPasswordsRestClient.kt +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/WPApiApplicationPasswordsRestClient.kt @@ -53,14 +53,18 @@ internal class WPApiApplicationPasswordsRestClient @Inject constructor( return when (response) { is WPAPIResponse.Success -> { - response.data?.let { - ApplicationPasswordCreationPayload(it.password, it.uuid) - } ?: ApplicationPasswordCreationPayload( - BaseNetworkError( - GenericErrorType.UNKNOWN, - "Password missing from response" + val password = response.data?.password + val uuid = response.data?.uuid + if (password != null && uuid != null) { + ApplicationPasswordCreationPayload(password, uuid) + } else { + ApplicationPasswordCreationPayload( + BaseNetworkError( + GenericErrorType.UNKNOWN, + "Password or UUID missing from response" + ) ) - ) + } } is WPAPIResponse.Error -> diff --git a/libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/JetpackApplicationPasswordsRestClientTest.kt b/libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/JetpackApplicationPasswordsRestClientTest.kt new file mode 100644 index 000000000000..fd0d6f344795 --- /dev/null +++ b/libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/JetpackApplicationPasswordsRestClientTest.kt @@ -0,0 +1,118 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel.JetpackTunnelGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel.JetpackTunnelGsonRequestBuilder.JetpackResponse +import org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel.JetpackTunnelGsonRequestBuilder.JetpackResponse.JetpackError +import org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel.JetpackTunnelGsonRequestBuilder.JetpackResponse.JetpackSuccess +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@ExperimentalCoroutinesApi +@RunWith(RobolectricTestRunner::class) +class JetpackApplicationPasswordsRestClientTest { + private val applicationName = "name" + private val testSite = SiteModel().apply { + url = "http://test-site.com" + } + + private val jetpackTunnelGsonRequestBuilder: JetpackTunnelGsonRequestBuilder = mock() + + private lateinit var restClient: JetpackApplicationPasswordsRestClient + + @Before + fun setup() { + restClient = JetpackApplicationPasswordsRestClient( + jetpackTunnelGsonRequestBuilder = jetpackTunnelGsonRequestBuilder, + appContext = mock(), + dispatcher = mock(), + requestQueue = mock(), + accessToken = mock(), + userAgent = mock() + ) + } + + @Test + fun `given a valid response, when creating a password, then return the credentials`() = runTest { + givenPostResponse( + JetpackSuccess(ApplicationPasswordCreationResponse(uuid = "uuid", name = applicationName, password = "pwd")) + ) + + val payload = restClient.createApplicationPassword(testSite, applicationName) + + assertFalse(payload.isError) + assertEquals("pwd", payload.password) + assertEquals("uuid", payload.uuid) + } + + @Test + fun `given a response with a null password, when creating a password, then return an error`() = runTest { + givenPostResponse( + JetpackSuccess(ApplicationPasswordCreationResponse(uuid = "uuid", name = applicationName, password = null)) + ) + + val payload = restClient.createApplicationPassword(testSite, applicationName) + + assertTrue(payload.isError) + assertEquals("Password or UUID missing from response", payload.error.message) + } + + @Test + fun `given a response with a null uuid, when creating a password, then return an error`() = runTest { + givenPostResponse( + JetpackSuccess(ApplicationPasswordCreationResponse(uuid = null, name = applicationName, password = "pwd")) + ) + + val payload = restClient.createApplicationPassword(testSite, applicationName) + + assertTrue(payload.isError) + assertEquals("Password or UUID missing from response", payload.error.message) + } + + @Test + fun `given a null response body, when creating a password, then return an error`() = runTest { + givenPostResponse(JetpackSuccess(null)) + + val payload = restClient.createApplicationPassword(testSite, applicationName) + + assertTrue(payload.isError) + assertEquals("Password or UUID missing from response", payload.error.message) + } + + @Test + fun `given a network error, when creating a password, then propagate the error`() = runTest { + val error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.SERVER_ERROR)) + givenPostResponse(JetpackError(error)) + + val payload = restClient.createApplicationPassword(testSite, applicationName) + + assertTrue(payload.isError) + assertEquals(error, payload.error) + } + + private suspend fun givenPostResponse(response: JetpackResponse) { + whenever( + jetpackTunnelGsonRequestBuilder.syncPostRequest( + any(), + any(), + any(), + any(), + eq(ApplicationPasswordCreationResponse::class.java) + ) + ).thenReturn(response) + } +} diff --git a/libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/WPApiApplicationPasswordsRestClientTest.kt b/libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/WPApiApplicationPasswordsRestClientTest.kt new file mode 100644 index 000000000000..e85db7a63682 --- /dev/null +++ b/libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/WPApiApplicationPasswordsRestClientTest.kt @@ -0,0 +1,121 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.rest.wpapi.CookieNonceAuthenticator +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPINetworkError +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@ExperimentalCoroutinesApi +@RunWith(RobolectricTestRunner::class) +class WPApiApplicationPasswordsRestClientTest { + private val applicationName = "name" + private val testSite = SiteModel().apply { + url = "http://test-site.com" + username = "username" + password = "password" + } + + private val cookieNonceAuthenticator: CookieNonceAuthenticator = mock() + + private lateinit var restClient: WPApiApplicationPasswordsRestClient + + @Before + fun setup() { + restClient = WPApiApplicationPasswordsRestClient( + wpApiGsonRequestBuilder = mock(), + cookieNonceAuthenticator = cookieNonceAuthenticator, + noCookieRequestQueue = mock(), + requestQueue = mock(), + dispatcher = mock(), + userAgent = mock() + ) + } + + @Test + fun `given a valid response, when creating a password, then return the credentials`() = runTest { + givenCreationResponse( + WPAPIResponse.Success( + ApplicationPasswordCreationResponse(uuid = "uuid", name = applicationName, password = "pwd") + ) + ) + + val payload = restClient.createApplicationPassword(testSite, applicationName) + + assertFalse(payload.isError) + assertEquals("pwd", payload.password) + assertEquals("uuid", payload.uuid) + } + + @Test + fun `given a response with a null password, when creating a password, then return an error`() = runTest { + givenCreationResponse( + WPAPIResponse.Success( + ApplicationPasswordCreationResponse(uuid = "uuid", name = applicationName, password = null) + ) + ) + + val payload = restClient.createApplicationPassword(testSite, applicationName) + + assertTrue(payload.isError) + assertEquals("Password or UUID missing from response", payload.error.message) + } + + @Test + fun `given a response with a null uuid, when creating a password, then return an error`() = runTest { + givenCreationResponse( + WPAPIResponse.Success( + ApplicationPasswordCreationResponse(uuid = null, name = applicationName, password = "pwd") + ) + ) + + val payload = restClient.createApplicationPassword(testSite, applicationName) + + assertTrue(payload.isError) + assertEquals("Password or UUID missing from response", payload.error.message) + } + + @Test + fun `given a null response body, when creating a password, then return an error`() = runTest { + givenCreationResponse(WPAPIResponse.Success(null)) + + val payload = restClient.createApplicationPassword(testSite, applicationName) + + assertTrue(payload.isError) + assertEquals("Password or UUID missing from response", payload.error.message) + } + + @Test + fun `given a network error, when creating a password, then propagate the error`() = runTest { + val error = WPAPINetworkError(BaseNetworkError(GenericErrorType.SERVER_ERROR)) + givenCreationResponse(WPAPIResponse.Error(error)) + + val payload = restClient.createApplicationPassword(testSite, applicationName) + + assertTrue(payload.isError) + assertEquals(error, payload.error) + } + + private suspend fun givenCreationResponse(response: WPAPIResponse) { + whenever( + cookieNonceAuthenticator.makeAuthenticatedWPAPIRequest>( + eq(testSite), + any() + ) + ).thenReturn(response) + } +}