Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GT-2112 Present auth error messages to the user on login #3213

Merged
merged 11 commits into from
Nov 7, 2023
Merged
Prev Previous commit
Next Next commit
create a method to extract the AuthToken from an auth API response
frett committed Nov 6, 2023

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
commit 20e8cc1719f7dcdfc509e9c9fedb69d24f58db9a
1 change: 1 addition & 0 deletions library/account/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -35,6 +35,7 @@ dependencies {
ksp(libs.dagger.compiler)
ksp(libs.hilt.compiler)

testImplementation(libs.json)
testImplementation(libs.kotlin.coroutines.test)
testImplementation(libs.turbine)
}
Original file line number Diff line number Diff line change
@@ -4,8 +4,12 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.compose.runtime.Composable
import kotlinx.coroutines.flow.Flow
import org.ccci.gto.android.common.Ordered
import org.ccci.gto.android.common.jsonapi.JsonApiConverter
import org.ccci.gto.android.common.jsonapi.model.JsonApiObject
import org.cru.godtools.account.AccountType
import org.cru.godtools.api.model.AuthToken
import org.json.JSONException
import retrofit2.Response

internal interface AccountProvider : Ordered {
val type: AccountType
@@ -24,3 +28,37 @@ internal interface AccountProvider : Ordered {

suspend fun authenticateWithMobileContentApi(): Result<AuthToken>
}

internal fun Response<JsonApiObject<AuthToken>>.extractAuthToken() = when {
isSuccessful -> {
val obj = body()
val data = obj?.dataSingle

when {
obj == null -> Result.failure(AuthenticationException.UnknownError)
obj.hasErrors -> obj.parseErrors()
data == null -> Result.failure(AuthenticationException.UnknownError)
else -> Result.success(data)
}
}
else -> errorBody()?.string()
?.let {
try {
JsonApiConverter.Builder().build().fromJson(it, AuthToken::class.java)
} catch (_: JSONException) {
null
}
}
?.parseErrors()
?: Result.failure(AuthenticationException.UnknownError)
}

private fun JsonApiObject<AuthToken>.parseErrors(): Result<AuthToken> {
errors.forEach {
when (it.code) {
AuthToken.ERROR_USER_ALREADY_EXISTS -> return Result.failure(AuthenticationException.UserAlreadyExists)
AuthToken.ERROR_USER_NOT_FOUND -> return Result.failure(AuthenticationException.UserNotFound)
}
}
return Result.failure(AuthenticationException.UnknownError)
}
Original file line number Diff line number Diff line change
@@ -5,4 +5,7 @@ internal sealed class AuthenticationException : Exception() {
data object MissingCredentials : AuthenticationException()
data object UnableToRefreshCredentials : AuthenticationException()
data object UnknownError : AuthenticationException()

data object UserAlreadyExists : AuthenticationException()
data object UserNotFound : AuthenticationException()
}
Original file line number Diff line number Diff line change
@@ -23,6 +23,7 @@ import org.ccci.gto.android.common.kotlin.coroutines.getStringFlow
import org.cru.godtools.account.AccountType
import org.cru.godtools.account.provider.AccountProvider
import org.cru.godtools.account.provider.AuthenticationException
import org.cru.godtools.account.provider.extractAuthToken
import org.cru.godtools.api.AuthApi
import org.cru.godtools.api.model.AuthToken

@@ -80,10 +81,8 @@ internal class FacebookAccountProvider @Inject constructor(
resp = accessToken.authenticateWithMobileContentApi()
}

val token = resp.takeIf { it.isSuccessful }?.body()?.takeUnless { it.hasErrors }?.dataSingle
?: return Result.failure(AuthenticationException.UnknownError)
prefs.edit { putString(accessToken.PREF_USER_ID, token.userId) }
return Result.success(token)
return resp.extractAuthToken()
.onSuccess { prefs.edit { putString(accessToken.PREF_USER_ID, it.userId) } }
}

private suspend fun AccessToken.authenticateWithMobileContentApi() =
Original file line number Diff line number Diff line change
@@ -24,6 +24,7 @@ import org.ccci.gto.android.common.play.auth.signin.GoogleSignInKtx
import org.cru.godtools.account.AccountType
import org.cru.godtools.account.provider.AccountProvider
import org.cru.godtools.account.provider.AuthenticationException
import org.cru.godtools.account.provider.extractAuthToken
import org.cru.godtools.api.AuthApi
import org.cru.godtools.api.model.AuthToken
import timber.log.Timber
@@ -87,10 +88,8 @@ internal class GoogleAccountProvider @Inject constructor(
?: return Result.failure(AuthenticationException.MissingCredentials)
}

val token = resp.takeIf { it.isSuccessful }?.body()?.takeUnless { it.hasErrors }?.dataSingle
?: return Result.failure(AuthenticationException.UnknownError)
prefs.edit { putString(account.PREF_USER_ID, token.userId) }
return Result.success(token)
return resp.extractAuthToken()
.onSuccess { prefs.edit { putString(account.PREF_USER_ID, it.userId) } }
}

private suspend fun GoogleSignInAccount.authenticateWithMobileContentApi() =
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package org.cru.godtools.account.provider

import kotlin.test.Test
import kotlin.test.assertEquals
import okhttp3.ResponseBody.Companion.toResponseBody
import org.ccci.gto.android.common.jsonapi.JsonApiConverter
import org.ccci.gto.android.common.jsonapi.model.JsonApiError
import org.ccci.gto.android.common.jsonapi.model.JsonApiObject
import org.cru.godtools.api.model.AuthToken
import retrofit2.Response

private const val ERROR_USER_ALREADY_EXISTS = """
{"errors":[{"code":"user_already_exists","detail":"User account already exists."}]}
"""
private const val ERROR_USER_NOT_FOUND = """
{"errors":[{"code":"user_not_found","detail":"User account not found."}]}
"""

class AccountProviderTest {
// region extractAuthToken()
@Test
fun `extractAuthToken() - Successful`() {
val token = AuthToken()
val response = Response.success(JsonApiObject.of(token))

assertEquals(token, response.extractAuthToken().getOrNull())
}

@Test
fun `extractAuthToken() - Error - Successful status with jsonapi error`() {
val error = JsonApiError(code = AuthToken.ERROR_USER_ALREADY_EXISTS)
val response = Response.success(JsonApiObject.error<AuthToken>(error))

assertEquals(
AuthenticationException.UserAlreadyExists,
response.extractAuthToken().exceptionOrNull()
)
}

@Test
fun `extractAuthToken() - Error - 500 ISE`() {
val response = Response.error<JsonApiObject<AuthToken>>(500, "500 ISE".toResponseBody())

assertEquals(
AuthenticationException.UnknownError,
response.extractAuthToken().exceptionOrNull()
)
}

@Test
fun `extractAuthToken() - Error - User Already Exists`() {
val response = Response.error<JsonApiObject<AuthToken>>(400, ERROR_USER_ALREADY_EXISTS.toResponseBody())

assertEquals(
AuthenticationException.UserAlreadyExists,
response.extractAuthToken().exceptionOrNull()
)
}

@Test
fun `extractAuthToken() - Error - User Not Found`() {
val response = Response.error<JsonApiObject<AuthToken>>(400, ERROR_USER_NOT_FOUND.toResponseBody())

assertEquals(
AuthenticationException.UserNotFound,
response.extractAuthToken().exceptionOrNull()
)
}

@Test
fun `extractAuthToken() - Error - Valid jsonapi response with error status`() {
val converter = JsonApiConverter.Builder()
.addClasses(AuthToken::class.java)
.build()

val response = Response.error<JsonApiObject<AuthToken>>(
400,
converter.toJson(JsonApiObject.of(AuthToken())).toResponseBody()
)

assertEquals(
AuthenticationException.UnknownError,
response.extractAuthToken().exceptionOrNull()
)
}
// endregion extractAuthToken()
}
Original file line number Diff line number Diff line change
@@ -16,6 +16,11 @@ data class AuthToken(
@JsonApiAttribute(ATTR_TOKEN)
var token: String? = null
) {
companion object {
const val ERROR_USER_ALREADY_EXISTS = "user_already_exists"
const val ERROR_USER_NOT_FOUND = "user_not_found"
}

@JsonApiType("auth-token-request")
data class Request(
@JsonApiAttribute(ATTR_FACEBOOK_ACCESS_TOKEN) val fbAccessToken: String? = null,