From c7387c7b523159eb9dd3c46a4863fecb0f1c0a01 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Wed, 14 Aug 2024 18:58:12 +0200 Subject: [PATCH] prevent mixup of account timelines (#4599) This does 2 things: - Removes `AccountSwitchInterceptor`, the main culprit for the bug. APIs can no longer change their base url after they have been created. As a result they are not Singletons anymore. - Additionally, I refactored how MainActivity handles Intents to make it less likely to have multiple instances of it active. Here is how I could reliably reproduce the bug: - Be logged in with account A and B - Write a post with account A, cancel it before it sends (go into flight mode for that) - Switch to account B - Open the "this post failed to send" notification from account A, drafts will open - Go back. You are in the MainActivity of account A, everything seems fine. - Go back again. You are in the old, now broken MainActivity of account B. It uses the database of account B but the network of account A. Refreshing will show posts from A. closes #4567 closes #4554 closes #4402 closes #4148 closes #2663 and possibly #4588 --- .../com/keylesspalace/tusky/MainActivity.kt | 20 ++- .../tusky/components/compose/MediaUploader.kt | 2 - .../instanceinfo/InstanceInfoRepository.kt | 8 +- .../keylesspalace/tusky/di/NetworkModule.kt | 63 +++++--- .../keylesspalace/tusky/network/ApiFactory.kt | 57 +++++++ .../tusky/network/FailingCall.kt | 65 ++++++++ .../network/InstanceSwitchAuthInterceptor.kt | 84 ----------- .../InstanceSwitchAuthInterceptorTest.kt | 142 ------------------ .../tusky/network/RetrofitApiTest.kt | 126 ++++++++++++++++ 9 files changed, 306 insertions(+), 261 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/network/ApiFactory.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/network/FailingCall.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt delete mode 100644 app/src/test/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptorTest.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/network/RetrofitApiTest.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index 894757a79..787f78ef5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -363,6 +363,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } } + override fun onDestroy() { + cacheUpdater.stop() + super.onDestroy() + } + /** Handle an incoming Intent, * @returns true when the intent is coming from an notification and the interface should show the notification tab. */ @@ -390,6 +395,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { val accountRequested = tuskyAccountId != -1L if (accountRequested && tuskyAccountId != activeAccount.id) { accountManager.setActiveAccount(tuskyAccountId) + changeAccount(tuskyAccountId, intent, withAnimation = false) + return false } val openDrafts = intent.getBooleanExtra(OPEN_DRAFTS, false) @@ -567,7 +574,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } } startActivity(composeIntent) - finish() } private fun setupDrawer( @@ -985,11 +991,17 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { return false } - private fun changeAccount(newSelectedId: Long, forward: Intent?) { + private fun changeAccount( + newSelectedId: Long, + forward: Intent?, + withAnimation: Boolean = true + ) { cacheUpdater.stop() accountManager.setActiveAccount(newSelectedId) val intent = Intent(this, MainActivity::class.java) - intent.putExtra(OPEN_WITH_EXPLODE_ANIMATION, true) + if (withAnimation) { + intent.putExtra(OPEN_WITH_EXPLODE_ANIMATION, true) + } if (forward != null) { intent.type = forward.type intent.action = forward.action @@ -1240,6 +1252,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { fun accountSwitchIntent(context: Context, tuskyAccountId: Long): Intent { return Intent(context, MainActivity::class.java).apply { putExtra(TUSKY_ACCOUNT_ID, tuskyAccountId) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } } @@ -1286,7 +1299,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { fun redirectIntent(context: Context, tuskyAccountId: Long, url: String): Intent { return accountSwitchIntent(context, tuskyAccountId).apply { putExtra(REDIRECT_URL, url) - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt index 9c5520c3c..478ab7e64 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt @@ -40,7 +40,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import java.io.IOException import javax.inject.Inject -import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -93,7 +92,6 @@ class MediaTypeException : Exception() class CouldNotOpenFileException : Exception() class UploadServerError(val errorMessage: String) : Exception() -@Singleton class MediaUploader @Inject constructor( @ApplicationContext private val context: Context, private val mediaUploadApi: MediaUploadApi diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt index ffd475414..7e8378588 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt @@ -35,13 +35,11 @@ import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.isHttpNotFound import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject -import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -@Singleton class InstanceInfoRepository @Inject constructor( private val api: MastodonApi, db: AppDatabase, @@ -53,9 +51,6 @@ class InstanceInfoRepository @Inject constructor( private val instanceName get() = accountManager.activeAccount!!.domain - /** In-memory cache for instance data, per instance domain. */ - private var instanceInfoCache = ConcurrentHashMap() - fun precache() { // We are avoiding some duplicate work but we are not trying too hard. // We might request it multiple times in parallel which is not a big problem. @@ -212,6 +207,9 @@ class InstanceInfoRepository @Inject constructor( companion object { private const val TAG = "InstanceInfoRepo" + /** In-memory cache for instance data, per instance domain. */ + private var instanceInfoCache = ConcurrentHashMap() + const val DEFAULT_CHARACTER_LIMIT = 500 private const val DEFAULT_MAX_OPTION_COUNT = 4 private const val DEFAULT_MAX_OPTION_LENGTH = 50 diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt index b1ccfa4be..db887585c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt @@ -26,9 +26,9 @@ import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.json.GuardedAdapter -import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MediaUploadApi +import com.keylesspalace.tusky.network.apiForAccount import com.keylesspalace.tusky.settings.PrefKeys.HTTP_PROXY_ENABLED import com.keylesspalace.tusky.settings.PrefKeys.HTTP_PROXY_PORT import com.keylesspalace.tusky.settings.PrefKeys.HTTP_PROXY_SERVER @@ -47,6 +47,7 @@ import java.net.InetSocketAddress import java.net.Proxy import java.util.Date import java.util.concurrent.TimeUnit +import javax.inject.Named import javax.inject.Singleton import okhttp3.Cache import okhttp3.OkHttp @@ -54,7 +55,6 @@ import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory -import retrofit2.create /** * Created by charlag on 3/24/18. @@ -66,6 +66,18 @@ object NetworkModule { private const val TAG = "NetworkModule" + @Provides + @Named("defaultPort") + fun providesDefaultPort(): Int { + return 443 + } + + @Provides + @Named("defaultScheme") + fun providesDefaultScheme(): String { + return "https://" + } + @Provides @Singleton fun providesMoshi(): Moshi = Moshi.Builder() @@ -92,7 +104,6 @@ object NetworkModule { @Provides @Singleton fun providesHttpClient( - accountManager: AccountManager, @ApplicationContext context: Context, preferences: SharedPreferences ): OkHttpClient { @@ -125,23 +136,22 @@ object NetworkModule { builder.proxy(Proxy(Proxy.Type.HTTP, address)) } ?: Log.w(TAG, "Invalid proxy configuration: ($httpServer, $httpPort)") } - - return builder - .apply { - addInterceptor(InstanceSwitchAuthInterceptor(accountManager)) - if (BuildConfig.DEBUG) { - addInterceptor( - HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC } - ) - } - } - .build() + if (BuildConfig.DEBUG) { + builder.addInterceptor( + HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC } + ) + } + return builder.build() } @Provides @Singleton - fun providesRetrofit(httpClient: OkHttpClient, moshi: Moshi): Retrofit { - return Retrofit.Builder().baseUrl("https://" + MastodonApi.PLACEHOLDER_DOMAIN) + fun providesRetrofit( + httpClient: OkHttpClient, + moshi: Moshi + ): Retrofit { + return Retrofit.Builder() + .baseUrl("https://${MastodonApi.PLACEHOLDER_DOMAIN}") .client(httpClient) .addConverterFactory(MoshiConverterFactory.create(moshi)) .addCallAdapterFactory(NetworkResultCallAdapterFactory.create()) @@ -149,20 +159,25 @@ object NetworkModule { } @Provides - @Singleton - fun providesApi(retrofit: Retrofit): MastodonApi = retrofit.create() + fun providesMastodonApi( + httpClient: OkHttpClient, + retrofit: Retrofit, + accountManager: AccountManager + ): MastodonApi { + return apiForAccount(accountManager.activeAccount, httpClient, retrofit) + } @Provides - @Singleton - fun providesMediaUploadApi(retrofit: Retrofit, okHttpClient: OkHttpClient): MediaUploadApi { + fun providesMediaUploadApi( + retrofit: Retrofit, + okHttpClient: OkHttpClient, + accountManager: AccountManager + ): MediaUploadApi { val longTimeOutOkHttpClient = okHttpClient.newBuilder() .readTimeout(100, TimeUnit.SECONDS) .writeTimeout(100, TimeUnit.SECONDS) .build() - return retrofit.newBuilder() - .client(longTimeOutOkHttpClient) - .build() - .create() + return apiForAccount(accountManager.activeAccount, longTimeOutOkHttpClient, retrofit) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/ApiFactory.kt b/app/src/main/java/com/keylesspalace/tusky/network/ApiFactory.kt new file mode 100644 index 000000000..9db7ed643 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/network/ApiFactory.kt @@ -0,0 +1,57 @@ +package com.keylesspalace.tusky.network + +import com.keylesspalace.tusky.db.entity.AccountEntity +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.create + +/** + * Creates an instance of an Api that will only make requests as the provided account. + * @param account The account to make requests as. + * When null, request without additional DOMAIN_HEADER will fail. + * @param httpClient The OkHttpClient to make requests as + * @param retrofit The Retrofit instance to derive the api from + * @param scheme The scheme to use. Only used in tests. + * @param port The port to use. Only used in tests. + */ +inline fun apiForAccount( + account: AccountEntity?, + httpClient: OkHttpClient, + retrofit: Retrofit, + scheme: String = "https://", + port: Int? = null +): T { + return retrofit.newBuilder() + .apply { + if (account != null) { + baseUrl("$scheme${account.domain}${ if (port == null) "" else ":$port"}") + } + } + .callFactory { originalRequest -> + var request = originalRequest + + val domainHeader = originalRequest.header(MastodonApi.DOMAIN_HEADER) + if (domainHeader != null) { + request = originalRequest.newBuilder() + .url( + originalRequest.url.newBuilder().host(domainHeader).build() + ) + .removeHeader(MastodonApi.DOMAIN_HEADER) + .build() + } + + if (account != null && request.url.host == account.domain) { + request = request.newBuilder() + .header("Authorization", "Bearer ${account.accessToken}") + .build() + } + + if (request.url.host == MastodonApi.PLACEHOLDER_DOMAIN) { + FailingCall(request) + } else { + httpClient.newCall(request) + } + } + .build() + .create() +} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/FailingCall.kt b/app/src/main/java/com/keylesspalace/tusky/network/FailingCall.kt new file mode 100644 index 000000000..c07489524 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/network/FailingCall.kt @@ -0,0 +1,65 @@ +/* Copyright 2024 Tusky contributors + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.network + +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.Timeout + +class FailingCall(private val request: Request) : Call { + + private var isExecuted: Boolean = false + + override fun cancel() { } + + override fun clone(): Call { + return FailingCall(request()) + } + + override fun enqueue(responseCallback: Callback) { + isExecuted = true + responseCallback.onResponse(this, failingResponse()) + } + + override fun execute(): Response { + isExecuted = true + return failingResponse() + } + + override fun isCanceled(): Boolean = false + + override fun isExecuted(): Boolean = isExecuted + + override fun request(): Request = request + + override fun timeout(): Timeout { + return Timeout.NONE + } + + private fun failingResponse(): Response { + return Response.Builder() + .request(request) + .code(400) + .message("Bad Request") + .protocol(Protocol.HTTP_1_1) + .body("".toResponseBody()) + .build() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt b/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt deleted file mode 100644 index 6aabaa13d..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* Copyright 2022 Tusky Contributors - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.network - -import android.util.Log -import com.keylesspalace.tusky.db.AccountManager -import java.io.IOException -import okhttp3.HttpUrl -import okhttp3.Interceptor -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.Protocol -import okhttp3.Request -import okhttp3.Response -import okhttp3.ResponseBody.Companion.toResponseBody - -class InstanceSwitchAuthInterceptor(private val accountManager: AccountManager) : Interceptor { - - @Throws(IOException::class) - override fun intercept(chain: Interceptor.Chain): Response { - val originalRequest: Request = chain.request() - - // only switch domains if the request comes from retrofit - return if (originalRequest.url.host == MastodonApi.PLACEHOLDER_DOMAIN) { - val builder: Request.Builder = originalRequest.newBuilder() - val instanceHeader = originalRequest.header(MastodonApi.DOMAIN_HEADER) - - if (instanceHeader != null) { - // use domain explicitly specified in custom header - builder.url(swapHost(originalRequest.url, instanceHeader)) - builder.removeHeader(MastodonApi.DOMAIN_HEADER) - } else { - val currentAccount = accountManager.activeAccount - - if (currentAccount != null) { - val accessToken = currentAccount.accessToken - if (accessToken.isNotEmpty()) { - // use domain of current account - builder.url(swapHost(originalRequest.url, currentAccount.domain)) - .header("Authorization", "Bearer %s".format(accessToken)) - } - } - } - - val newRequest: Request = builder.build() - - if (MastodonApi.PLACEHOLDER_DOMAIN == newRequest.url.host) { - Log.w( - "ISAInterceptor", - "no user logged in or no domain header specified - can't make request to " + newRequest.url - ) - return Response.Builder() - .code(400) - .message("Bad Request") - .protocol(Protocol.HTTP_2) - .body("".toResponseBody("text/plain".toMediaType())) - .request(chain.request()) - .build() - } - - chain.proceed(newRequest) - } else { - chain.proceed(originalRequest) - } - } - - companion object { - private fun swapHost(url: HttpUrl, host: String): HttpUrl { - return url.newBuilder().host(host).build() - } - } -} diff --git a/app/src/test/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptorTest.kt b/app/src/test/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptorTest.kt deleted file mode 100644 index 206e27d5c..000000000 --- a/app/src/test/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptorTest.kt +++ /dev/null @@ -1,142 +0,0 @@ -package com.keylesspalace.tusky.network - -import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.db.entity.AccountEntity -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Before -import org.junit.Test -import org.mockito.kotlin.doAnswer -import org.mockito.kotlin.mock - -class InstanceSwitchAuthInterceptorTest { - - private val mockWebServer = MockWebServer() - - @Before - fun setup() { - mockWebServer.start() - } - - @After - fun teardown() { - mockWebServer.shutdown() - } - - @Test - fun `should make regular request when requested`() { - mockWebServer.enqueue(MockResponse()) - - val accountManager: AccountManager = mock { - on { activeAccount } doAnswer { null } - } - - val okHttpClient = OkHttpClient.Builder() - .addInterceptor(InstanceSwitchAuthInterceptor(accountManager)) - .build() - - val request = Request.Builder() - .get() - .url(mockWebServer.url("/test")) - .build() - - val response = okHttpClient.newCall(request).execute() - - assertEquals(200, response.code) - } - - @Test - fun `should make request to instance requested in special header`() { - mockWebServer.enqueue(MockResponse()) - - val accountManager: AccountManager = mock { - on { activeAccount } doAnswer { - AccountEntity( - id = 1, - domain = "test.domain", - accessToken = "fakeToken", - clientId = "fakeId", - clientSecret = "fakeSecret", - isActive = true - ) - } - } - - val okHttpClient = OkHttpClient.Builder() - .addInterceptor(InstanceSwitchAuthInterceptor(accountManager)) - .build() - - val request = Request.Builder() - .get() - .url("http://" + MastodonApi.PLACEHOLDER_DOMAIN + ":" + mockWebServer.port + "/test") - .header(MastodonApi.DOMAIN_HEADER, mockWebServer.hostName) - .build() - - val response = okHttpClient.newCall(request).execute() - - assertEquals(200, response.code) - - assertNull(mockWebServer.takeRequest().getHeader("Authorization")) - } - - @Test - fun `should make request to current instance when requested and user is logged in`() { - mockWebServer.enqueue(MockResponse()) - - val accountManager: AccountManager = mock { - on { activeAccount } doAnswer { - AccountEntity( - id = 1, - domain = mockWebServer.hostName, - accessToken = "fakeToken", - clientId = "fakeId", - clientSecret = "fakeSecret", - isActive = true - ) - } - } - - val okHttpClient = OkHttpClient.Builder() - .addInterceptor(InstanceSwitchAuthInterceptor(accountManager)) - .build() - - val request = Request.Builder() - .get() - .url("http://" + MastodonApi.PLACEHOLDER_DOMAIN + ":" + mockWebServer.port + "/test") - .build() - - val response = okHttpClient.newCall(request).execute() - - assertEquals(200, response.code) - - assertEquals("Bearer fakeToken", mockWebServer.takeRequest().getHeader("Authorization")) - } - - @Test - fun `should fail to make request when request to current instance is requested but no user is logged in`() { - mockWebServer.enqueue(MockResponse()) - - val accountManager: AccountManager = mock { - on { activeAccount } doAnswer { null } - } - - val okHttpClient = OkHttpClient.Builder() - .addInterceptor(InstanceSwitchAuthInterceptor(accountManager)) - .build() - - val request = Request.Builder() - .get() - .url("http://" + MastodonApi.PLACEHOLDER_DOMAIN + "/test") - .build() - - val response = okHttpClient.newCall(request).execute() - - assertEquals(400, response.code) - assertEquals(0, mockWebServer.requestCount) - } -} diff --git a/app/src/test/java/com/keylesspalace/tusky/network/RetrofitApiTest.kt b/app/src/test/java/com/keylesspalace/tusky/network/RetrofitApiTest.kt new file mode 100644 index 000000000..ae0024fa7 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/network/RetrofitApiTest.kt @@ -0,0 +1,126 @@ +package com.keylesspalace.tusky.network + +import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory +import com.keylesspalace.tusky.db.entity.AccountEntity +import com.keylesspalace.tusky.entity.Instance +import com.squareup.moshi.Moshi +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory + +class RetrofitApiTest { + + private val mockWebServer = MockWebServer() + private val okHttpClient = OkHttpClient.Builder().build() + private val moshi = Moshi.Builder().build() + + @Before + fun setup() { + mockWebServer.start() + } + + @After + fun teardown() { + mockWebServer.shutdown() + } + + private fun retrofit() = Retrofit.Builder() + .baseUrl("http://${MastodonApi.PLACEHOLDER_DOMAIN}:${mockWebServer.port}") + .client(okHttpClient) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .addCallAdapterFactory(NetworkResultCallAdapterFactory.create()) + .build() + + @Test + fun `should make request to the active account's instance`() = runTest { + mockInstanceResponse() + + val account = AccountEntity( + id = 1, + domain = mockWebServer.hostName, + accessToken = "fakeToken", + clientId = "fakeId", + clientSecret = "fakeSecret", + isActive = true + ) + + val retrofit = retrofit() + val api: MastodonApi = apiForAccount(account, okHttpClient, retrofit, "http://", mockWebServer.port) + + val instanceResponse = api.getInstance() + + assertTrue(instanceResponse.isSuccess) + assertEquals("Bearer fakeToken", mockWebServer.takeRequest().getHeader("Authorization")) + } + + @Test + fun `should make request to instance requested in special header when account active`() = runTest { + mockInstanceResponse() + + val account = AccountEntity( + id = 1, + domain = "test.domain", + accessToken = "fakeToken", + clientId = "fakeId", + clientSecret = "fakeSecret", + isActive = true + ) + + val retrofit = retrofit() + val api: MastodonApi = apiForAccount(account, okHttpClient, retrofit, "http://", mockWebServer.port) + + val instanceResponse = api.getInstance(domain = mockWebServer.hostName) + + assertTrue(instanceResponse.isSuccess) + assertNull(mockWebServer.takeRequest().getHeader("Authorization")) + } + + @Test + fun `should make request to instance requested in special header when no account active`() = runTest { + mockInstanceResponse() + + val retrofit = retrofit() + val api: MastodonApi = apiForAccount(null, okHttpClient, retrofit, "http://", mockWebServer.port) + + val instanceResponse = api.getInstance(domain = mockWebServer.hostName) + + assertTrue(instanceResponse.isSuccess) + assertNull(mockWebServer.takeRequest().getHeader("Authorization")) + } + + @Test + fun `should fail when current instance is requested but no user is logged in`() = runTest { + mockInstanceResponse() + + val retrofit = retrofit() + val api: MastodonApi = apiForAccount(null, okHttpClient, retrofit, "http://", mockWebServer.port) + + val instanceResponse = api.getInstance() + + assertTrue(instanceResponse.isFailure) + assertEquals(0, mockWebServer.requestCount) + } + + private fun mockInstanceResponse() { + mockWebServer.enqueue( + MockResponse() + .setBody( + moshi.adapter(Instance::class.java).toJson( + Instance( + domain = "example.org", + version = "1.0.0" + ) + ) + ) + ) + } +}