BIT-133 Implement happy path login (#52)

This commit is contained in:
Andrew Haisting 2023-09-19 09:36:55 -05:00 committed by Álison Fernandes
parent 36942ab296
commit 14d01877fe
16 changed files with 432 additions and 33 deletions

View file

@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.auth.datasource.network.api
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
import retrofit2.http.Body
import retrofit2.http.POST
/**
* Defines calls under the /accounts API.
*/
interface AccountsApi {
/**
* Make pre login request to get KDF params.
*/
@POST("/accounts/prelogin")
suspend fun preLogin(@Body body: PreLoginRequestJson): Result<PreLoginResponseJson>
}

View file

@ -0,0 +1,38 @@
package com.x8bit.bitwarden.data.auth.datasource.network.api
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlEncode
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Url
import java.util.UUID
/**
* Defines calls under the /identity API.
*/
interface IdentityApi {
/**
* Make request to get an access token.
*/
@POST
@Suppress("LongParameterList")
@FormUrlEncoded
suspend fun getToken(
// TODO: use correct base URL here BIT-328
@Url url: String = "https://vault.bitwarden.com/identity/connect/token",
@Field(value = "scope", encoded = true) scope: String = "api+offline_access",
@Field(value = "client_id") clientId: String = "mobile",
@Field(value = "username") email: String,
@Header(value = "auth-email") authEmail: String = email.base64UrlEncode(),
@Field(value = "password") passwordHash: String,
// TODO: use correct device identifier here BIT-325
@Field(value = "deviceIdentifier") deviceIdentifier: String = UUID.randomUUID().toString(),
// TODO: use correct values for deviceName and deviceType BIT-326
@Field(value = "deviceName") deviceName: String = "Pixel 6",
@Field(value = "deviceType") deviceType: String = "1",
@Field(value = "grant_type") grantType: String = "password",
): Result<GetTokenResponseJson>
}

View file

@ -0,0 +1,22 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
/**
* Models high level auth state for the application.
*/
sealed class AuthState {
/**
* Auth state is unknown.
*/
data object Uninitialized : AuthState()
/**
* User is unauthenticated. Said another way, the app has no access token.
*/
data object Unauthenticated : AuthState()
/**
* User is authenticated with the given access token.
*/
data class Authenticated(val accessToken: String) : AuthState()
}

View file

@ -0,0 +1,15 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Models json response of the get token request.
*
* @param accessToken the access token.
*/
@Serializable
data class GetTokenResponseJson(
@SerialName("access_token")
val accessToken: String,
)

View file

@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
/**
* Models result of logging in.
*
* TODO: Add more detail to these cases to expose server error messages (BIT-320)
*/
sealed class LoginResult {
/**
* Login succeeded.
*/
data object Success : LoginResult()
/**
* There was an error logging in.
*/
data object Error : LoginResult()
}

View file

@ -0,0 +1,13 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Request body for pre login.
*/
@Serializable
data class PreLoginRequestJson(
@SerialName("email")
val email: String,
)

View file

@ -0,0 +1,20 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Response body for pre login.
*/
@Serializable
data class PreLoginResponseJson(
// TODO parse this property as an enum (BIT-329)
@SerialName("kdf")
val kdf: Int,
@SerialName("kdfIterations")
val kdfIterations: UInt,
@SerialName("kdfMemory")
val kdfMemory: Int? = null,
@SerialName("kdfParallelism")
val kdfParallelism: Int? = null,
)

View file

@ -0,0 +1,24 @@
package com.x8bit.bitwarden.data.auth.repository
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import kotlinx.coroutines.flow.StateFlow
/**
* Provides an API for observing an modifying authentication state.
*/
interface AuthRepository {
/**
* Models the current auth state.
*/
val authStateFlow: StateFlow<AuthState>
/**
* Attempt to login with the given email and password. Updated access token will be reflected
* in [authStateFlow].
*/
suspend fun login(
email: String,
password: String,
): LoginResult
}

View file

@ -0,0 +1,62 @@
package com.x8bit.bitwarden.data.auth.repository
import com.bitwarden.core.Kdf
import com.bitwarden.sdk.Client
import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi
import com.x8bit.bitwarden.data.auth.datasource.network.api.IdentityApi
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJson
import com.x8bit.bitwarden.data.platform.util.flatMap
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Default implementation of [AuthRepository].
*/
@Singleton
class AuthRepositoryImpl @Inject constructor(
private val accountsApi: AccountsApi,
private val identityApi: IdentityApi,
private val bitwardenSdkClient: Client,
) : AuthRepository {
private val mutableAccessTokenFlow = MutableStateFlow<AuthState>(AuthState.Unauthenticated)
override val authStateFlow: StateFlow<AuthState> = mutableAccessTokenFlow.asStateFlow()
/**
* Attempt to login with the given email.
*/
override suspend fun login(
email: String,
password: String,
): LoginResult = accountsApi
.preLogin(PreLoginRequestJson(email))
.flatMap {
// TODO: Use KDF enum from pre login correctly (BIT-329)
val passwordHash = bitwardenSdkClient
.auth()
.hashPassword(
email = email,
password = password,
kdfParams = Kdf.Pbkdf2(it.kdfIterations),
)
identityApi.getToken(
email = email,
passwordHash = passwordHash,
)
}
.fold(
onFailure = {
// TODO: Add more detail to these cases to expose server error messages (BIT-320)
LoginResult.Error
},
onSuccess = {
mutableAccessTokenFlow.value = AuthState.Authenticated(it.accessToken)
LoginResult.Success
},
)
}

View file

@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.auth.repository.di
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
/**
* Provides repositories in the auth package.
*/
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
abstract fun bindsAuthRepository(authRepositoryImpl: AuthRepositoryImpl): AuthRepository
}

View file

@ -1,6 +1,8 @@
package com.x8bit.bitwarden.data.platform.datasource.network.di package com.x8bit.bitwarden.data.platform.datasource.network.di
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi
import com.x8bit.bitwarden.data.auth.datasource.network.api.IdentityApi
import com.x8bit.bitwarden.data.platform.datasource.network.api.ConfigApi import com.x8bit.bitwarden.data.platform.datasource.network.api.ConfigApi
import com.x8bit.bitwarden.data.platform.datasource.network.core.ResultCallAdapterFactory import com.x8bit.bitwarden.data.platform.datasource.network.core.ResultCallAdapterFactory
import dagger.Module import dagger.Module
@ -12,6 +14,7 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Singleton import javax.inject.Singleton
/** /**
@ -24,9 +27,15 @@ object NetworkModule {
@Provides @Provides
@Singleton @Singleton
fun provideConfigApiService(retrofit: Retrofit): ConfigApi { fun providesAccountsApiService(retrofit: Retrofit): AccountsApi = retrofit.create()
return retrofit.create(ConfigApi::class.java)
} @Provides
@Singleton
fun providesConfigApiService(retrofit: Retrofit): ConfigApi = retrofit.create()
@Provides
@Singleton
fun providesIdentityApiService(retrofit: Retrofit): IdentityApi = retrofit.create()
@Provides @Provides
@Singleton @Singleton
@ -42,14 +51,27 @@ object NetworkModule {
@Provides @Provides
@Singleton @Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit { fun provideRetrofit(
okHttpClient: OkHttpClient,
json: Json,
): Retrofit {
val contentType = "application/json".toMediaType() val contentType = "application/json".toMediaType()
return Retrofit.Builder() return Retrofit.Builder()
.baseUrl("https://api.bitwarden.com") .baseUrl("https://api.bitwarden.com")
.client(okHttpClient) .client(okHttpClient)
.addConverterFactory(Json.asConverterFactory(contentType)) .addConverterFactory(json.asConverterFactory(contentType))
.addCallAdapterFactory(ResultCallAdapterFactory()) .addCallAdapterFactory(ResultCallAdapterFactory())
.build() .build()
} }
@Provides
@Singleton
fun providesJson(): Json = Json {
// If there are keys returned by the server not modeled by a serializable class,
// ignore them.
// This makes additive server changes non-breaking.
ignoreUnknownKeys = true
}
} }

View file

@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.platform.datasource.network.util
import java.util.Base64
/**
* Base 64 encode the string as well as make special modifications required by the backend:
*
* - replace all "+" with "-"
* - replace all "/" with "_"
* - replace all "=" with ""
*/
fun String.base64UrlEncode(): String =
Base64.getEncoder()
.encodeToString(toByteArray())
.replace("+", "-")
.replace("/", "_")
.replace("=", "")

View file

@ -3,11 +3,14 @@ package com.x8bit.bitwarden.ui.auth.feature.login
import android.os.Parcelable import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import javax.inject.Inject import javax.inject.Inject
@ -18,12 +21,13 @@ private const val KEY_STATE = "state"
*/ */
@HiltViewModel @HiltViewModel
class LoginViewModel @Inject constructor( class LoginViewModel @Inject constructor(
private val authRepository: AuthRepository,
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
) : BaseViewModel<LoginState, LoginEvent, LoginAction>( ) : BaseViewModel<LoginState, LoginEvent, LoginAction>(
initialState = savedStateHandle[KEY_STATE] initialState = savedStateHandle[KEY_STATE]
?: LoginState( ?: LoginState(
emailAddress = LoginArgs(savedStateHandle).emailAddress, emailAddress = LoginArgs(savedStateHandle).emailAddress,
isLoginButtonEnabled = false, isLoginButtonEnabled = true,
passwordInput = "", passwordInput = "",
), ),
) { ) {
@ -45,7 +49,19 @@ class LoginViewModel @Inject constructor(
} }
private fun handleLoginButtonClicked() { private fun handleLoginButtonClicked() {
// TODO BIT-133 make login request and allow user to login viewModelScope.launch {
// TODO: show progress here BIT-320
val result = authRepository.login(
email = mutableStateFlow.value.emailAddress,
password = mutableStateFlow.value.passwordInput,
)
when (result) {
// TODO: show an error here BIT-320
LoginResult.Error -> Unit
// No action required on success, root nav will navigate to logged in state
LoginResult.Success -> Unit
}
}
} }
private fun handleNotYouButtonClicked() { private fun handleNotYouButtonClicked() {

View file

@ -3,13 +3,13 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav
import android.os.Parcelable import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import javax.inject.Inject import javax.inject.Inject
@ -20,8 +20,9 @@ private const val KEY_NAV_DESTINATION = "nav_state"
*/ */
@HiltViewModel @HiltViewModel
class RootNavViewModel @Inject constructor( class RootNavViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val savedStateHandle: SavedStateHandle, private val savedStateHandle: SavedStateHandle,
) : BaseViewModel<RootNavState, Unit, Unit>( ) : BaseViewModel<RootNavState, Unit, RootNavAction>(
initialState = RootNavState.Splash, initialState = RootNavState.Splash,
) { ) {
@ -39,14 +40,25 @@ class RootNavViewModel @Inject constructor(
stateFlow stateFlow
.onEach { savedRootNavState = it } .onEach { savedRootNavState = it }
.launchIn(viewModelScope) .launchIn(viewModelScope)
viewModelScope.launch { authRepository
@Suppress("MagicNumber") .authStateFlow
delay(1000) .onEach { trySendAction(RootNavAction.AuthStateUpdated(it)) }
mutableStateFlow.update { RootNavState.Auth } .launchIn(viewModelScope)
}
override fun handleAction(action: RootNavAction) {
when (action) {
is RootNavAction.AuthStateUpdated -> handleAuthStateUpdated(action)
} }
} }
override fun handleAction(action: Unit) = Unit private fun handleAuthStateUpdated(action: RootNavAction.AuthStateUpdated) {
when (action.newState) {
is AuthState.Authenticated -> mutableStateFlow.update { RootNavState.VaultUnlocked }
is AuthState.Unauthenticated -> mutableStateFlow.update { RootNavState.Auth }
is AuthState.Uninitialized -> mutableStateFlow.update { RootNavState.Splash }
}
}
} }
/** /**
@ -71,3 +83,14 @@ sealed class RootNavState : Parcelable {
@Parcelize @Parcelize
data object VaultUnlocked : RootNavState() data object VaultUnlocked : RootNavState()
} }
/**
* Models root level navigation actions.
*/
sealed class RootNavAction {
/**
* Auth state in the repository layer changed.
*/
data class AuthStateUpdated(val newState: AuthState) : RootNavAction()
}

View file

@ -2,7 +2,12 @@ package com.x8bit.bitwarden.ui.auth.feature.login
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test import app.cash.turbine.test
import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@ -15,7 +20,10 @@ class LoginViewModelTest : BaseViewModelTest() {
@Test @Test
fun `initial state should be correct`() = runTest { fun `initial state should be correct`() = runTest {
val viewModel = LoginViewModel(savedStateHandle) val viewModel = LoginViewModel(
authRepository = mockk(),
savedStateHandle = savedStateHandle,
)
viewModel.stateFlow.test { viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem()) assertEquals(DEFAULT_STATE, awaitItem())
} }
@ -33,26 +41,56 @@ class LoginViewModelTest : BaseViewModelTest() {
"state" to expectedState, "state" to expectedState,
), ),
) )
val viewModel = LoginViewModel(handle) val viewModel = LoginViewModel(
authRepository = mockk(),
savedStateHandle = handle,
)
viewModel.stateFlow.test { viewModel.stateFlow.test {
assertEquals(expectedState, awaitItem()) assertEquals(expectedState, awaitItem())
} }
} }
@Test @Test
fun `LoginButtonClick should do nothing`() = runTest { fun `LoginButtonClick login returns error should do nothing`() = runTest {
// TODO: handle and display errors (BIT-320)
val authRepository = mockk<AuthRepository> {
coEvery { login(email = "test@gmail.com", password = "") } returns LoginResult.Error
}
val viewModel = LoginViewModel( val viewModel = LoginViewModel(
authRepository = authRepository,
savedStateHandle = savedStateHandle, savedStateHandle = savedStateHandle,
) )
viewModel.eventFlow.test { viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LoginAction.LoginButtonClick) viewModel.actionChannel.trySend(LoginAction.LoginButtonClick)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
} }
coVerify {
authRepository.login(email = "test@gmail.com", password = "")
}
}
@Test
fun `LoginButtonClick login returns success should do nothing`() = runTest {
val authRepository = mockk<AuthRepository> {
coEvery { login("test@gmail.com", "") } returns LoginResult.Success
}
val viewModel = LoginViewModel(
authRepository = authRepository,
savedStateHandle = savedStateHandle,
)
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LoginAction.LoginButtonClick)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
coVerify {
authRepository.login(email = "test@gmail.com", password = "")
}
} }
@Test @Test
fun `SingleSignOnClick should do nothing`() = runTest { fun `SingleSignOnClick should do nothing`() = runTest {
val viewModel = LoginViewModel( val viewModel = LoginViewModel(
authRepository = mockk(),
savedStateHandle = savedStateHandle, savedStateHandle = savedStateHandle,
) )
viewModel.eventFlow.test { viewModel.eventFlow.test {
@ -64,6 +102,7 @@ class LoginViewModelTest : BaseViewModelTest() {
@Test @Test
fun `NotYouButtonClick should emit NavigateToLanding`() = runTest { fun `NotYouButtonClick should emit NavigateToLanding`() = runTest {
val viewModel = LoginViewModel( val viewModel = LoginViewModel(
authRepository = mockk(),
savedStateHandle = savedStateHandle, savedStateHandle = savedStateHandle,
) )
viewModel.eventFlow.test { viewModel.eventFlow.test {
@ -79,6 +118,7 @@ class LoginViewModelTest : BaseViewModelTest() {
fun `PasswordInputChanged should update password input`() = runTest { fun `PasswordInputChanged should update password input`() = runTest {
val input = "input" val input = "input"
val viewModel = LoginViewModel( val viewModel = LoginViewModel(
authRepository = mockk(),
savedStateHandle = savedStateHandle, savedStateHandle = savedStateHandle,
) )
viewModel.eventFlow.test { viewModel.eventFlow.test {
@ -94,7 +134,7 @@ class LoginViewModelTest : BaseViewModelTest() {
private val DEFAULT_STATE = LoginState( private val DEFAULT_STATE = LoginState(
emailAddress = "test@gmail.com", emailAddress = "test@gmail.com",
passwordInput = "", passwordInput = "",
isLoginButtonEnabled = false, isLoginButtonEnabled = true,
) )
} }
} }

View file

@ -1,33 +1,64 @@
package com.x8bit.bitwarden.ui.platform.feature.rootnav package com.x8bit.bitwarden.ui.platform.feature.rootnav
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState.Authenticated
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthState.Unauthenticated
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class RootNavViewModelTest : BaseViewModelTest() { class RootNavViewModelTest : BaseViewModelTest() {
@Test
fun `initial state should be splash`() {
val viewModel = RootNavViewModel(SavedStateHandle())
assertEquals(viewModel.stateFlow.value, RootNavState.Splash)
}
@Test @Test
fun `initial state should be the state in savedStateHandle`() { fun `initial state should be the state in savedStateHandle`() {
val authRepository = mockk<AuthRepository> {
every { this@mockk.authStateFlow } returns MutableStateFlow(mockk<Authenticated>())
}
val handle = SavedStateHandle(mapOf(("nav_state" to RootNavState.VaultUnlocked))) val handle = SavedStateHandle(mapOf(("nav_state" to RootNavState.VaultUnlocked)))
val viewModel = RootNavViewModel(handle) val viewModel = RootNavViewModel(
assertEquals(viewModel.stateFlow.value, RootNavState.VaultUnlocked) authRepository = authRepository,
savedStateHandle = handle,
)
assertEquals(RootNavState.VaultUnlocked, viewModel.stateFlow.value)
} }
@Test @Test
fun `state should move from splash to auth`() = runTest { fun `when auth state is Uninitialized nav state should be Splash`() {
val viewModel = RootNavViewModel(SavedStateHandle()) val viewModel = RootNavViewModel(
viewModel.stateFlow.test { authRepository = mockk {
assertEquals(awaitItem(), RootNavState.Splash) every { this@mockk.authStateFlow } returns MutableStateFlow(AuthState.Uninitialized)
assertEquals(awaitItem(), RootNavState.Auth) },
savedStateHandle = SavedStateHandle(),
)
assertEquals(RootNavState.Splash, viewModel.stateFlow.value)
}
@Test
fun `when auth state is Authenticated nav state should be VaultUnlocked`() {
val authRepository = mockk<AuthRepository> {
every { this@mockk.authStateFlow } returns MutableStateFlow(mockk<Authenticated>())
} }
val viewModel = RootNavViewModel(
authRepository = authRepository,
savedStateHandle = SavedStateHandle(),
)
assertEquals(RootNavState.VaultUnlocked, viewModel.stateFlow.value)
}
@Test
fun `when auth state is Unauthenticated nav state should be Auth`() = runTest {
val viewModel = RootNavViewModel(
authRepository = mockk {
every { this@mockk.authStateFlow } returns MutableStateFlow(Unauthenticated)
},
savedStateHandle = SavedStateHandle(),
)
assertEquals(RootNavState.Auth, viewModel.stateFlow.value)
} }
} }