diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AccountsApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AccountsApi.kt index 53ae0fc302..f96a9fab02 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AccountsApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/AccountsApi.kt @@ -6,7 +6,7 @@ import retrofit2.http.Body import retrofit2.http.POST /** - * Defines calls under the /accounts API. + * Defines raw calls under the /accounts API. */ interface AccountsApi { diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/IdentityApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/IdentityApi.kt index 31684a7549..4d809b6766 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/IdentityApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/api/IdentityApi.kt @@ -1,16 +1,14 @@ 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. + * Defines raw calls under the /identity API. */ interface IdentityApi { @@ -18,18 +16,15 @@ interface IdentityApi { @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", + @Url url: String, + @Field(value = "scope", encoded = true) scope: String, + @Field(value = "client_id") clientId: String, @Field(value = "username") email: String, - @Header(value = "auth-email") authEmail: String = email.base64UrlEncode(), + @Header(value = "auth-email") authEmail: String, @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 + @Field(value = "deviceIdentifier") deviceIdentifier: String, + @Field(value = "deviceName") deviceName: String, + @Field(value = "deviceType") deviceType: String, + @Field(value = "grant_type") grantType: String, + ): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/NetworkModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/NetworkModule.kt new file mode 100644 index 0000000000..5cde714980 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/di/NetworkModule.kt @@ -0,0 +1,37 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.di + +import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService +import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsServiceImpl +import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService +import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityServiceImpl +import com.x8bit.bitwarden.data.platform.datasource.network.di.NetworkModule +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import retrofit2.Retrofit +import retrofit2.create +import javax.inject.Named +import javax.inject.Singleton + +/** + * Provides network dependencies in the auth package. + */ +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + fun providesAccountService( + @Named(NetworkModule.UNAUTHORIZED) retrofit: Retrofit, + ): AccountsService = AccountsServiceImpl(retrofit.create()) + + @Provides + @Singleton + fun providesIdentityService( + @Named(NetworkModule.UNAUTHORIZED) retrofit: Retrofit, + json: Json, + ): IdentityService = IdentityServiceImpl(retrofit.create(), json) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/GetTokenResponseJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/GetTokenResponseJson.kt index 3a5cfee810..7adb741a9e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/GetTokenResponseJson.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/GetTokenResponseJson.kt @@ -4,12 +4,26 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** - * Models json response of the get token request. - * - * @param accessToken the access token. + * Models response bodies from the get token request. */ -@Serializable -data class GetTokenResponseJson( - @SerialName("access_token") - val accessToken: String, -) +sealed class GetTokenResponseJson { + /** + * Models json response of the get token request. + * + * @param accessToken the access token. + */ + @Serializable + data class Success( + @SerialName("access_token") + val accessToken: String, + ) : GetTokenResponseJson() + + /** + * Models json body of a captcha error. + */ + @Serializable + data class CaptchaRequired( + @SerialName("HCaptcha_SiteKey") + val captchaKey: String, + ) : GetTokenResponseJson() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/LoginResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/LoginResult.kt index 78ff14bf1f..174b63673d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/LoginResult.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/model/LoginResult.kt @@ -12,6 +12,11 @@ sealed class LoginResult { */ data object Success : LoginResult() + /** + * Captcha verification is required. + */ + data class CaptchaRequired(val captchaId: String) : LoginResult() + /** * There was an error logging in. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt new file mode 100644 index 0000000000..6d66510e77 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsService.kt @@ -0,0 +1,14 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.service + +import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson + +/** + * Wraps raw retrofit accounts API in a cleaner interface. + */ +interface AccountsService { + + /** + * Make pre login request to get KDF params. + */ + suspend fun preLogin(email: String): Result +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt new file mode 100644 index 0000000000..8883164aee --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceImpl.kt @@ -0,0 +1,13 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.service + +import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi +import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson + +class AccountsServiceImpl constructor( + private val accountsApi: AccountsApi, +) : AccountsService { + + override suspend fun preLogin(email: String): Result = + accountsApi.preLogin(PreLoginRequestJson(email = email)) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt new file mode 100644 index 0000000000..1bdefb3abb --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityService.kt @@ -0,0 +1,20 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.service + +import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson + +/** + * Wraps raw retrofit identity API in a cleaner interface. + */ +interface IdentityService { + + /** + * Make request to get an access token. + * + * @param email user's email address. + * @param passwordHash password hashed with the Bitwarden SDK. + */ + suspend fun getToken( + email: String, + passwordHash: String, + ): Result +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt new file mode 100644 index 0000000000..06eb8b728d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceImpl.kt @@ -0,0 +1,46 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.service + +import com.x8bit.bitwarden.data.auth.datasource.network.api.IdentityApi +import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson +import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlEncode +import com.x8bit.bitwarden.data.platform.datasource.network.util.parseErrorBodyAsResult +import kotlinx.serialization.json.Json +import java.net.HttpURLConnection.HTTP_BAD_REQUEST +import java.util.UUID + +class IdentityServiceImpl constructor( + private val api: IdentityApi, + private val json: Json, + // TODO: use correct base URL here BIT-328 + private val baseUrl: String = "https://vault.bitwarden.com", +) : IdentityService { + + override suspend fun getToken( + email: String, + passwordHash: String, + ): Result = api + .getToken( + // TODO: use correct base URL here BIT-328 + url = "$baseUrl/identity/connect/token", + scope = "api+offline_access", + clientId = "mobile", + authEmail = email.base64UrlEncode(), + // TODO: use correct device identifier here BIT-325 + deviceIdentifier = UUID.randomUUID().toString(), + // TODO: use correct values for deviceName and deviceType BIT-326 + deviceName = "Pixel 6", + deviceType = "1", + grantType = "password", + passwordHash = passwordHash, + email = email, + ) + .fold( + onSuccess = { Result.success(it) }, + onFailure = { + it.parseErrorBodyAsResult( + code = HTTP_BAD_REQUEST, + json = json, + ) + }, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index a41f1c273a..313c6689f5 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -2,11 +2,12 @@ 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.GetTokenResponseJson.CaptchaRequired +import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson.Success 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.auth.datasource.network.service.AccountsService +import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor import com.x8bit.bitwarden.data.platform.util.flatMap import kotlinx.coroutines.flow.MutableStateFlow @@ -20,8 +21,8 @@ import javax.inject.Singleton */ @Singleton class AuthRepositoryImpl @Inject constructor( - private val accountsApi: AccountsApi, - private val identityApi: IdentityApi, + private val accountsService: AccountsService, + private val identityService: IdentityService, private val bitwardenSdkClient: Client, private val authTokenInterceptor: AuthTokenInterceptor, ) : AuthRepository { @@ -35,8 +36,8 @@ class AuthRepositoryImpl @Inject constructor( override suspend fun login( email: String, password: String, - ): LoginResult = accountsApi - .preLogin(PreLoginRequestJson(email)) + ): LoginResult = accountsService + .preLogin(email = email) .flatMap { // TODO: Use KDF enum from pre login correctly (BIT-329) val passwordHash = bitwardenSdkClient @@ -46,21 +47,27 @@ class AuthRepositoryImpl @Inject constructor( password = password, kdfParams = Kdf.Pbkdf2(it.kdfIterations), ) - identityApi.getToken( + identityService.getToken( email = email, passwordHash = passwordHash, ) } .fold( onFailure = { - // TODO: Add more detail to these cases to expose server error messages (BIT-320) + // TODO: Add more detail to error case to expose server error messages (BIT-320) LoginResult.Error }, onSuccess = { - // TODO: Create intermediate class for providing auth token to interceptor (BIT-411) - authTokenInterceptor.authToken = it.accessToken - mutableAuthStateFlow.value = AuthState.Authenticated(it.accessToken) - LoginResult.Success + when (it) { + is CaptchaRequired -> LoginResult.CaptchaRequired(it.captchaKey) + is Success -> { + // TODO: Create intermediate class for providing auth token + // to interceptor (BIT-411) + authTokenInterceptor.authToken = it.accessToken + mutableAuthStateFlow.value = AuthState.Authenticated(it.accessToken) + LoginResult.Success + } + } }, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/di/NetworkModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/di/NetworkModule.kt index 736ddc84f7..efcf29c49c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/di/NetworkModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/di/NetworkModule.kt @@ -1,8 +1,6 @@ package com.x8bit.bitwarden.data.platform.datasource.network.di 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.core.ResultCallAdapterFactory import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor @@ -27,23 +25,23 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object NetworkModule { - private const val AUTHORIZED = "authorized" - private const val UNAUTHORIZED = "unauthorized" - - @Provides - @Singleton - fun providesAccountsApiService(@Named(UNAUTHORIZED) retrofit: Retrofit): AccountsApi = - retrofit.create() + const val AUTHORIZED: String = "authorized" + const val UNAUTHORIZED: String = "unauthorized" @Provides @Singleton fun providesConfigApiService(@Named(UNAUTHORIZED) retrofit: Retrofit): ConfigApi = retrofit.create() - @Provides - @Singleton - fun providesIdentityApiService(@Named(UNAUTHORIZED) retrofit: Retrofit): IdentityApi = - retrofit.create() + fun provideOkHttpClient(): OkHttpClient { + return OkHttpClient.Builder() + .addInterceptor( + HttpLoggingInterceptor().apply { + setLevel(HttpLoggingInterceptor.Level.BODY) + }, + ) + .build() + } @Provides @Singleton diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/util/ExceptionExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/util/ExceptionExtensions.kt new file mode 100644 index 0000000000..34124e4372 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/util/ExceptionExtensions.kt @@ -0,0 +1,32 @@ +package com.x8bit.bitwarden.data.platform.datasource.network.util + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import retrofit2.HttpException + +/** + * Attempt to parse the error body to serializable type [T]. + * + * Useful in service layer for parsing non-200 response bodies. + * + * If the receiver is not an [HttpException] or the error body cannot be parsed, the original + * Throwable will be returned as a Result.failure. + * + * @param code HTTP code associated with the error. Only responses with this code will be attempted + * to be parsed. + * @param json [Json] serializer to use. + */ +@OptIn(ExperimentalSerializationApi::class) +inline fun Throwable.parseErrorBodyAsResult(code: Int, json: Json): Result = + (this as? HttpException) + ?.response() + ?.takeIf { it.code() == code } + ?.errorBody() + ?.let { errorBody -> + try { + Result.success(json.decodeFromStream(errorBody.byteStream())) + } catch (_: Exception) { + Result.failure(this) + } + } ?: Result.failure(this) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt index 62fbf0f418..d33c59b210 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt @@ -60,6 +60,8 @@ class LoginViewModel @Inject constructor( LoginResult.Error -> Unit // No action required on success, root nav will navigate to logged in state LoginResult.Success -> Unit + // TODO: launch intent with captcha URL BIT-399 + is LoginResult.CaptchaRequired -> Unit } } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt new file mode 100644 index 0000000000..5bd1204eb7 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/AccountsServiceTest.kt @@ -0,0 +1,43 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.service + +import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi +import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson +import com.x8bit.bitwarden.data.platform.base.BaseServiceTest +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import retrofit2.create + +class AccountsServiceTest : BaseServiceTest() { + + private val accountsApi: AccountsApi = retrofit.create() + private val service = AccountsServiceImpl(accountsApi) + + @Test + fun `preLogin should call API`() = runTest { + val response = MockResponse().setBody(PRE_LOGIN_RESPONSE_JSON) + server.enqueue(response) + assertEquals(Result.success(PRE_LOGIN_RESPONSE), service.preLogin(EMAIL)) + } + + companion object { + private const val EMAIL = "email" + } +} + +private const val PRE_LOGIN_RESPONSE_JSON = """ +{ + "kdf": 1, + "kdfIterations": 1, + "kdfMemory": 1, + "kdfParallelism": 1 +} +""" + +private val PRE_LOGIN_RESPONSE = PreLoginResponseJson( + kdf = 1, + kdfIterations = 1u, + kdfMemory = 1, + kdfParallelism = 1, +) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt new file mode 100644 index 0000000000..2973e37de6 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/network/service/IdentityServiceTest.kt @@ -0,0 +1,63 @@ +package com.x8bit.bitwarden.data.auth.datasource.network.service + +import com.x8bit.bitwarden.data.auth.datasource.network.api.IdentityApi +import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson +import com.x8bit.bitwarden.data.platform.base.BaseServiceTest +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.mockwebserver.MockResponse +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import retrofit2.create + +class IdentityServiceTest : BaseServiceTest() { + + private val identityApi: IdentityApi = retrofit.create() + + private val identityService = IdentityServiceImpl( + api = identityApi, + json = Json, + baseUrl = server.url("/").toString(), + ) + + @Test + fun `getToken when request response is Success should return Success`() = runTest { + server.enqueue(MockResponse().setBody(LOGIN_SUCCESS_JSON)) + val result = identityService.getToken(email = EMAIL, passwordHash = PASSWORD_HASH) + assertEquals(Result.success(LOGIN_SUCCESS), result) + } + + @Test + fun `getToken when request is error should return error`() = runTest { + server.enqueue(MockResponse().setResponseCode(500)) + val result = identityService.getToken(email = EMAIL, passwordHash = PASSWORD_HASH) + assertTrue(result.isFailure) + } + + @Test + fun `getToken when response is CaptchaRequired should return CaptchaRequired`() = runTest { + server.enqueue(MockResponse().setResponseCode(400).setBody(CAPTCHA_BODY_JSON)) + val result = identityService.getToken(email = EMAIL, passwordHash = PASSWORD_HASH) + assertEquals(Result.success(CAPTCHA_BODY), result) + } + + companion object { + private const val EMAIL = "email" + private const val PASSWORD_HASH = "passwordHash" + } +} + +private const val CAPTCHA_BODY_JSON = """ +{ + "HCaptcha_SiteKey": "123" +} +""" +private val CAPTCHA_BODY = GetTokenResponseJson.CaptchaRequired("123") + +private const val LOGIN_SUCCESS_JSON = """ +{ + "access_token": "123" +} +""" +private val LOGIN_SUCCESS = GetTokenResponseJson.Success("123") diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt new file mode 100644 index 0000000000..98748fa4fc --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -0,0 +1,110 @@ +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.model.AuthState +import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson +import com.x8bit.bitwarden.data.auth.datasource.network.model.LoginResult +import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson +import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService +import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService +import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class AuthRepositoryTest { + + private val accountsService: AccountsService = mockk() + private val identityService: IdentityService = mockk() + private val authInterceptor = mockk() + private val mockBitwardenSdk = mockk { + coEvery { + auth().hashPassword( + email = EMAIL, + password = PASSWORD, + kdfParams = Kdf.Pbkdf2(iterations = PRE_LOGIN_SUCCESS.kdfIterations), + ) + } returns PASSWORD_HASH + } + + private val repository = AuthRepositoryImpl( + accountsService = accountsService, + identityService = identityService, + bitwardenSdkClient = mockBitwardenSdk, + authTokenInterceptor = authInterceptor, + ) + + @BeforeEach + fun beforeEach() { + clearMocks(identityService, accountsService, authInterceptor) + } + + @Test + fun `login when pre login fails should return Error`() = runTest { + coEvery { accountsService.preLogin(EMAIL) } returns (Result.failure(RuntimeException())) + val result = repository.login(EMAIL, PASSWORD) + assertEquals(LoginResult.Error, result) + assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value) + coVerify { accountsService.preLogin(EMAIL) } + } + + @Test + fun `login get token fails should return Error`() = runTest { + coEvery { accountsService.preLogin(EMAIL) } returns Result.success(PRE_LOGIN_SUCCESS) + coEvery { identityService.getToken(EMAIL, PASSWORD_HASH) } + .returns(Result.failure(RuntimeException())) + val result = repository.login(EMAIL, PASSWORD) + assertEquals(LoginResult.Error, result) + assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value) + coVerify { accountsService.preLogin(EMAIL) } + coVerify { identityService.getToken(EMAIL, PASSWORD_HASH) } + } + + @Test + fun `login get token succeeds should return Success and update AuthState`() = runTest { + coEvery { accountsService.preLogin(EMAIL) } returns Result.success(PRE_LOGIN_SUCCESS) + coEvery { identityService.getToken(email = EMAIL, passwordHash = PASSWORD_HASH) } + .returns(Result.success(GetTokenResponseJson.Success(accessToken = ACCESS_TOKEN))) + every { authInterceptor.authToken = ACCESS_TOKEN } returns Unit + val result = repository.login(EMAIL, PASSWORD) + assertEquals(LoginResult.Success, result) + assertEquals(AuthState.Authenticated(ACCESS_TOKEN), repository.authStateFlow.value) + verify { authInterceptor.authToken = ACCESS_TOKEN } + coVerify { accountsService.preLogin(EMAIL) } + coVerify { identityService.getToken(EMAIL, PASSWORD_HASH) } + } + + @Test + fun `login get token returns captcha request should return CaptchaRequired`() = runTest { + coEvery { accountsService.preLogin(EMAIL) } returns Result.success(PRE_LOGIN_SUCCESS) + coEvery { identityService.getToken(email = EMAIL, passwordHash = PASSWORD_HASH) } + .returns(Result.success(GetTokenResponseJson.CaptchaRequired(CAPTCHA_KEY))) + val result = repository.login(EMAIL, PASSWORD) + assertEquals(LoginResult.CaptchaRequired(CAPTCHA_KEY), result) + assertEquals(AuthState.Unauthenticated, repository.authStateFlow.value) + coVerify { accountsService.preLogin(EMAIL) } + coVerify { identityService.getToken(EMAIL, PASSWORD_HASH) } + } + + companion object { + private const val EMAIL = "test@test.com" + private const val PASSWORD = "password" + private const val PASSWORD_HASH = "passwordHash" + private const val ACCESS_TOKEN = "accessToken" + private const val CAPTCHA_KEY = "captcha" + private val PRE_LOGIN_SUCCESS = PreLoginResponseJson( + kdf = 1, + kdfIterations = 1u, + kdfMemory = null, + kdfParallelism = null, + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/base/BaseServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/base/BaseServiceTest.kt new file mode 100644 index 0000000000..2d6e91bdbf --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/base/BaseServiceTest.kt @@ -0,0 +1,28 @@ +package com.x8bit.bitwarden.data.platform.base + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import com.x8bit.bitwarden.data.platform.datasource.network.core.ResultCallAdapterFactory +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import retrofit2.Retrofit + +/** + * Base class for service tests. Provides common mock web server and retrofit setup. + */ +abstract class BaseServiceTest { + + protected val server = MockWebServer().apply { start() } + + protected val retrofit: Retrofit = Retrofit.Builder() + .baseUrl(server.url("/").toString()) + .addCallAdapterFactory(ResultCallAdapterFactory()) + .addConverterFactory(Json.asConverterFactory("application/json".toMediaType())) + .build() + + @After + fun after() { + server.shutdown() + } +}