BIT-725, BIT-328: Add base URL interceptors and dynamically change environments (#160)

This commit is contained in:
Brian Yencho 2023-10-25 14:24:02 -05:00 committed by Álison Fernandes
parent 8bdda9bffd
commit dc6d37dc32
18 changed files with 676 additions and 78 deletions

View file

@ -5,18 +5,16 @@ import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Url
/**
* Defines raw calls under the /identity API.
*/
interface IdentityApi {
@POST
@POST("/connect/token")
@Suppress("LongParameterList")
@FormUrlEncoded
suspend fun getToken(
@Url url: String,
@Field(value = "scope", encoded = true) scope: String,
@Field(value = "client_id") clientId: String,
@Field(value = "username") email: String,

View file

@ -6,15 +6,13 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedSe
import com.x8bit.bitwarden.data.auth.datasource.network.service.HaveIBeenPwnedServiceImpl
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 com.x8bit.bitwarden.data.platform.datasource.network.retrofit.Retrofits
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
/**
@ -27,23 +25,30 @@ object NetworkModule {
@Provides
@Singleton
fun providesAccountService(
@Named(NetworkModule.UNAUTHORIZED) retrofit: Retrofit,
retrofits: Retrofits,
json: Json,
): AccountsService = AccountsServiceImpl(retrofit.create(), json)
): AccountsService = AccountsServiceImpl(
accountsApi = retrofits.unauthenticatedApiRetrofit.create(),
json = json,
)
@Provides
@Singleton
fun providesIdentityService(
@Named(NetworkModule.UNAUTHORIZED) retrofit: Retrofit,
retrofits: Retrofits,
json: Json,
): IdentityService = IdentityServiceImpl(retrofit.create(), json)
): IdentityService = IdentityServiceImpl(
api = retrofits.unauthenticatedIdentityRetrofit.create(),
json = json,
)
@Provides
@Singleton
fun providesHaveIBeenPwnedService(
@Named(NetworkModule.UNAUTHORIZED) retrofit: Retrofit,
retrofits: Retrofits,
): HaveIBeenPwnedService = HaveIBeenPwnedServiceImpl(
retrofit.newBuilder()
retrofits
.staticRetrofitBuilder
.baseUrl("https://api.pwnedpasswords.com")
.build()
.create(),

View file

@ -13,8 +13,6 @@ 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",
private val deviceModelProvider: DeviceModelProvider = DeviceModelProvider(),
) : IdentityService {
@ -24,8 +22,6 @@ class IdentityServiceImpl constructor(
captchaToken: String?,
): Result<GetTokenResponseJson> = api
.getToken(
// TODO: use correct base URL here BIT-328
url = "$baseUrl/identity/connect/token",
scope = "api+offline_access",
clientId = "mobile",
authEmail = email.base64UrlEncode(),

View file

@ -1,8 +1,9 @@
package com.x8bit.bitwarden.data.platform.datasource.network.di
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.x8bit.bitwarden.data.platform.datasource.network.core.ResultCallAdapterFactory
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors
import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.Retrofits
import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.RetrofitsImpl
import com.x8bit.bitwarden.data.platform.datasource.network.serializer.LocalDateTimeSerializer
import com.x8bit.bitwarden.data.platform.datasource.network.service.ConfigService
import com.x8bit.bitwarden.data.platform.datasource.network.service.ConfigServiceImpl
@ -13,13 +14,8 @@ import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.create
import java.time.LocalDateTime
import javax.inject.Named
import javax.inject.Singleton
/**
@ -30,13 +26,11 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object NetworkModule {
const val AUTHORIZED: String = "authorized"
const val UNAUTHORIZED: String = "unauthorized"
@Provides
@Singleton
fun providesConfigService(@Named(UNAUTHORIZED) retrofit: Retrofit): ConfigService =
ConfigServiceImpl(retrofit.create())
fun providesConfigService(
retrofits: Retrofits,
): ConfigService = ConfigServiceImpl(retrofits.unauthenticatedApiRetrofit.create())
@Provides
@Singleton
@ -44,50 +38,16 @@ object NetworkModule {
@Provides
@Singleton
fun providesOkHttpClientBuilder(): OkHttpClient.Builder =
OkHttpClient.Builder().addInterceptor(
HttpLoggingInterceptor().apply {
setLevel(HttpLoggingInterceptor.Level.BODY)
},
)
@Provides
@Singleton
fun providesRetrofitBuilder(
json: Json,
): Retrofit.Builder {
val contentType = "application/json".toMediaType()
return Retrofit.Builder().baseUrl("https://api.bitwarden.com")
.addConverterFactory(json.asConverterFactory(contentType))
.addCallAdapterFactory(ResultCallAdapterFactory())
}
@Provides
@Singleton
@Named(UNAUTHORIZED)
fun providesUnauthorizedRetrofit(
okHttpClientBuilder: OkHttpClient.Builder,
retrofitBuilder: Retrofit.Builder,
): Retrofit =
retrofitBuilder
.client(
okHttpClientBuilder.build(),
)
.build()
@Provides
@Singleton
@Named(AUTHORIZED)
fun providesAuthorizedRetrofit(
okHttpClientBuilder: OkHttpClient.Builder,
retrofitBuilder: Retrofit.Builder,
fun provideRetrofits(
authTokenInterceptor: AuthTokenInterceptor,
): Retrofit =
retrofitBuilder
.client(
okHttpClientBuilder.addInterceptor(authTokenInterceptor).build(),
baseUrlInterceptors: BaseUrlInterceptors,
json: Json,
): Retrofits =
RetrofitsImpl(
authTokenInterceptor = authTokenInterceptor,
baseUrlInterceptors = baseUrlInterceptors,
json = json,
)
.build()
@OptIn(ExperimentalSerializationApi::class)
@Provides

View file

@ -0,0 +1,58 @@
package com.x8bit.bitwarden.data.platform.datasource.network.interceptor
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.Response
/**
* A [Interceptor] that optionally takes the current base URL of a request and replaces it with
* the currently set [baseUrl]
*/
class BaseUrlInterceptor : Interceptor {
/**
* The base URL to use as an override, or `null` if no override should be performed.
*/
var baseUrl: String? = null
set(value) {
field = value
baseHttpUrl = baseUrl?.let { requireNotNull(it.toHttpUrlOrNull()) }
}
private var baseHttpUrl: HttpUrl? = null
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
// If no base URL is set, we can simply skip
val base = baseHttpUrl ?: return chain.proceed(request)
// Update the base URL used.
return chain.proceed(
request
.newBuilder()
.url(
request
.url
.replaceBaseUrlWith(base),
)
.build(),
)
}
}
/**
* Given a [HttpUrl], replaces the existing base URL with the given [baseUrl].
*/
private fun HttpUrl.replaceBaseUrlWith(
baseUrl: HttpUrl,
) = baseUrl
.newBuilder()
.addEncodedPathSegments(
this
.encodedPathSegments
.joinToString(separator = "/"),
)
.encodedQuery(this.encodedQuery)
.build()

View file

@ -0,0 +1,65 @@
package com.x8bit.bitwarden.data.platform.datasource.network.interceptor
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.util.orNullIfBlank
import javax.inject.Inject
import javax.inject.Singleton
/**
* An overall container for various [BaseUrlInterceptor] implementations for different API groups.
*/
@Singleton
class BaseUrlInterceptors @Inject constructor() {
var environment: Environment = Environment.Us
set(value) {
field = value
updateBaseUrls(environment = value)
}
/**
* An interceptor for "/api" calls.
*/
val apiInterceptor: BaseUrlInterceptor = BaseUrlInterceptor()
/**
* An interceptor for "/identity" calls.
*/
val identityInterceptor: BaseUrlInterceptor = BaseUrlInterceptor()
/**
* An interceptor for "/events" calls.
*/
val eventsInterceptor: BaseUrlInterceptor = BaseUrlInterceptor()
init {
// Ensure all interceptors begin with a default value
environment = Environment.Us
}
private fun updateBaseUrls(environment: Environment) {
val environmentUrlData = environment.environmentUrlData
val baseUrl = environmentUrlData.base.trim()
// Determine the required base URLs
val apiUrl: String
val identityUrl: String
val eventsUrl: String
if (baseUrl.isNotEmpty()) {
apiUrl = "$baseUrl/api"
identityUrl = "$baseUrl/identity"
eventsUrl = "$baseUrl/events"
} else {
apiUrl =
environmentUrlData.api.orNullIfBlank() ?: "https://api.bitwarden.com"
identityUrl =
environmentUrlData.identity.orNullIfBlank() ?: "https://identity.bitwarden.com"
eventsUrl =
environmentUrlData.events.orNullIfBlank() ?: "https://events.bitwarden.com"
}
// Update the base URLs
apiInterceptor.baseUrl = apiUrl
identityInterceptor.baseUrl = identityUrl
eventsInterceptor.baseUrl = eventsUrl
}
}

View file

@ -0,0 +1,39 @@
package com.x8bit.bitwarden.data.platform.datasource.network.retrofit
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors
import retrofit2.Retrofit
/**
* A collection of various [Retrofit] instances that serve different purposes.
*/
interface Retrofits {
/**
* Allows access to "/api" calls that must be authenticated.
*
* The base URL can be dynamically determined via the [BaseUrlInterceptors].
*/
val authenticatedApiRetrofit: Retrofit
/**
* Allows access to "/api" calls that do not require authentication.
*
* The base URL can be dynamically determined via the [BaseUrlInterceptors].
*/
val unauthenticatedApiRetrofit: Retrofit
/**
* Allows access to "/identity" calls that do not require authentication.
*
* The base URL can be dynamically determined via the [BaseUrlInterceptors].
*/
val unauthenticatedIdentityRetrofit: Retrofit
/**
* Allows access to static API calls (ex: external APIs) that do not therefore require
* authentication with Bitwarden's servers.
*
* No base URL is supplied as part of the builder and no longer is added to make this URL
* dynamically updatable.
*/
val staticRetrofitBuilder: Retrofit.Builder
}

View file

@ -0,0 +1,122 @@
package com.x8bit.bitwarden.data.platform.datasource.network.retrofit
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.x8bit.bitwarden.data.platform.datasource.network.core.ResultCallAdapterFactory
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptor
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
/**
* Primary implementation of [Retrofits].
*/
class RetrofitsImpl(
authTokenInterceptor: AuthTokenInterceptor,
baseUrlInterceptors: BaseUrlInterceptors,
json: Json,
) : Retrofits {
//region Authenticated Retrofits
override val authenticatedApiRetrofit: Retrofit by lazy {
createAuthenticatedRetrofit(
baseUrlInterceptor = baseUrlInterceptors.apiInterceptor,
)
}
//endregion Authenticated Retrofits
//region Unauthenticated Retrofits
override val unauthenticatedApiRetrofit: Retrofit by lazy {
createUnauthenticatedRetrofit(
baseUrlInterceptor = baseUrlInterceptors.apiInterceptor,
)
}
override val unauthenticatedIdentityRetrofit: Retrofit by lazy {
createUnauthenticatedRetrofit(
baseUrlInterceptor = baseUrlInterceptors.identityInterceptor,
)
}
//endregion Unauthenticated Retrofits
//region Other Retrofits
override val staticRetrofitBuilder: Retrofit.Builder by lazy {
baseRetrofitBuilder
.client(
baseOkHttpClient
.newBuilder()
.addInterceptor(loggingInterceptor)
.build(),
)
}
//endregion Other Retrofits
//region Helper properties and functions
private val loggingInterceptor: HttpLoggingInterceptor by lazy {
HttpLoggingInterceptor().apply {
setLevel(HttpLoggingInterceptor.Level.BODY)
}
}
private val baseOkHttpClient: OkHttpClient =
OkHttpClient.Builder()
.build()
private val authenticatedOkHttpClient: OkHttpClient by lazy {
baseOkHttpClient
.newBuilder()
.addInterceptor(authTokenInterceptor)
.build()
}
private val baseRetrofit: Retrofit by lazy {
baseRetrofitBuilder
.baseUrl("https://api.bitwarden.com")
.build()
}
private val baseRetrofitBuilder: Retrofit.Builder by lazy {
Retrofit.Builder()
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.addCallAdapterFactory(ResultCallAdapterFactory())
.client(baseOkHttpClient)
}
private fun createAuthenticatedRetrofit(
baseUrlInterceptor: BaseUrlInterceptor,
): Retrofit =
baseRetrofit
.newBuilder()
.client(
authenticatedOkHttpClient
.newBuilder()
.addInterceptor(baseUrlInterceptor)
.addInterceptor(loggingInterceptor)
.build(),
)
.build()
private fun createUnauthenticatedRetrofit(
baseUrlInterceptor: BaseUrlInterceptor,
): Retrofit =
baseRetrofit
.newBuilder()
.client(
baseOkHttpClient
.newBuilder()
.addInterceptor(baseUrlInterceptor)
.addInterceptor(loggingInterceptor)
.build(),
)
.build()
//endregion Helper properties and functions
}

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.platform.repository
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
@ -15,6 +16,7 @@ class NetworkConfigRepositoryImpl(
private val authRepository: AuthRepository,
private val authTokenInterceptor: AuthTokenInterceptor,
private val environmentRepository: EnvironmentRepository,
private val baseUrlInterceptors: BaseUrlInterceptors,
dispatcher: CoroutineDispatcher,
) : NetworkConfigRepository {
@ -35,7 +37,7 @@ class NetworkConfigRepositoryImpl(
environmentRepository
.environmentStateFlow
.onEach { environment ->
// TODO: Update base URL interceptors (BIT-725)
baseUrlInterceptors.environment = environment
}
.launchIn(scope)
}

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.platform.repository.di
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepositoryImpl
import com.x8bit.bitwarden.data.platform.repository.NetworkConfigRepository
@ -37,11 +38,13 @@ object RepositoryModule {
authRepository: AuthRepository,
authTokenInterceptor: AuthTokenInterceptor,
environmentRepository: EnvironmentRepository,
baseUrlInterceptors: BaseUrlInterceptors,
): NetworkConfigRepository =
NetworkConfigRepositoryImpl(
authRepository = authRepository,
authTokenInterceptor = authTokenInterceptor,
environmentRepository = environmentRepository,
baseUrlInterceptors = baseUrlInterceptors,
dispatcher = Dispatchers.IO,
)
}

View file

@ -0,0 +1,12 @@
package com.x8bit.bitwarden.data.platform.util
/**
* Returns the original [String] only if:
*
* - it is non-null
* - it is not blank (where blank refers to empty strings of those containing only white space)
*
* Otherwise `null` is returned.
*/
fun String?.orNullIfBlank(): String? =
this?.takeUnless { it.isBlank() }

View file

@ -1,14 +1,12 @@
package com.x8bit.bitwarden.data.vault.datasource.network.di
import com.x8bit.bitwarden.data.platform.datasource.network.di.NetworkModule
import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.Retrofits
import com.x8bit.bitwarden.data.vault.datasource.network.api.SyncApi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Named
import javax.inject.Singleton
/**
@ -20,6 +18,7 @@ object NetworkModule {
@Provides
@Singleton
fun provideSyncApiService(@Named(NetworkModule.AUTHORIZED) retrofit: Retrofit): SyncApi =
retrofit.create()
fun provideSyncApiService(
retrofits: Retrofits,
): SyncApi = retrofits.authenticatedApiRetrofit.create()
}

View file

@ -29,7 +29,6 @@ class IdentityServiceTest : BaseServiceTest() {
private val identityService = IdentityServiceImpl(
api = identityApi,
json = Json,
baseUrl = server.url("/").toString(),
deviceModelProvider = deviceModelProvider,
)

View file

@ -0,0 +1,36 @@
package com.x8bit.bitwarden.data.platform.datasource.network.interceptor
import okhttp3.Request
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Test
class BaseUrlInterceptorTest {
private val baseUrlInterceptor = BaseUrlInterceptor()
@Test
fun `intercept with a null base URL should proceed with the original request`() {
val request = Request.Builder().url("http://www.fake.com/").build()
val chain = FakeInterceptorChain(request)
val response = baseUrlInterceptor.intercept(chain)
assertEquals(request, response.request)
assertEquals("http", response.request.url.scheme)
assertEquals("www.fake.com", response.request.url.host)
}
@Test
fun `intercept with a non-null base URL should update the base URL used by the request`() {
baseUrlInterceptor.baseUrl = "https://api.bitwarden.com"
val request = Request.Builder().url("http://www.fake.com/").build()
val chain = FakeInterceptorChain(request)
val response = baseUrlInterceptor.intercept(chain)
assertNotEquals(request, response.request)
assertEquals("https", response.request.url.scheme)
assertEquals("api.bitwarden.com", response.request.url.host)
}
}

View file

@ -0,0 +1,101 @@
package com.x8bit.bitwarden.data.platform.datasource.network.interceptor
import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class BaseUrlInterceptorsTest {
private val baseUrlInterceptors = BaseUrlInterceptors()
@Test
fun `the default environment should be US and all interceptors should have the correct URLs`() {
assertEquals(
Environment.Us,
baseUrlInterceptors.environment,
)
assertEquals(
"https://vault.bitwarden.com/api",
baseUrlInterceptors.apiInterceptor.baseUrl,
)
assertEquals(
"https://vault.bitwarden.com/identity",
baseUrlInterceptors.identityInterceptor.baseUrl,
)
assertEquals(
"https://vault.bitwarden.com/events",
baseUrlInterceptors.eventsInterceptor.baseUrl,
)
}
@Suppress("MaxLineLength")
@Test
fun `setting the environment should update all the interceptors correctly for a non-blank base URL`() {
baseUrlInterceptors.environment = Environment.Eu
assertEquals(
"https://vault.bitwarden.eu/api",
baseUrlInterceptors.apiInterceptor.baseUrl,
)
assertEquals(
"https://vault.bitwarden.eu/identity",
baseUrlInterceptors.identityInterceptor.baseUrl,
)
assertEquals(
"https://vault.bitwarden.eu/events",
baseUrlInterceptors.eventsInterceptor.baseUrl,
)
}
@Suppress("MaxLineLength")
@Test
fun `setting the environment should update all the interceptors correctly for a blank base URL and all URLs filled`() {
baseUrlInterceptors.environment = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson(
base = " ",
api = "https://api.com",
identity = "https://identity.com",
events = "https://events.com",
),
)
assertEquals(
"https://api.com",
baseUrlInterceptors.apiInterceptor.baseUrl,
)
assertEquals(
"https://identity.com",
baseUrlInterceptors.identityInterceptor.baseUrl,
)
assertEquals(
"https://events.com",
baseUrlInterceptors.eventsInterceptor.baseUrl,
)
}
@Suppress("MaxLineLength")
@Test
fun `setting the environment should update all the interceptors correctly for a blank base URL and some or all URLs absent`() {
baseUrlInterceptors.environment = Environment.SelfHosted(
environmentUrlData = EnvironmentUrlDataJson(
base = " ",
api = "",
identity = "",
icon = " ",
),
)
assertEquals(
"https://api.bitwarden.com",
baseUrlInterceptors.apiInterceptor.baseUrl,
)
assertEquals(
"https://identity.bitwarden.com",
baseUrlInterceptors.identityInterceptor.baseUrl,
)
assertEquals(
"https://events.bitwarden.com",
baseUrlInterceptors.eventsInterceptor.baseUrl,
)
}
}

View file

@ -0,0 +1,155 @@
package com.x8bit.bitwarden.data.platform.datasource.network.retrofit
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import okhttp3.Interceptor
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import retrofit2.Retrofit
import retrofit2.create
import retrofit2.http.GET
class RetrofitsTest {
private val authTokenInterceptor = mockk<AuthTokenInterceptor> {
mockIntercept { isAuthInterceptorCalled = true }
}
private val baseUrlInterceptors = mockk<BaseUrlInterceptors> {
every { apiInterceptor } returns mockk {
mockIntercept { isApiInterceptorCalled = true }
}
every { identityInterceptor } returns mockk {
mockIntercept { isIdentityInterceptorCalled = true }
}
every { eventsInterceptor } returns mockk {
mockIntercept { isEventsInterceptorCalled = true }
}
}
private val json = Json
private val server = MockWebServer()
private val retrofits = RetrofitsImpl(
authTokenInterceptor = authTokenInterceptor,
baseUrlInterceptors = baseUrlInterceptors,
json = json,
)
private var isAuthInterceptorCalled = false
private var isApiInterceptorCalled = false
private var isIdentityInterceptorCalled = false
private var isEventsInterceptorCalled = false
@Before
fun setUp() {
server.start()
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun `authenticatedApiRetrofit should invoke the correct interceptors`() = runBlocking {
val testApi = retrofits
.authenticatedApiRetrofit
.createMockRetrofit()
.create<TestApi>()
server.enqueue(MockResponse().setBody("""{}"""))
testApi.test()
assertTrue(isAuthInterceptorCalled)
assertTrue(isApiInterceptorCalled)
assertFalse(isIdentityInterceptorCalled)
assertFalse(isEventsInterceptorCalled)
}
@Test
fun `unauthenticatedApiRetrofit should invoke the correct interceptors`() = runBlocking {
val testApi = retrofits
.unauthenticatedApiRetrofit
.createMockRetrofit()
.create<TestApi>()
server.enqueue(MockResponse().setBody("""{}"""))
testApi.test()
assertFalse(isAuthInterceptorCalled)
assertTrue(isApiInterceptorCalled)
assertFalse(isIdentityInterceptorCalled)
assertFalse(isEventsInterceptorCalled)
}
@Test
fun `unauthenticatedIdentityRetrofit should invoke the correct interceptors`() = runBlocking {
val testApi = retrofits
.unauthenticatedIdentityRetrofit
.createMockRetrofit()
.create<TestApi>()
server.enqueue(MockResponse().setBody("""{}"""))
testApi.test()
assertFalse(isAuthInterceptorCalled)
assertFalse(isApiInterceptorCalled)
assertTrue(isIdentityInterceptorCalled)
assertFalse(isEventsInterceptorCalled)
}
@Test
fun `staticRetrofitBuilder should invoke the correct interceptors`() = runBlocking {
val testApi = retrofits
.staticRetrofitBuilder
.baseUrl(server.url("/").toString())
.build()
.createMockRetrofit()
.create<TestApi>()
server.enqueue(MockResponse().setBody("""{}"""))
testApi.test()
assertFalse(isAuthInterceptorCalled)
assertFalse(isApiInterceptorCalled)
assertFalse(isIdentityInterceptorCalled)
assertFalse(isEventsInterceptorCalled)
}
private fun Retrofit.createMockRetrofit(): Retrofit =
this
.newBuilder()
.baseUrl(server.url("/").toString())
.build()
}
interface TestApi {
@GET("/test")
suspend fun test(): JsonObject
}
/**
* Mocks the given [Interceptor] such that the [Interceptor.intercept] is a no-op but triggers the
* [isCalledCallback].
*/
private fun Interceptor.mockIntercept(isCalledCallback: () -> Unit) {
val chainSlot = slot<Interceptor.Chain>()
every { intercept(capture(chainSlot)) } answers {
isCalledCallback()
val chain = chainSlot.captured
chain.proceed(chain.request())
}
}

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.platform.repository
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import io.mockk.every
import io.mockk.mockk
@ -28,6 +29,7 @@ class NetworkConfigRepositoryTest {
}
private val authTokenInterceptor = AuthTokenInterceptor()
private val baseUrlInterceptors = BaseUrlInterceptors()
private lateinit var networkConfigRepository: NetworkConfigRepository
@ -37,6 +39,7 @@ class NetworkConfigRepositoryTest {
authRepository = authRepository,
authTokenInterceptor = authTokenInterceptor,
environmentRepository = environmentRepository,
baseUrlInterceptors = baseUrlInterceptors,
dispatcher = UnconfinedTestDispatcher(),
)
}
@ -55,4 +58,19 @@ class NetworkConfigRepositoryTest {
mutableAuthStateFlow.value = AuthState.Unauthenticated
assertNull(authTokenInterceptor.authToken)
}
@Test
fun `changes in the Environment should update the BaseUrlInterceptors`() {
mutableEnvironmentStateFlow.value = Environment.Us
assertEquals(
Environment.Us,
baseUrlInterceptors.environment,
)
mutableEnvironmentStateFlow.value = Environment.Eu
assertEquals(
Environment.Eu,
baseUrlInterceptors.environment,
)
}
}

View file

@ -0,0 +1,30 @@
package com.x8bit.bitwarden.data.platform.util
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
class StringExtensionsTest {
@Test
fun `orNullIfBlank returns null for a null String`() {
assertNull((null as String?).orNullIfBlank())
}
@Test
fun `orNullIfBlank returns null for an empty String`() {
assertNull("".orNullIfBlank())
}
@Test
fun `orNullIfBlank returns null for a blank String`() {
assertNull(" ".orNullIfBlank())
}
@Test
fun `orNullIfBlank returns the original value for a non-blank String`() {
assertEquals(
"test",
"test".orNullIfBlank(),
)
}
}