diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt new file mode 100644 index 0000000000..a8843a9ca2 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider +import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider +import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory +import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory +import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider +import io.element.android.libraries.network.useragent.SimpleUserAgentProvider +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.File + +class RustTemporaryMatrixClientFactoryTest { + @Test + fun `create returns a TemporaryMatrixClient`() = runTest { + val sut = createRustTemporaryMatrixClientFactory() + val result = sut.create("https://matrix.org") + assertThat(result.isSuccess).isTrue() + } + + @Test + fun `create can fail gracefully`() = runTest { + val sut = createRustTemporaryMatrixClientFactory( + rustMatrixClientFactory = createRustMatrixClientFactory( + clientBuilderProvider = FakeClientBuilderProvider( + provideResult = { throw IllegalStateException("Failed to create client builder") } + ) + ) + ) + val result = sut.create("https://matrix.org") + assertThat(result.isFailure).isTrue() + } + + private fun TestScope.createRustTemporaryMatrixClientFactory( + sessionPathsFactory: SessionPathsFactory = SessionPathsFactory(File("/base"), File("/cache")), + rustMatrixClientFactory: RustMatrixClientFactory = createRustMatrixClientFactory(), + ): RustTemporaryMatrixClientFactory { + return RustTemporaryMatrixClientFactory( + sessionPathsFactory = sessionPathsFactory, + rustMatrixClientFactory = rustMatrixClientFactory, + ) + } + + private fun TestScope.createRustMatrixClientFactory( + cacheDirectory: File = File("/cache"), + sessionStore: SessionStore = InMemorySessionStore( + updateUserProfileResult = { _, _, _ -> }, + ), + clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), + workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), + contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory = { ContentScannerUrlProvider { Result.success(null) } }, + ) = RustMatrixClientFactory( + cacheDirectory = cacheDirectory, + appCoroutineScope = backgroundScope, + coroutineDispatchers = testCoroutineDispatchers(), + sessionStore = sessionStore, + userAgentProvider = SimpleUserAgentProvider(), + proxyProvider = FakeProxyProvider(), + clock = FakeSystemClock(), + analyticsService = FakeAnalyticsService(), + featureFlagService = FakeFeatureFlagService(), + timelineEventFilterFactory = FakeTimelineEventFilterFactory(), + clientBuilderProvider = clientBuilderProvider, + sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), + workManagerScheduler = workManagerScheduler, + contentScannerUrlProviderFactory = contentScannerUrlProviderFactory, + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt index f5f3ffd10b..e349c616c0 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.test.auth.FakeOAuthRedirectUrlProvide import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest @@ -50,6 +51,29 @@ class RustMatrixAuthenticationServiceTest { assertThat(sut.setHomeserver("matrix.org").isSuccess).isTrue() } + @Test + fun `setHomeserver can fail gracefully and clean up the temporary client`() = runTest { + val closeResult = lambdaRecorder {} + val sut = createRustMatrixAuthenticationService( + clientBuilderProvider = FakeClientBuilderProvider( + provideResult = { + FakeFfiClientBuilder( + buildResult = { + FakeFfiClient( + homeserverLoginDetailsResult = { + throw IllegalStateException("Failed to get homeserver login details") + }, + closeResult = closeResult, + ) + }, + ) + }, + ), + ) + assertThat(sut.setHomeserver("matrix.org").isFailure).isTrue() + closeResult.assertions().isCalledOnce() + } + private fun TestScope.createRustMatrixAuthenticationService( sessionStore: SessionStore = InMemorySessionStore(), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetriever.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetriever.kt index fbfa58b108..60e8c621e2 100644 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetriever.kt +++ b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetriever.kt @@ -56,7 +56,9 @@ class DefaultWellknownRetriever( } // The passed `host` function may be a full URL, in that case we're only interested in the actual host - val checkedHost = runCatchingExceptions { URL(host.ensureProtocol()).host.takeIfNotBlank() }.getOrNull() ?: host + val checkedHost = runCatchingExceptions { URL(host.ensureProtocol()).host.takeIfNotBlank() } + .getOrElse { return WellknownRetrieverResult.Error(IllegalArgumentException("host parameter is not valid", it)) } + ?: host // We instantiate different stores for different sources, so that we can cache them separately. // The ESS_CONFIG source is cached with a prefix to differentiate it from the WELLKNOWN_ENDPOINT. diff --git a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetrieverTest.kt b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetrieverTest.kt index 5151f17599..224c847387 100644 --- a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetrieverTest.kt +++ b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetrieverTest.kt @@ -14,18 +14,22 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.wellknown.test.FakeElementWellknownStore import io.element.android.features.wellknown.test.anElementWellKnown +import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.matrix.api.UrlContentFetcher import io.element.android.libraries.matrix.api.exception.ClientException import io.element.android.libraries.wellknown.api.ElementWellKnown import io.element.android.libraries.wellknown.api.ElementWellKnownParser +import io.element.android.libraries.wellknown.api.ElementWellknownStore import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource import io.element.android.libraries.wellknown.api.WellknownRetrieverResult import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test +import java.net.URL class DefaultWellknownRetrieverTest { @Test @@ -68,6 +72,93 @@ class DefaultWellknownRetrieverTest { .isInstanceOf(WellknownRetrieverResult.NotFound::class.java) } + @Test + fun `get element wellknown will return an error if the provided host is not valid`() = runTest { + val sut = createDefaultWellknownRetriever() + assertThat(sut.getElementWellKnown("!not valid", EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)) + .isInstanceOf(WellknownRetrieverResult.Error::class.java) + } + + @Test + fun `get element wellknown will return a cached value if present instead of fetching`() = runTest { + val host = URL(WELLKNOWN_URL).host.ensureProtocol() + val sut = createDefaultWellknownRetriever( + cacheStoreFactory = { FakeElementWellknownStore(initialData = mapOf(host to WellknownRetrieverResult.Success(expectedElementWellKnown))) } + ) + assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)) + .isInstanceOf(WellknownRetrieverResult.Success::class.java) + } + + @Test + fun `get element wellknown will return an outdated value first then fetch the current one`() = runTest { + val host = URL(WELLKNOWN_URL).host.ensureProtocol() + val resolver = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) } + val sut = createDefaultWellknownRetriever( + urlResolver = resolver, + cacheStoreFactory = { FakeElementWellknownStore(initialData = mapOf(host to WellknownRetrieverResult.Outdated(expectedElementWellKnown))) } + ) + // The first call returns the outdated value + assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)) + .isInstanceOf(WellknownRetrieverResult.Outdated::class.java) + // The resolver is not called yet because the fetch is done in the background + resolver.assertions().isNeverCalled() + + // We give it some time to run the background fetch + runCurrent() + + // The resolver is called during the background fetch + resolver.assertions().isCalledOnce().with(value(WELLKNOWN_URL)) + } + + @Test + fun `get element wellknown will return an error if it happens when trying to retrieve the cached value`() = runTest { + val host = URL(WELLKNOWN_URL).host.ensureProtocol() + val sut = createDefaultWellknownRetriever( + cacheStoreFactory = { FakeElementWellknownStore(initialData = mapOf(host to WellknownRetrieverResult.Error(IllegalStateException("BOOM")))) } + ) + assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)) + .isInstanceOf(WellknownRetrieverResult.Error::class.java) + } + + @Test + fun `get element wellknown will return an error if it fails to serialize the retrieved value into the cache`() = runTest { + val sut = createDefaultWellknownRetriever( + elementWellKnownParser = { Result.failure(IllegalStateException("BOOM")) }, + ) + assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)) + .isInstanceOf(WellknownRetrieverResult.Error::class.java) + } + + @Test + fun `WELLKNOWN_ENDPOINT source creates a store with no prefix`() = runTest { + val storeFactory = lambdaRecorder { FakeElementWellknownStore() } + val sut = createDefaultWellknownRetriever(cacheStoreFactory = storeFactory) + + sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT) + + storeFactory.assertions().isCalledOnce().with(value(null)) + } + + @Test + fun `ESS_CONFIG source creates a store with ess prefix and checks the ESS config endpoint`() = runTest { + val essConfigUrl = "https://ess-config.example.com" + val storeFactory = lambdaRecorder { FakeElementWellknownStore() } + val resolver = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) } + val sut = createDefaultWellknownRetriever( + cacheStoreFactory = storeFactory, + enterpriseService = FakeEnterpriseService( + overrideWellKnownResult = { null }, + essConfigEndpointUrlResult = { essConfigUrl }, + ), + urlResolver = resolver, + ) + + sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.ESS_CONFIG) + + storeFactory.assertions().isCalledOnce().with(value("ess_config")) + resolver.assertions().isCalledOnce().with(value(essConfigUrl)) + } + @Test fun `get element wellknown was overridden`() = runTest { val wellKnown = anElementWellKnown() @@ -91,12 +182,12 @@ class DefaultWellknownRetrieverTest { } private fun TestScope.createDefaultWellknownRetriever( - cacheStore: FakeElementWellknownStore = FakeElementWellknownStore(), + cacheStoreFactory: ElementWellknownStore.Factory = { FakeElementWellknownStore() }, elementWellKnownParser: ElementWellKnownParser = { Result.success(expectedElementWellKnown) }, enterpriseService: FakeEnterpriseService = FakeEnterpriseService(overrideWellKnownResult = { null }), urlResolver: UrlContentFetcher = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) }, ) = DefaultWellknownRetriever( - elementWellknownStoreFactory = { cacheStore }, + elementWellknownStoreFactory = cacheStoreFactory, enterpriseService = enterpriseService, elementWellKnownParser = elementWellKnownParser, urlContentFetcher = urlResolver,