#40: Add basic AuthenticationService and missing UI tests.

* Add MockAuthenticationService and ServerSelectionUITests.
* Add tests covering the Authentication flow.
This commit is contained in:
Doug
2022-07-04 10:00:27 +01:00
committed by GitHub
parent 0a199ee611
commit 26d8affaa8
29 changed files with 641 additions and 203 deletions
@@ -6,28 +6,18 @@
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import UIKit
import MatrixRustSDK
enum AuthenticationCoordinatorError: Error {
case failedLoggingIn
}
@MainActor
protocol AuthenticationCoordinatorDelegate: AnyObject {
func authenticationCoordinatorDidStartLoading(_ authenticationCoordinator: AuthenticationCoordinator)
func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator,
didLoginWithSession userSession: UserSessionProtocol)
func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator,
didFailWithError error: AuthenticationCoordinatorError)
}
class AuthenticationCoordinator: Coordinator {
class AuthenticationCoordinator: Coordinator, Presentable {
private let userSessionStore: UserSessionStoreProtocol
private let authenticationService: AuthenticationServiceProtocol
private let navigationRouter: NavigationRouter
private(set) var clientProxy: ClientProxyProtocol?
@@ -35,9 +25,9 @@ class AuthenticationCoordinator: Coordinator {
weak var delegate: AuthenticationCoordinatorDelegate?
init(userSessionStore: UserSessionStoreProtocol,
init(authenticationService: AuthenticationServiceProtocol,
navigationRouter: NavigationRouter) {
self.userSessionStore = userSessionStore
self.authenticationService = authenticationService
self.navigationRouter = navigationRouter
}
@@ -45,6 +35,10 @@ class AuthenticationCoordinator: Coordinator {
showSplashScreen()
}
func toPresentable() -> UIViewController {
navigationRouter.toPresentable()
}
// MARK: - Private
private func showSplashScreen() {
@@ -67,28 +61,18 @@ class AuthenticationCoordinator: Coordinator {
}
private func showLoginScreen() {
let homeserver = LoginHomeserver(address: BuildSettings.defaultHomeserverURLString)
let parameters = LoginCoordinatorParameters(navigationRouter: navigationRouter, homeserver: homeserver)
let parameters = LoginCoordinatorParameters(authenticationService: authenticationService,
navigationRouter: navigationRouter)
let coordinator = LoginCoordinator(parameters: parameters)
coordinator.callback = { [weak self, weak coordinator] action in
guard let self = self, let coordinator = coordinator else {
return
}
guard let self = self, let coordinator = coordinator else { return }
switch action {
case .login(let username, let password):
Task {
switch await self.login(username: username, password: password) {
case .success(let userSession):
self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
self.remove(childCoordinator: coordinator)
self.navigationRouter.dismissModule()
case .failure(let error):
self.delegate?.authenticationCoordinator(self, didFailWithError: error)
MXLog.error("Failed logging in user with error: \(error)")
}
}
case .signedIn(let userSession):
self.delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
self.remove(childCoordinator: coordinator)
self.navigationRouter.dismissModule()
case .continueWithOIDC:
break
}
@@ -101,38 +85,4 @@ class AuthenticationCoordinator: Coordinator {
self?.remove(childCoordinator: coordinator)
}
}
private func login(username: String, password: String) async -> Result<UserSession, AuthenticationCoordinatorError> {
Benchmark.startTrackingForIdentifier("Login", message: "Started new login")
delegate?.authenticationCoordinatorDidStartLoading(self)
let basePath = userSessionStore.baseDirectoryPath(for: username)
let builder = ClientBuilder()
.basePath(path: basePath)
.username(username: username)
let loginTask: Task<Client, Error> = Task.detached {
let client = try builder.build()
try client.login(username: username, password: password)
return client
}
switch await loginTask.result {
case .success(let client):
return await userSession(for: client)
case .failure(let error):
MXLog.error("Failed logging in with error: \(error)")
return .failure(.failedLoggingIn)
}
}
private func userSession(for client: Client) async -> Result<UserSession, AuthenticationCoordinatorError> {
switch await userSessionStore.userSession(for: client) {
case .success(let clientProxy):
return .success(clientProxy)
case .failure:
return .failure(.failedLoggingIn)
}
}
}
@@ -18,26 +18,17 @@ import SwiftUI
import MatrixRustSDK
struct LoginCoordinatorParameters {
/// The service used to authenticate the user.
let authenticationService: AuthenticationServiceProtocol
/// The navigation router used to present the server selection screen.
let navigationRouter: NavigationRouterType
/// The homeserver to be shown initially.
let homeserver: LoginHomeserver
}
enum LoginCoordinatorAction: CustomStringConvertible {
/// Login with the associated username and password.
case login(username: String, password: String)
enum LoginCoordinatorAction {
/// Login was successful.
case signedIn(UserSessionProtocol)
/// Continue using OIDC.
case continueWithOIDC
/// A string representation of the action, ignoring any associated values that could leak PII.
var description: String {
switch self {
case .login:
return "login"
case .continueWithOIDC:
return "continueWithOIDC"
}
}
}
final class LoginCoordinator: Coordinator, Presentable {
@@ -56,6 +47,7 @@ final class LoginCoordinator: Coordinator, Presentable {
}
}
private var authenticationService: AuthenticationServiceProtocol { parameters.authenticationService }
private var navigationRouter: NavigationRouterType { parameters.navigationRouter }
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var activityIndicator: UserIndicator?
@@ -71,7 +63,7 @@ final class LoginCoordinator: Coordinator, Presentable {
init(parameters: LoginCoordinatorParameters) {
self.parameters = parameters
let viewModel = LoginViewModel(homeserver: parameters.homeserver)
let viewModel = LoginViewModel(homeserver: parameters.authenticationService.homeserver)
loginViewModel = viewModel
let view = LoginScreen(context: viewModel.context)
@@ -135,56 +127,68 @@ final class LoginCoordinator: Coordinator, Presentable {
}
/// Processes an error to either update the flow or display it to the user.
private func handleError(_ error: Error) {
loginViewModel.displayError(.alert(error.localizedDescription))
private func handleError(_ error: AuthenticationServiceError) {
switch error {
case .invalidCredentials:
loginViewModel.displayError(.alert(ElementL10n.authInvalidLoginParam))
case .accountDeactivated:
loginViewModel.displayError(.alert(ElementL10n.authInvalidLoginDeactivatedAccount))
default:
loginViewModel.displayError(.alert(ElementL10n.unknownError))
}
}
/// Requests the authentication coordinator to log in using the specified credentials.
private func login(username: String, password: String) {
var username = loginViewModel.context.username
startLoading(isInteractionBlocking: true)
if !isMXID(username: username) {
let homeserver = loginViewModel.context.viewState.homeserver
username = "@\(username):\(homeserver.address)"
Task {
switch await authenticationService.login(username: username, password: password) {
case .success(let userSession):
callback?(.signedIn(userSession))
stopLoading()
case .failure(let error):
stopLoading()
handleError(error)
}
}
callback?(.login(username: username, password: password))
}
/// Parses the specified username and looks up the homeserver when a Matrix ID is entered.
private func parseUsername(_ username: String) {
guard isMXID(username: username) else { return }
guard username.isMatrixUserID else { return }
let domain = String(username.split(separator: ":")[1])
let homeserverDomain = String(username.split(separator: ":")[1])
let homeserver = LoginHomeserver(address: domain)
updateViewModel(homeserver: homeserver)
indicateSuccess()
}
/// Checks whether the specified username is a Matrix ID or not.
private func isMXID(username: String) -> Bool {
let range = NSRange(location: 0, length: username.count)
startLoading(isInteractionBlocking: false)
let detector = try? NSRegularExpression(pattern: MatrixEntityRegex.userId.rawValue, options: .caseInsensitive)
return detector?.numberOfMatches(in: username, range: range) ?? 0 > 0
Task {
switch await authenticationService.startLogin(for: homeserverDomain) {
case .success:
updateViewModel()
stopLoading()
case .failure(let error):
stopLoading()
handleError(error)
}
}
}
/// Updates the view model with a different homeserver.
private func updateViewModel(homeserver: LoginHomeserver) {
loginViewModel.update(homeserver: homeserver)
private func updateViewModel() {
loginViewModel.update(homeserver: authenticationService.homeserver)
indicateSuccess()
}
/// Presents the server selection screen as a modal.
private func presentServerSelectionScreen() {
MXLog.debug("[LoginCoordinator] presentServerSelectionScreen")
let parameters = ServerSelectionCoordinatorParameters(homeserver: loginViewModel.context.viewState.homeserver,
let parameters = ServerSelectionCoordinatorParameters(authenticationService: authenticationService,
hasModalPresentation: true)
let coordinator = ServerSelectionCoordinator(parameters: parameters)
coordinator.callback = { [weak self, weak coordinator] result in
coordinator.callback = { [weak self, weak coordinator] action in
guard let self = self, let coordinator = coordinator else { return }
self.serverSelectionCoordinator(coordinator, didCompleteWith: result)
self.serverSelectionCoordinator(coordinator, didCompleteWith: action)
}
coordinator.start()
@@ -198,10 +202,10 @@ final class LoginCoordinator: Coordinator, Presentable {
/// Handles the result from the server selection modal, dismissing it after updating the view.
private func serverSelectionCoordinator(_ coordinator: ServerSelectionCoordinator,
didCompleteWith result: ServerSelectionCoordinatorResult) {
didCompleteWith action: ServerSelectionCoordinatorAction) {
navigationRouter.dismissModule(animated: true) { [weak self] in
if case let .selected(homeserver) = result {
self?.updateViewModel(homeserver: homeserver)
if action == .updated {
self?.updateViewModel()
}
self?.remove(childCoordinator: coordinator)
@@ -58,6 +58,7 @@ struct LoginServerInfoSection: View {
.padding(.vertical, 2)
}
.buttonStyle(.elementGhost())
.accessibilityIdentifier("editServerButton")
}
}
}
@@ -17,14 +17,14 @@
import SwiftUI
struct ServerSelectionCoordinatorParameters {
/// The homeserver to be shown initially.
let homeserver: LoginHomeserver
/// The service used to authenticate the user.
let authenticationService: AuthenticationServiceProtocol
/// Whether the screen is presented modally or within a navigation stack.
let hasModalPresentation: Bool
}
enum ServerSelectionCoordinatorResult {
case selected(LoginHomeserver)
enum ServerSelectionCoordinatorAction {
case updated
case dismiss
}
@@ -38,6 +38,7 @@ final class ServerSelectionCoordinator: Coordinator, Presentable {
private let serverSelectionHostingController: UIViewController
private var serverSelectionViewModel: ServerSelectionViewModelProtocol
private var authenticationService: AuthenticationServiceProtocol { parameters.authenticationService }
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
@@ -45,14 +46,14 @@ final class ServerSelectionCoordinator: Coordinator, Presentable {
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: (@MainActor (ServerSelectionCoordinatorResult) -> Void)?
var callback: (@MainActor (ServerSelectionCoordinatorAction) -> Void)?
// MARK: - Setup
init(parameters: ServerSelectionCoordinatorParameters) {
self.parameters = parameters
let viewModel = ServerSelectionViewModel(homeserverAddress: parameters.homeserver.address,
let viewModel = ServerSelectionViewModel(homeserverAddress: parameters.authenticationService.homeserver.address,
hasModalPresentation: parameters.hasModalPresentation)
let view = ServerSelectionScreen(context: viewModel.context)
serverSelectionViewModel = viewModel
@@ -66,11 +67,11 @@ final class ServerSelectionCoordinator: Coordinator, Presentable {
func start() {
MXLog.debug("[ServerSelectionCoordinator] did start.")
serverSelectionViewModel.callback = { [weak self] result in
serverSelectionViewModel.callback = { [weak self] action in
guard let self = self else { return }
MXLog.debug("[ServerSelectionCoordinator] ServerSelectionViewModel did complete with result: \(result).")
MXLog.debug("[ServerSelectionCoordinator] ServerSelectionViewModel did callback with action: \(action).")
switch result {
switch action {
case .confirm(let homeserverAddress):
self.useHomeserver(homeserverAddress)
case .dismiss:
@@ -102,9 +103,25 @@ final class ServerSelectionCoordinator: Coordinator, Presentable {
private func useHomeserver(_ homeserverAddress: String) {
startLoading()
let homeserverAddress = LoginHomeserver.sanitized(homeserverAddress)
stopLoading()
callback?(.selected(LoginHomeserver(address: homeserverAddress)))
Task {
switch await authenticationService.startLogin(for: homeserverAddress) {
case .success:
callback?(.updated)
stopLoading()
case .failure(let error):
stopLoading()
handleError(error)
}
}
}
/// Processes an error to either update the flow or display it to the user.
private func handleError(_ error: AuthenticationServiceError) {
switch error {
case .invalidServer:
serverSelectionViewModel.displayError(.footerMessage(ElementL10n.loginErrorHomeserverNotFound))
default:
serverSelectionViewModel.displayError(.footerMessage(ElementL10n.unknownError))
}
}
}
@@ -18,7 +18,7 @@ import Foundation
// MARK: View model
enum ServerSelectionViewModelResult {
enum ServerSelectionViewModelAction {
/// The user would like to use the homeserver at the given address.
case confirm(homeserverAddress: String)
/// Dismiss the view without using the entered address.
@@ -26,7 +26,7 @@ class ServerSelectionViewModel: ServerSelectionViewModelType, ServerSelectionVie
// MARK: Public
var callback: (@MainActor (ServerSelectionViewModelResult) -> Void)?
var callback: (@MainActor (ServerSelectionViewModelAction) -> Void)?
// MARK: - Setup
@@ -19,7 +19,7 @@ import Foundation
@MainActor
protocol ServerSelectionViewModelProtocol {
var callback: (@MainActor (ServerSelectionViewModelResult) -> Void)? { get set }
var callback: (@MainActor (ServerSelectionViewModelAction) -> Void)? { get set }
var context: ServerSelectionViewModelType.Context { get }
/// Displays an error to the user.