feat(sdk): AuthGoogleApp mutation and Google ID token auth without Firebase
Made-with: Cursor
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
mutation AuthGoogleNew($accessToken: String!) {
|
mutation AuthGoogleApp($accessToken: String!) {
|
||||||
authGoogleApp(input: { accessToken: $accessToken }) {
|
authGoogleApp(input: { accessToken: $accessToken }) {
|
||||||
token
|
token
|
||||||
name
|
name
|
||||||
@@ -4,7 +4,7 @@ import com.apollographql.apollo.ApolloClient
|
|||||||
import com.apollographql.apollo.api.Optional
|
import com.apollographql.apollo.api.Optional
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import org.db3.airmq.sdk.AuthGoogleNewMutation
|
import org.db3.airmq.sdk.AuthGoogleAppMutation
|
||||||
import org.db3.airmq.sdk.LoginLocalMutation
|
import org.db3.airmq.sdk.LoginLocalMutation
|
||||||
import org.db3.airmq.sdk.RegisterMutation
|
import org.db3.airmq.sdk.RegisterMutation
|
||||||
import org.db3.airmq.sdk.auth.model.AuthProvider
|
import org.db3.airmq.sdk.auth.model.AuthProvider
|
||||||
@@ -82,28 +82,22 @@ class FirebaseAuthService @Inject constructor(
|
|||||||
localEmailAuthStore.clearProfile().getOrThrow()
|
localEmailAuthStore.clearProfile().getOrThrow()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun signInWithGoogle(token: String): User {
|
/**
|
||||||
|
* Google OAuth: send the Google ID token from Credential Manager to [authGoogleApp], then persist the API JWT
|
||||||
|
* (same storage as email login). Does not sign in to Firebase; the backend verifies the Google JWT.
|
||||||
|
*/
|
||||||
|
private suspend fun signInWithGoogle(googleIdToken: String): User {
|
||||||
localEmailAuthStore.clearProfile().getOrThrow()
|
localEmailAuthStore.clearProfile().getOrThrow()
|
||||||
val firebaseSessionUser = firebaseSessionManager.signInWithGoogle(token)
|
val response = apolloClient
|
||||||
try {
|
.mutation(AuthGoogleAppMutation(accessToken = googleIdToken))
|
||||||
val apiToken = exchangeFirebaseIdTokenWithBackend(firebaseSessionUser.firebaseAccessToken)
|
.execute()
|
||||||
apiTokenStore.saveToken(apiToken).getOrThrow()
|
response.exception?.let { throw it }
|
||||||
return firebaseSessionUser.user
|
response.errors?.firstOrNull()?.let { gqlError ->
|
||||||
} catch (e: Throwable) {
|
throw IllegalStateException(gqlError.message)
|
||||||
rollbackSessionAfterFailedGoogleBackendAuth()
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Firebase may already be signed in when the backend exchange fails; clear everything so the user stays logged out. */
|
|
||||||
private suspend fun rollbackSessionAfterFailedGoogleBackendAuth() {
|
|
||||||
try {
|
|
||||||
firebaseSessionManager.signOut()
|
|
||||||
apiTokenStore.clearToken().getOrThrow()
|
|
||||||
localEmailAuthStore.clearProfile().getOrThrow()
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
// Best-effort; caller still receives the original sign-in failure.
|
|
||||||
}
|
}
|
||||||
|
val auth = response.data?.authGoogleApp
|
||||||
|
?: error("Google sign-in response missing data.")
|
||||||
|
return commitEmailAuthSession(auth.token, auth._id, auth.email, auth.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun commitEmailAuthSession(
|
private suspend fun commitEmailAuthSession(
|
||||||
@@ -130,16 +124,4 @@ class FirebaseAuthService @Inject constructor(
|
|||||||
isAuthenticated = true
|
isAuthenticated = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun exchangeFirebaseIdTokenWithBackend(firebaseIdToken: String): String {
|
|
||||||
val response = apolloClient
|
|
||||||
.mutation(AuthGoogleNewMutation(accessToken = firebaseIdToken))
|
|
||||||
.execute()
|
|
||||||
response.exception?.let { throw it }
|
|
||||||
response.errors?.firstOrNull()?.let { gqlError ->
|
|
||||||
throw IllegalStateException(gqlError.message)
|
|
||||||
}
|
|
||||||
return response.data?.authGoogleApp?.token
|
|
||||||
?: error("Backend auth exchange succeeded without API token.")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,5 @@ import org.db3.airmq.sdk.auth.model.User
|
|||||||
interface FirebaseSessionManager {
|
interface FirebaseSessionManager {
|
||||||
suspend fun getUser(): User?
|
suspend fun getUser(): User?
|
||||||
suspend fun isSignedIn(): Boolean
|
suspend fun isSignedIn(): Boolean
|
||||||
suspend fun signInWithGoogle(idToken: String): FirebaseSessionUser
|
|
||||||
suspend fun signOut()
|
suspend fun signOut()
|
||||||
}
|
}
|
||||||
|
|
||||||
data class FirebaseSessionUser(
|
|
||||||
val user: User,
|
|
||||||
val firebaseAccessToken: String
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
package org.db3.airmq.sdk.auth
|
package org.db3.airmq.sdk.auth
|
||||||
|
|
||||||
import com.google.android.gms.tasks.Task
|
|
||||||
import com.google.firebase.auth.FirebaseAuth
|
import com.google.firebase.auth.FirebaseAuth
|
||||||
import com.google.firebase.auth.FirebaseUser
|
import com.google.firebase.auth.FirebaseUser
|
||||||
import com.google.firebase.auth.GoogleAuthProvider
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.coroutines.resume
|
|
||||||
import kotlin.coroutines.resumeWithException
|
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
||||||
import org.db3.airmq.sdk.auth.model.User
|
import org.db3.airmq.sdk.auth.model.User
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -20,18 +15,6 @@ class FirebaseSessionManagerImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun isSignedIn(): Boolean = firebaseAuth.currentUser != null
|
override suspend fun isSignedIn(): Boolean = firebaseAuth.currentUser != null
|
||||||
|
|
||||||
override suspend fun signInWithGoogle(idToken: String): FirebaseSessionUser {
|
|
||||||
val credential = GoogleAuthProvider.getCredential(idToken, null)
|
|
||||||
val authResult = firebaseAuth.signInWithCredential(credential).awaitResult()
|
|
||||||
val firebaseUser = authResult.user ?: error("Google sign-in completed without a Firebase user.")
|
|
||||||
val firebaseAccessToken = firebaseUser.getIdToken(true).awaitResult().token
|
|
||||||
?: error("Firebase token exchange completed without an ID token.")
|
|
||||||
return FirebaseSessionUser(
|
|
||||||
user = firebaseUser.toUser(),
|
|
||||||
firebaseAccessToken = firebaseAccessToken
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun signOut() {
|
override suspend fun signOut() {
|
||||||
firebaseAuth.signOut()
|
firebaseAuth.signOut()
|
||||||
}
|
}
|
||||||
@@ -43,15 +26,3 @@ class FirebaseSessionManagerImpl @Inject constructor(
|
|||||||
isAuthenticated = true
|
isAuthenticated = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun <T> Task<T>.awaitResult(): T = suspendCancellableCoroutine { continuation ->
|
|
||||||
addOnSuccessListener { result ->
|
|
||||||
continuation.resume(result)
|
|
||||||
}
|
|
||||||
addOnFailureListener { exception ->
|
|
||||||
continuation.resumeWithException(exception)
|
|
||||||
}
|
|
||||||
addOnCanceledListener {
|
|
||||||
continuation.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.db3.airmq.sdk.network
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.apollographql.apollo.api.ApolloRequest
|
import com.apollographql.apollo.api.ApolloRequest
|
||||||
import com.apollographql.apollo.api.ApolloResponse
|
import com.apollographql.apollo.api.ApolloResponse
|
||||||
|
import com.apollographql.apollo.api.Error
|
||||||
import com.apollographql.apollo.api.Operation
|
import com.apollographql.apollo.api.Operation
|
||||||
import com.apollographql.apollo.interceptor.ApolloInterceptor
|
import com.apollographql.apollo.interceptor.ApolloInterceptor
|
||||||
import com.apollographql.apollo.interceptor.ApolloInterceptorChain
|
import com.apollographql.apollo.interceptor.ApolloInterceptorChain
|
||||||
@@ -12,6 +13,9 @@ import kotlinx.coroutines.flow.onEach
|
|||||||
/**
|
/**
|
||||||
* Logs every GraphQL operation name, document, variable payload ([Operation] `toString`),
|
* Logs every GraphQL operation name, document, variable payload ([Operation] `toString`),
|
||||||
* and response data or errors to logcat (tag [TAG]).
|
* and response data or errors to logcat (tag [TAG]).
|
||||||
|
*
|
||||||
|
* GraphQL [Error]s are logged with [Error.path], [Error.extensions] (e.g. `code`, `stacktrace`),
|
||||||
|
* and [Error.nonStandardFields], not only message and locations.
|
||||||
*/
|
*/
|
||||||
class ApolloLoggingInterceptor : ApolloInterceptor {
|
class ApolloLoggingInterceptor : ApolloInterceptor {
|
||||||
override fun <D : Operation.Data> intercept(
|
override fun <D : Operation.Data> intercept(
|
||||||
@@ -27,36 +31,145 @@ class ApolloLoggingInterceptor : ApolloInterceptor {
|
|||||||
if (response.exception != null) {
|
if (response.exception != null) {
|
||||||
Log.e(
|
Log.e(
|
||||||
TAG,
|
TAG,
|
||||||
"---------- GraphQL response: $operationName FAILED ----------",
|
"---------- GraphQL response: $operationName FAILED (transport / parse) ----------",
|
||||||
response.exception
|
response.exception
|
||||||
)
|
)
|
||||||
|
logGraphqlErrorsIfAny(operationName, response)
|
||||||
|
logResponseExtensionsIfAny(operationName, response)
|
||||||
|
val dataStr = response.data?.toString() ?: "null"
|
||||||
|
logChunked("$TAG.response.$operationName.data", dataStr)
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "---------- GraphQL response: $operationName ----------")
|
Log.d(TAG, "---------- GraphQL response: $operationName ----------")
|
||||||
val errors = response.errors
|
logGraphqlErrorsIfAny(operationName, response)
|
||||||
if (!errors.isNullOrEmpty()) {
|
logResponseExtensionsIfAny(operationName, response)
|
||||||
errors.forEachIndexed { i, err ->
|
|
||||||
Log.w(TAG, "errors[$i]: ${err.message} (locations=${err.locations})")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "errors: none")
|
|
||||||
}
|
|
||||||
val dataStr = response.data?.toString() ?: "null"
|
val dataStr = response.data?.toString() ?: "null"
|
||||||
logChunked("$TAG.response.$operationName.data", dataStr)
|
logChunked("$TAG.response.$operationName.data", dataStr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Android single-line log limit is ~4k; split large payloads. */
|
private fun <D : Operation.Data> logResponseExtensionsIfAny(
|
||||||
private fun logChunked(label: String, text: String, chunkSize: Int = 3500) {
|
operationName: String,
|
||||||
|
response: ApolloResponse<D>
|
||||||
|
) {
|
||||||
|
val ext = response.extensions
|
||||||
|
if (ext.isEmpty()) {
|
||||||
|
Log.d(TAG, "response.extensions: none")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logChunked(
|
||||||
|
"$TAG.response.$operationName.protocolExtensions",
|
||||||
|
ext.entries.joinToString(prefix = "{", postfix = "}") { (k, v) ->
|
||||||
|
"\n $k = ${formatExtensionValue(v)}"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <D : Operation.Data> logGraphqlErrorsIfAny(
|
||||||
|
operationName: String,
|
||||||
|
response: ApolloResponse<D>
|
||||||
|
) {
|
||||||
|
val errors = response.errors
|
||||||
|
if (errors.isNullOrEmpty()) {
|
||||||
|
Log.d(TAG, "graphql.errors: none")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Log.w(TAG, "graphql.errors: count=${errors.size} (partial data may still be present)")
|
||||||
|
errors.forEachIndexed { index, err ->
|
||||||
|
val detail = formatGraphqlErrorDetail(err)
|
||||||
|
logChunked(
|
||||||
|
"$TAG.response.$operationName.graphqlError[$index]",
|
||||||
|
detail,
|
||||||
|
priority = Log.WARN
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatGraphqlErrorDetail(error: Error): String = buildString {
|
||||||
|
appendLine("message: ${error.message}")
|
||||||
|
val path = error.path
|
||||||
|
if (!path.isNullOrEmpty()) {
|
||||||
|
appendLine("path: ${path.joinToString(prefix = "[", postfix = "]")}")
|
||||||
|
} else {
|
||||||
|
appendLine("path: (none)")
|
||||||
|
}
|
||||||
|
val locs = error.locations
|
||||||
|
if (!locs.isNullOrEmpty()) {
|
||||||
|
appendLine(
|
||||||
|
"locations: " + locs.joinToString { loc ->
|
||||||
|
"line=${loc.line}, column=${loc.column}"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
appendLine("locations: (none)")
|
||||||
|
}
|
||||||
|
appendLine("extensions:")
|
||||||
|
appendIndentedMapOrEmpty(error.extensions, indent = 2)
|
||||||
|
appendLine("nonStandardFields:")
|
||||||
|
appendIndentedMapOrEmpty(error.nonStandardFields, indent = 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun StringBuilder.appendIndentedMapOrEmpty(
|
||||||
|
map: Map<String, Any?>?,
|
||||||
|
indent: Int
|
||||||
|
) {
|
||||||
|
val prefix = " ".repeat(indent)
|
||||||
|
if (map.isNullOrEmpty()) {
|
||||||
|
appendLine("$prefix(none)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
map.forEach { (k, v) ->
|
||||||
|
append(prefix)
|
||||||
|
append(k)
|
||||||
|
append(" = ")
|
||||||
|
appendLine(formatExtensionValue(v, indent + 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatExtensionValue(value: Any?, nestedIndent: Int = 0): String = when (value) {
|
||||||
|
null -> "null"
|
||||||
|
is Map<*, *> -> {
|
||||||
|
val prefix = "\n" + " ".repeat(nestedIndent)
|
||||||
|
value.entries.joinToString(prefix = "{", postfix = "}") { (k, v) ->
|
||||||
|
"$prefix${k.toString()} = ${formatExtensionValue(v, nestedIndent + 2)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Iterable<*> -> {
|
||||||
|
val prefix = "\n" + " ".repeat(nestedIndent)
|
||||||
|
value.withIndex().joinToString(prefix = "[", postfix = "]") { (i, v) ->
|
||||||
|
"$prefix[$i] ${formatExtensionValue(v, nestedIndent + 2)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Array<*> -> formatExtensionValue(value.toList(), nestedIndent)
|
||||||
|
else -> value.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android single-line log limit is ~4k; split large payloads.
|
||||||
|
* @param priority [Log.DEBUG] for normal traffic; [Log.WARN] for GraphQL error bodies.
|
||||||
|
*/
|
||||||
|
private fun logChunked(
|
||||||
|
label: String,
|
||||||
|
text: String,
|
||||||
|
chunkSize: Int = 3500,
|
||||||
|
priority: Int = Log.DEBUG
|
||||||
|
) {
|
||||||
|
val logLine: (String) -> Unit = { line ->
|
||||||
|
when (priority) {
|
||||||
|
Log.WARN -> Log.w(TAG, line)
|
||||||
|
Log.ERROR -> Log.e(TAG, line)
|
||||||
|
else -> Log.d(TAG, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (text.length <= chunkSize) {
|
if (text.length <= chunkSize) {
|
||||||
Log.d(TAG, "$label: $text")
|
logLine("$label: $text")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var start = 0
|
var start = 0
|
||||||
var part = 0
|
var part = 0
|
||||||
while (start < text.length) {
|
while (start < text.length) {
|
||||||
val end = minOf(start + chunkSize, text.length)
|
val end = minOf(start + chunkSize, text.length)
|
||||||
Log.d(TAG, "$label [part $part]: ${text.substring(start, end)}")
|
logLine("$label [part $part]: ${text.substring(start, end)}")
|
||||||
part++
|
part++
|
||||||
start = end
|
start = end
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
package org.db3.airmq.sdk.auth
|
package org.db3.airmq.sdk.auth
|
||||||
|
|
||||||
import com.apollographql.apollo.ApolloClient
|
import com.apollographql.apollo.ApolloClient
|
||||||
|
import com.apollographql.apollo.api.ApolloResponse
|
||||||
|
import com.benasher44.uuid.uuid4
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.db3.airmq.sdk.AuthGoogleNewMutation
|
import org.db3.airmq.sdk.AuthGoogleAppMutation
|
||||||
import org.db3.airmq.sdk.auth.model.AuthProvider
|
import org.db3.airmq.sdk.auth.model.AuthProvider
|
||||||
import org.db3.airmq.sdk.auth.model.User
|
import org.db3.airmq.sdk.auth.model.User
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -20,32 +22,12 @@ import org.junit.Test
|
|||||||
class FirebaseAuthServiceTest {
|
class FirebaseAuthServiceTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun signIn_google_failsWhenFirebaseSignInFails() = runTest {
|
fun signIn_google_failsWhenAuthGoogleAppFails() = runTest {
|
||||||
val sessionManager = FakeFirebaseSessionManager(signInError = IllegalStateException("firebase failed"))
|
val sessionManager = FakeFirebaseSessionManager(user = null, isSignedIn = false)
|
||||||
val apolloClient = mockk<ApolloClient>()
|
val apolloClient = mockk<ApolloClient>()
|
||||||
val tokenStore = FakeApiTokenStore()
|
val tokenStore = FakeApiTokenStore()
|
||||||
val localEmailStore = FakeLocalEmailAuthStore()
|
val localEmailStore = FakeLocalEmailAuthStore()
|
||||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
mockAuthGoogleAppError(apolloClient, IllegalStateException("backend failed"))
|
||||||
|
|
||||||
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
|
|
||||||
|
|
||||||
assertTrue(result.isFailure)
|
|
||||||
assertEquals("firebase failed", result.exceptionOrNull()?.message)
|
|
||||||
assertEquals(null, tokenStore.storedToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun signIn_google_failsWhenBackendExchangeFails() = runTest {
|
|
||||||
val sessionManager = FakeFirebaseSessionManager(
|
|
||||||
signInResult = FirebaseSessionUser(
|
|
||||||
user = User("uid-1", "user@airmq.cc", "User", true),
|
|
||||||
firebaseAccessToken = "firebase-token"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val apolloClient = mockk<ApolloClient>()
|
|
||||||
val tokenStore = FakeApiTokenStore()
|
|
||||||
val localEmailStore = FakeLocalEmailAuthStore()
|
|
||||||
mockAuthGoogleNewError(apolloClient, IllegalStateException("backend failed"))
|
|
||||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||||
|
|
||||||
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
|
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
|
||||||
@@ -53,17 +35,48 @@ class FirebaseAuthServiceTest {
|
|||||||
assertTrue(result.isFailure)
|
assertTrue(result.isFailure)
|
||||||
assertEquals("backend failed", result.exceptionOrNull()?.message)
|
assertEquals("backend failed", result.exceptionOrNull()?.message)
|
||||||
assertEquals(null, tokenStore.storedToken)
|
assertEquals(null, tokenStore.storedToken)
|
||||||
|
assertEquals(0, sessionManager.signOutCallCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun signIn_google_succeedsWhenAuthGoogleAppReturnsToken() = runTest {
|
||||||
|
val sessionManager = FakeFirebaseSessionManager(user = null, isSignedIn = false)
|
||||||
|
val apolloClient = mockk<ApolloClient>()
|
||||||
|
val tokenStore = FakeApiTokenStore()
|
||||||
|
val localEmailStore = FakeLocalEmailAuthStore()
|
||||||
|
val mockCall = mockk<com.apollographql.apollo.ApolloCall<AuthGoogleAppMutation.Data>>()
|
||||||
|
every { apolloClient.mutation(any<AuthGoogleAppMutation>()) } returns mockCall
|
||||||
|
val mutationData = AuthGoogleAppMutation.Data(
|
||||||
|
authGoogleApp = AuthGoogleAppMutation.AuthGoogleApp(
|
||||||
|
token = "api-jwt",
|
||||||
|
name = "N",
|
||||||
|
email = "u@test.dev",
|
||||||
|
roles = null,
|
||||||
|
_id = "backend-id",
|
||||||
|
regDate = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val apolloResponse = ApolloResponse.Builder(
|
||||||
|
AuthGoogleAppMutation(accessToken = "google-id-token"),
|
||||||
|
uuid4()
|
||||||
|
).data(mutationData).build()
|
||||||
|
coEvery { mockCall.execute() } returns apolloResponse
|
||||||
|
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||||
|
|
||||||
|
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
|
||||||
|
|
||||||
|
assertTrue(result.isSuccess)
|
||||||
|
assertEquals("api-jwt", tokenStore.storedToken)
|
||||||
|
assertEquals("backend-id", localEmailStore.getProfile()?.userId)
|
||||||
|
assertEquals("u@test.dev", localEmailStore.getProfile()?.email)
|
||||||
assertEquals(1, sessionManager.signOutCallCount)
|
assertEquals(1, sessionManager.signOutCallCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun isAuthenticated_trueWhenFirebaseUserAndApiTokenPresent() = runTest {
|
fun isAuthenticated_trueWhenFirebaseUserAndApiTokenPresent() = runTest {
|
||||||
val sessionManager = FakeFirebaseSessionManager(
|
val sessionManager = FakeFirebaseSessionManager(
|
||||||
isSignedIn = true,
|
user = User("uid-fb", "fb@test.dev", "FB", true),
|
||||||
signInResult = FirebaseSessionUser(
|
isSignedIn = true
|
||||||
user = User("uid-fb", "fb@test.dev", "FB", true),
|
|
||||||
firebaseAccessToken = "ft"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
val apolloClient = mockk<ApolloClient>()
|
val apolloClient = mockk<ApolloClient>()
|
||||||
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
|
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
|
||||||
@@ -78,7 +91,7 @@ class FirebaseAuthServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun isAuthenticated_trueWhenLocalEmailProfileAndApiTokenPresent() = runTest {
|
fun isAuthenticated_trueWhenLocalEmailProfileAndApiTokenPresent() = runTest {
|
||||||
val sessionManager = FakeFirebaseSessionManager(isSignedIn = false, signInResult = null)
|
val sessionManager = FakeFirebaseSessionManager(user = null, isSignedIn = false)
|
||||||
val apolloClient = mockk<ApolloClient>()
|
val apolloClient = mockk<ApolloClient>()
|
||||||
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
|
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
|
||||||
val localEmailStore = FakeLocalEmailAuthStore().apply {
|
val localEmailStore = FakeLocalEmailAuthStore().apply {
|
||||||
@@ -92,10 +105,7 @@ class FirebaseAuthServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
fun getUser_returnsNullWhenFirebaseUserButNoApiToken() = runTest {
|
fun getUser_returnsNullWhenFirebaseUserButNoApiToken() = runTest {
|
||||||
val sessionManager = FakeFirebaseSessionManager(
|
val sessionManager = FakeFirebaseSessionManager(
|
||||||
signInResult = FirebaseSessionUser(
|
user = User("uid-fb", "fb@test.dev", "FB", true)
|
||||||
user = User("uid-fb", "fb@test.dev", "FB", true),
|
|
||||||
firebaseAccessToken = "ft"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
val apolloClient = mockk<ApolloClient>()
|
val apolloClient = mockk<ApolloClient>()
|
||||||
val tokenStore = FakeApiTokenStore(storedToken = null)
|
val tokenStore = FakeApiTokenStore(storedToken = null)
|
||||||
@@ -108,10 +118,7 @@ class FirebaseAuthServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
fun getUser_returnsFirebaseUserWhenApiTokenPresentAndNoLocalProfile() = runTest {
|
fun getUser_returnsFirebaseUserWhenApiTokenPresentAndNoLocalProfile() = runTest {
|
||||||
val sessionManager = FakeFirebaseSessionManager(
|
val sessionManager = FakeFirebaseSessionManager(
|
||||||
signInResult = FirebaseSessionUser(
|
user = User("uid-fb", "fb@test.dev", "FB", true)
|
||||||
user = User("uid-fb", "fb@test.dev", "FB", true),
|
|
||||||
firebaseAccessToken = "ft"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
val apolloClient = mockk<ApolloClient>()
|
val apolloClient = mockk<ApolloClient>()
|
||||||
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
|
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
|
||||||
@@ -126,7 +133,7 @@ class FirebaseAuthServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun getUser_returnsLocalProfileWhenNoFirebaseUser() = runTest {
|
fun getUser_returnsLocalProfileWhenNoFirebaseUser() = runTest {
|
||||||
val sessionManager = FakeFirebaseSessionManager(isSignedIn = false, signInResult = null)
|
val sessionManager = FakeFirebaseSessionManager(user = null, isSignedIn = false)
|
||||||
val apolloClient = mockk<ApolloClient>()
|
val apolloClient = mockk<ApolloClient>()
|
||||||
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
|
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
|
||||||
val localEmailStore = FakeLocalEmailAuthStore().apply {
|
val localEmailStore = FakeLocalEmailAuthStore().apply {
|
||||||
@@ -144,7 +151,7 @@ class FirebaseAuthServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun signOut_clearsTokenAndLocalProfile() = runTest {
|
fun signOut_clearsTokenAndLocalProfile() = runTest {
|
||||||
val sessionManager = FakeFirebaseSessionManager(isSignedIn = false, signInResult = null)
|
val sessionManager = FakeFirebaseSessionManager(user = null, isSignedIn = false)
|
||||||
val apolloClient = mockk<ApolloClient>()
|
val apolloClient = mockk<ApolloClient>()
|
||||||
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
|
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
|
||||||
val localEmailStore = FakeLocalEmailAuthStore().apply {
|
val localEmailStore = FakeLocalEmailAuthStore().apply {
|
||||||
@@ -157,30 +164,24 @@ class FirebaseAuthServiceTest {
|
|||||||
assertNull(localEmailStore.getProfile())
|
assertNull(localEmailStore.getProfile())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mockAuthGoogleNewError(apolloClient: ApolloClient, error: Throwable) {
|
private fun mockAuthGoogleAppError(apolloClient: ApolloClient, error: Throwable) {
|
||||||
val mockCall = mockk<com.apollographql.apollo.ApolloCall<AuthGoogleNewMutation.Data>>()
|
val mockCall = mockk<com.apollographql.apollo.ApolloCall<AuthGoogleAppMutation.Data>>()
|
||||||
every { apolloClient.mutation(any<AuthGoogleNewMutation>()) } returns mockCall
|
every { apolloClient.mutation(any<AuthGoogleAppMutation>()) } returns mockCall
|
||||||
coEvery { mockCall.execute() } throws error
|
coEvery { mockCall.execute() } throws error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeFirebaseSessionManager(
|
private class FakeFirebaseSessionManager(
|
||||||
private val signInResult: FirebaseSessionUser? = null,
|
private val user: User? = null,
|
||||||
private val signInError: Throwable? = null,
|
|
||||||
private val isSignedIn: Boolean = true
|
private val isSignedIn: Boolean = true
|
||||||
) : FirebaseSessionManager {
|
) : FirebaseSessionManager {
|
||||||
var signOutCallCount = 0
|
var signOutCallCount = 0
|
||||||
private set
|
private set
|
||||||
|
|
||||||
override suspend fun getUser(): User? = signInResult?.user
|
override suspend fun getUser(): User? = user
|
||||||
|
|
||||||
override suspend fun isSignedIn(): Boolean = isSignedIn
|
override suspend fun isSignedIn(): Boolean = isSignedIn
|
||||||
|
|
||||||
override suspend fun signInWithGoogle(idToken: String): FirebaseSessionUser {
|
|
||||||
signInError?.let { throw it }
|
|
||||||
return signInResult ?: error("No signInResult configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun signOut() {
|
override suspend fun signOut() {
|
||||||
signOutCallCount++
|
signOutCallCount++
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user