Test for Oidc

This commit is contained in:
Benoit Marty 2023-04-20 21:15:46 +02:00
parent 9551a5e6f8
commit f8dbd31c11
9 changed files with 365 additions and 24 deletions

View file

@ -51,25 +51,27 @@ class OidcPresenter @AssistedInject constructor(
fun handleCancel() {
requestState = Async.Loading()
localCoroutineScope.launch {
requestState = try {
authenticationService.cancelOidcLogin()
// Then go back
Async.Success(Unit)
} catch (throwable: Throwable) {
Async.Failure(throwable)
}
authenticationService.cancelOidcLogin()
.fold(
onSuccess = {
// Then go back
requestState = Async.Success(Unit)
},
onFailure = {
requestState = Async.Failure(it)
}
)
}
}
fun handleSuccess(url: String) {
requestState = Async.Loading()
localCoroutineScope.launch {
try {
authenticationService.loginWithOidc(url)
// Then the node tree will be updated, there is nothing to do
} catch (throwable: Throwable) {
requestState = Async.Failure(throwable)
}
authenticationService.loginWithOidc(url)
.onFailure {
requestState = Async.Failure(it)
}
// On success, the node tree will be updated, there is nothing to do
}
}

View file

@ -32,7 +32,7 @@ class OidcUrlParser {
* Return a OidcAction, or null if the url is not a OidcUrl
*/
fun parse(url: String): OidcAction? {
if (!url.startsWith(OidcConfig.redirectUri)) return null
if (url.startsWith(OidcConfig.redirectUri).not()) return null
if (url.contains("error=access_denied")) return OidcAction.GoBack
if (url.contains("code=")) return OidcAction.Success(url)

View file

@ -97,10 +97,12 @@ class LoginRootPresenter @Inject constructor(
homeserver: String,
state: MutableState<Async<MatrixHomeServerDetails>>,
) = launch {
state.value = Async.Loading()
suspend {
authenticationService.setHomeserver(homeserver)
authenticationService.getHomeserverDetails().value!!
.map {
authenticationService.getHomeserverDetails().value!!
}
.getOrThrow()
}.execute(state)
}

View file

@ -20,9 +20,9 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_HOMESERVER
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
@ -39,7 +39,7 @@ class ChangeServerPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.homeserver).isEqualTo(A_HOMESERVER_URL)
assertThat(initialState.homeserver).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL)
assertThat(initialState.submitEnabled).isTrue()
}
}

View file

@ -0,0 +1,145 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.login.impl.oidc
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.auth.A_OIDC_DATA
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
class OidcPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = OidcPresenter(
A_OIDC_DATA,
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.oidcDetails).isEqualTo(A_OIDC_DATA)
assertThat(initialState.requestState).isEqualTo(Async.Uninitialized)
}
}
@Test
fun `present - go back`() = runTest {
val presenter = OidcPresenter(
A_OIDC_DATA,
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(OidcEvents.Cancel)
val loadingState = awaitItem()
assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>())
val finalState = awaitItem()
assertThat(finalState.requestState).isEqualTo(Async.Success(Unit))
}
}
@Test
fun `present - go back with failure`() = runTest {
val authenticationService = FakeAuthenticationService()
val presenter = OidcPresenter(
A_OIDC_DATA,
authenticationService,
)
authenticationService.givenOidcCancelError(A_THROWABLE)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(OidcEvents.Cancel)
val loadingState = awaitItem()
assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>())
val finalState = awaitItem()
assertThat(finalState.requestState).isEqualTo(Async.Failure<Unit>(A_THROWABLE))
// Note: in real life I do not think this can happen, and the app should not block the user.
}
}
@Test
fun `present - user cancels from webview`() = runTest {
val presenter = OidcPresenter(
A_OIDC_DATA,
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(OidcEvents.OidcActionEvent(OidcAction.GoBack))
val loadingState = awaitItem()
assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>())
val finalState = awaitItem()
assertThat(finalState.requestState).isEqualTo(Async.Success(Unit))
}
}
@Test
fun `present - login success`() = runTest {
val presenter = OidcPresenter(
A_OIDC_DATA,
FakeAuthenticationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(OidcEvents.OidcActionEvent(OidcAction.Success("A_URL")))
val loadingState = awaitItem()
assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>())
// In this case, no success, the session is created and the node get destroyed.
}
}
@Test
fun `present - login error`() = runTest {
val authenticationService = FakeAuthenticationService()
val presenter = OidcPresenter(
A_OIDC_DATA,
authenticationService,
)
authenticationService.givenLoginError(A_THROWABLE)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(OidcEvents.OidcActionEvent(OidcAction.Success("A_URL")))
val loadingState = awaitItem()
assertThat(loadingState.requestState).isEqualTo(Async.Loading<Unit>())
val errorState = awaitItem()
assertThat(errorState.requestState).isEqualTo(Async.Failure<Unit>(A_THROWABLE))
errorState.eventSink.invoke(OidcEvents.ClearError)
val finalState = awaitItem()
assertThat(finalState.requestState).isEqualTo(Async.Uninitialized)
}
}
}

View file

@ -0,0 +1,59 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.login.impl.oidc
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.auth.OidcConfig
import org.junit.Assert
import org.junit.Test
class OidcUrlParserTest {
@Test
fun `test empty url`() {
val sut = OidcUrlParser()
assertThat(sut.parse("")).isNull()
}
@Test
fun `test regular url`() {
val sut = OidcUrlParser()
assertThat(sut.parse("https://matrix.org")).isNull()
}
@Test
fun `test cancel url`() {
val sut = OidcUrlParser()
val aCancelUrl = OidcConfig.redirectUri + "?error=access_denied&state=IFF1UETGye2ZA8pO"
assertThat(sut.parse(aCancelUrl)).isEqualTo(OidcAction.GoBack)
}
@Test
fun `test success url`() {
val sut = OidcUrlParser()
val aSuccessUrl = OidcConfig.redirectUri + "?state=IFF1UETGye2ZA8pO&code=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB"
assertThat(sut.parse(aSuccessUrl)).isEqualTo(OidcAction.Success(aSuccessUrl))
}
@Test
fun `test unknown url`() {
val sut = OidcUrlParser()
val anUnknownUrl = OidcConfig.redirectUri + "?state=IFF1UETGye2ZA8pO&goat=y6X1GZeqA3xxOWcTeShgv8nkgFJXyzWB"
Assert.assertThrows(IllegalStateException::class.java) {
assertThat(sut.parse(anUnknownUrl))
}
}
}

View file

@ -20,11 +20,16 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.test.A_HOMESERVER
import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC
import io.element.android.libraries.matrix.test.A_PASSWORD
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.auth.A_OIDC_DATA
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -39,18 +44,79 @@ class LoginRootPresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.homeserverDetails).isEqualTo(A_HOMESERVER)
assertThat(initialState.homeserverUrl).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL)
assertThat(initialState.homeserverDetails).isEqualTo(Async.Uninitialized)
assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
assertThat(initialState.submitEnabled).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - initial state server load`() = runTest {
val authenticationService = FakeAuthenticationService()
val presenter = LoginRootPresenter(
authenticationService,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.homeserverUrl).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL)
assertThat(initialState.homeserverDetails).isEqualTo(Async.Uninitialized)
assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
assertThat(initialState.submitEnabled).isFalse()
val loadingState = awaitItem()
assertThat(loadingState.homeserverDetails).isEqualTo(Async.Loading<MatrixHomeServerDetails>())
authenticationService.givenHomeserver(A_HOMESERVER)
skipItems(1)
val loadedState = awaitItem()
assertThat(loadedState.homeserverDetails).isEqualTo(Async.Success(A_HOMESERVER))
}
}
@Test
fun `present - initial state server load error and retry`() = runTest {
val authenticationService = FakeAuthenticationService()
val presenter = LoginRootPresenter(
authenticationService,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.homeserverUrl).isEqualTo(LoginConstants.DEFAULT_HOMESERVER_URL)
assertThat(initialState.homeserverDetails).isEqualTo(Async.Uninitialized)
assertThat(initialState.loggedInState).isEqualTo(LoggedInState.NotLoggedIn)
assertThat(initialState.formState).isEqualTo(LoginFormState.Default)
assertThat(initialState.submitEnabled).isFalse()
val loadingState = awaitItem()
assertThat(loadingState.homeserverDetails).isEqualTo(Async.Loading<MatrixHomeServerDetails>())
val aThrowable = Throwable("Error")
authenticationService.givenChangeServerError(aThrowable)
val errorState = awaitItem()
assertThat(errorState.homeserverDetails).isEqualTo(Async.Failure<MatrixHomeServerDetails>(aThrowable))
// Retry
errorState.eventSink.invoke(LoginRootEvents.RetryFetchServerInfo)
val loadingState2 = awaitItem()
assertThat(loadingState2.homeserverDetails).isEqualTo(Async.Loading<MatrixHomeServerDetails>())
authenticationService.givenChangeServerError(null)
authenticationService.givenHomeserver(A_HOMESERVER)
skipItems(1)
val loadedState = awaitItem()
assertThat(loadedState.homeserverDetails).isEqualTo(Async.Success(A_HOMESERVER))
}
}
@Test
fun `present - enter login and password`() = runTest {
val authenticationService = FakeAuthenticationService()
val presenter = LoginRootPresenter(
FakeAuthenticationService(),
authenticationService,
)
authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -67,10 +133,49 @@ class LoginRootPresenterTest {
}
@Test
fun `present - submit`() = runTest {
fun `present - oidc login`() = runTest {
val authenticationService = FakeAuthenticationService()
val presenter = LoginRootPresenter(
FakeAuthenticationService(),
authenticationService,
)
authenticationService.givenHomeserver(A_HOMESERVER_OIDC)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.submitEnabled).isTrue()
initialState.eventSink.invoke(LoginRootEvents.Submit)
val oidcState = awaitItem()
assertThat(oidcState.loggedInState).isEqualTo(LoggedInState.OidcStarted(A_OIDC_DATA))
}
}
@Test
fun `present - oidc login error`() = runTest {
val authenticationService = FakeAuthenticationService()
val presenter = LoginRootPresenter(
authenticationService,
)
authenticationService.givenHomeserver(A_HOMESERVER_OIDC)
authenticationService.givenOidcError(A_THROWABLE)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.submitEnabled).isTrue()
initialState.eventSink.invoke(LoginRootEvents.Submit)
val oidcState = awaitItem()
assertThat(oidcState.loggedInState).isEqualTo(LoggedInState.ErrorLoggingIn(A_THROWABLE))
}
}
@Test
fun `present - submit`() = runTest {
val authenticationService = FakeAuthenticationService()
val presenter = LoginRootPresenter(
authenticationService,
)
authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -93,6 +198,7 @@ class LoginRootPresenterTest {
val presenter = LoginRootPresenter(
authenticationService,
)
authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -116,11 +222,11 @@ class LoginRootPresenterTest {
val presenter = LoginRootPresenter(
authenticationService,
)
authenticationService.givenHomeserver(A_HOMESERVER)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Submit will return an error
authenticationService.givenLoginError(A_THROWABLE)
initialState.eventSink(LoginRootEvents.Submit)

View file

@ -47,7 +47,8 @@ const val ANOTHER_MESSAGE = "Hello universe!"
const val A_HOMESERVER_URL = "matrix.org"
const val A_HOMESERVER_URL_2 = "matrix-client.org"
val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, true, null)
val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = true, supportsOidc = false)
val A_HOMESERVER_OIDC = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = false, supportsOidc = true)
const val AN_AVATAR_URL = "mxc://data"

View file

@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.test.auth
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_USER_ID
import kotlinx.coroutines.delay
@ -27,8 +28,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOf
val A_OIDC_DATA = OidcDetails(url = "a-url")
class FakeAuthenticationService : MatrixAuthenticationService {
private var homeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
private var oidcError: Throwable? = null
private var oidcCancelError: Throwable? = null
private var loginError: Throwable? = null
private var changeServerError: Throwable? = null
@ -62,6 +67,27 @@ class FakeAuthenticationService : MatrixAuthenticationService {
return loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID)
}
override suspend fun getOidcUrl(): Result<OidcDetails> {
return oidcError?.let { Result.failure(it) } ?: Result.success(A_OIDC_DATA)
}
override suspend fun cancelOidcLogin(): Result<Unit> {
return oidcCancelError?.let { Result.failure(it) } ?: Result.success(Unit)
}
override suspend fun loginWithOidc(callbackUrl: String): Result<SessionId> {
delay(100)
return loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID)
}
fun givenOidcError(throwable: Throwable?) {
oidcError = throwable
}
fun givenOidcCancelError(throwable: Throwable?) {
oidcCancelError = throwable
}
fun givenLoginError(throwable: Throwable?) {
loginError = throwable
}