Files
element-x-ios/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift
T
Michael 22d14ff8e4 AppSettings are Mockable (#5589)
* create DependenciesProtocol and replace ServiceLocator registration with it

replace mocking calls for ServiceLocator to Dependencies

replace ServiceLocator with instance level calls

replace ServiceLocator configs with TestDependencies config in Tests

replace servicelocator usage in analytics service

replace remaining ServiceLocator calls in unit tests

that depended on the UnitTestAppCoordinator configuration with per suite dependencies instances

fix swiftlint warning

remove remaining references to ServiceLocator

make dependencies properties consistent in tests

delete Dependencies and DependenciesProtocol

fix whitespace modifications

updates from PR feedback

a pass over converting AppSettings and AnalyticsService test instance properties to per test instantiation

fix a missed PR feedback

Sync up after rebase

create DependenciesProtocol and replace ServiceLocator registration with it

replace mocking calls for ServiceLocator to Dependencies

replace ServiceLocator with instance level calls

replace ServiceLocator configs with TestDependencies config in Tests

replace servicelocator usage in analytics service

replace remaining ServiceLocator calls in unit tests

that depended on the UnitTestAppCoordinator configuration with per suite dependencies instances

fix swiftlint warning

remove remaining references to ServiceLocator

make dependencies properties consistent in tests

delete Dependencies and DependenciesProtocol

* AppSettings is instance level mockable now

* replace mocked and preview usages of reset + AppSettings global init with

AppSettings(store: UserDefaultsMock())

* AppSettings.UserDefaultsKeys is now a RawRep struct, making it extensible

* update tests with new AppSettings so it builds

* update remaining AppSettings test/mock inits

* AppSettings.UserDefaultsKey conformed to ExpressibleByStringLiteral

* addressed issues found in first pass ai review

* update AppSettings instantiations after rebase

* fix accessibility tests coordinator after rebase

* Rename to VolatileUserDefaults

* AppSettings init uses mandatory parameter

* Revert UserDefaultsKey to enum

* Add TrackedUserDefaults to be able to reset the defaults object

* Added volatile static func to AppSettings

* Use .volatile() static func instead of full init

* review fixups

* Resolve build issues after rebase

* updated enterprise module

* remove the static reset settings call from the preview tests

---------

Co-authored-by: Mauro Romito <mauro.romito@element.io>
2026-05-22 15:14:08 +02:00

398 lines
23 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
@testable import ElementX
import MatrixRustSDKMocks
import SwiftUI
import Testing
@MainActor
final class ServerConfirmationScreenViewModelTests {
var clientFactory: AuthenticationClientFactoryMock!
var client: ClientSDKMock!
var service: AuthenticationServiceProtocol!
var viewModel: ServerConfirmationScreenViewModel!
var context: ServerConfirmationScreenViewModel.Context {
viewModel.context
}
private let appSettings: AppSettings
init() {
appSettings = AppSettings.volatile()
}
// MARK: - Confirmation mode
@Test
func confirmLoginWithoutConfiguration() async throws {
// Given a view model for login using a service that hasn't been configured.
setupViewModel(authenticationFlow: .login)
#expect(service.homeserver.value.loginMode == .unknown)
#expect(context.viewState.mode == .confirmation(service.homeserver.value.address))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth }
context.send(viewAction: .confirm)
try await deferred.fulfill()
// Then a call to configure service should be made.
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent)
#expect(service.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: true))
}
@Test
func confirmLoginAfterConfiguration() async throws {
// Given a view model for login using a service that has already been configured (via the server selection screen).
setupViewModel(authenticationFlow: .login)
guard case .success = await service.configure(for: viewModel.state.homeserverAddress, flow: .login) else {
Issue.record("The configuration should succeed.")
return
}
#expect(service.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: true))
#expect(context.viewState.mode == .confirmation(service.homeserver.value.address))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth }
context.send(viewAction: .confirm)
try await deferred.fulfill()
// Then the configured homeserver should be used and no additional client should be built.
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent)
}
@Test
func confirmRegisterWithoutConfiguration() async throws {
// Given a view model for registration using a service that hasn't been configured.
setupViewModel(authenticationFlow: .register)
#expect(service.homeserver.value.loginMode == .unknown)
#expect(context.viewState.mode == .confirmation(service.homeserver.value.address))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth }
context.send(viewAction: .confirm)
try await deferred.fulfill()
// Then a call to configure service should be made.
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
// The create prompt is broken: https://github.com/element-hq/matrix-authentication-service/issues/3429
// #expect(client.urlForOauthOauthConfigurationPromptReceivedArguments?.prompt == .create)
#expect(service.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: true))
}
@Test
func confirmRegisterAfterConfiguration() async throws {
// Given a view model for registration using a service that has already been configured (via the server selection screen).
setupViewModel(authenticationFlow: .register)
guard case .success = await service.configure(for: viewModel.state.homeserverAddress, flow: .register) else {
Issue.record("The configuration should succeed.")
return
}
#expect(service.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: true))
#expect(context.viewState.mode == .confirmation(service.homeserver.value.address))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth }
context.send(viewAction: .confirm)
try await deferred.fulfill()
// Then the configured homeserver should be used and no additional client should be built.
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
// The create prompt is broken: https://github.com/element-hq/matrix-authentication-service/issues/3429
// #expect(client.urlForOauthOauthConfigurationPromptReceivedArguments?.prompt == .create)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
}
@Test
func confirmPasswordLoginWithoutConfiguration() async throws {
// Given a view model for login using a service that hasn't been configured (against a server that doesn't support OAuth).
setupViewModel(authenticationFlow: .login, supportsOAuth: false)
#expect(service.homeserver.value.loginMode == .unknown)
#expect(context.viewState.mode == .confirmation(service.homeserver.value.address))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
context.send(viewAction: .confirm)
try await deferred.fulfill()
// Then a call to configure service should be made, but not for the OAuth URL.
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
#expect(service.homeserver.value.loginMode == .password)
}
@Test
func confirmPasswordLoginAfterConfiguration() async throws {
// Given a view model for login using a service that has already been configured (via the server selection screen).
setupViewModel(authenticationFlow: .login, supportsOAuth: false)
guard case .success = await service.configure(for: viewModel.state.homeserverAddress, flow: .login) else {
Issue.record("The configuration should succeed.")
return
}
#expect(service.homeserver.value.loginMode == .password)
#expect(context.viewState.mode == .confirmation(service.homeserver.value.address))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
context.send(viewAction: .confirm)
try await deferred.fulfill()
// Then the configured homeserver should be used and no additional client should be built, nor a call to get the OAuth URL.
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
}
@Test
func registrationNotSupportedAlert() async throws {
// Given a view model for registration using a service that hasn't been configured and the default server doesn't support registration.
// Note: We don't currently take the create prompt into account when determining registration support.
setupViewModel(authenticationFlow: .register, supportsOAuth: false, supportsOAuthCreatePrompt: false)
#expect(service.homeserver.value.loginMode == .unknown)
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(context.alertInfo == nil)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil }
context.send(viewAction: .confirm)
try await deferred.fulfill()
// Then the configuration should fail with an alert about not supporting registration.
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(context.alertInfo?.id == .registration)
}
@Test
func loginNotSupportedAlert() async throws {
// Given a view model for login using a service that hasn't been configured and the default server doesn't support login.
setupViewModel(authenticationFlow: .login, supportsOAuth: false, supportsOAuthCreatePrompt: false, supportsPasswordLogin: false)
#expect(service.homeserver.value.loginMode == .unknown)
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(context.alertInfo == nil)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil }
context.send(viewAction: .confirm)
try await deferred.fulfill()
// Then the configuration should fail with an alert about not supporting login.
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(context.alertInfo?.id == .login)
}
@Test
func elementProRequired() async throws {
// Given a view model for login using a service that hasn't been configured and the default server requires Element Pro.
setupViewModel(authenticationFlow: .login, supportsOAuth: false, supportsOAuthCreatePrompt: false, supportsPasswordLogin: false, requiresElementPro: true)
#expect(service.homeserver.value.loginMode == .unknown)
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(context.alertInfo == nil)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil }
context.send(viewAction: .confirm)
try await deferred.fulfill()
// Then the configuration should fail with an alert telling the user to download Element Pro.
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(context.alertInfo?.id == .elementProRequired(serverName: "matrix.org"))
}
// MARK: - Picker mode
@Test
func pickerWithoutConfiguration() async throws {
// Given a view model for login using a service that hasn't been configured.
setupViewModel(authenticationFlow: .login, restrictedFlow: true)
#expect(service.homeserver.value.loginMode == .unknown)
#expect(context.viewState.mode == .picker(appSettings.accountProviders))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth }
context.send(viewAction: .confirm)
try await deferred.fulfill()
// Then a call to configure service should be made.
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent)
#expect(service.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: true))
}
@Test
func pickerAfterConfiguration() async throws {
// Given a view model for login using a service that has already been configured (via the server selection screen).
setupViewModel(authenticationFlow: .login, restrictedFlow: true)
guard case .success = await service.configure(for: appSettings.accountProviders[0], flow: .login) else {
Issue.record("The configuration should succeed.")
return
}
#expect(service.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: true))
#expect(context.viewState.mode == .picker(appSettings.accountProviders))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth }
context.send(viewAction: .confirm)
try await deferred.fulfill()
// Then the configured homeserver should be used and no additional client should be built.
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent)
}
@Test
func pickerForPasswordLoginWithoutConfiguration() async throws {
// Given a view model for login using a service that hasn't been configured (against a server that doesn't support OAuth).
setupViewModel(authenticationFlow: .login, supportsOAuth: false, restrictedFlow: true)
#expect(service.homeserver.value.loginMode == .unknown)
#expect(context.viewState.mode == .picker(appSettings.accountProviders))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
context.send(viewAction: .confirm)
try await deferred.fulfill()
// Then a call to configure service should be made, but not for the OAuth URL.
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
#expect(service.homeserver.value.loginMode == .password)
}
@Test
func pickerForPasswordLoginAfterConfiguration() async throws {
// Given a view model for login using a service that has already been configured (via the server selection screen).
setupViewModel(authenticationFlow: .login, supportsOAuth: false, restrictedFlow: true)
guard case .success = await service.configure(for: appSettings.accountProviders[0], flow: .login) else {
Issue.record("The configuration should succeed.")
return
}
#expect(service.homeserver.value.loginMode == .password)
#expect(context.viewState.mode == .picker(appSettings.accountProviders))
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
context.send(viewAction: .confirm)
try await deferred.fulfill()
// Then the configured homeserver should be used and no additional client should be built, nor a call to get the OAuth URL.
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
}
// MARK: - Helpers
private func setupViewModel(authenticationFlow: AuthenticationFlow,
supportsOAuth: Bool = true,
supportsOAuthCreatePrompt: Bool = true,
supportsPasswordLogin: Bool = true,
restrictedFlow: Bool = false,
requiresElementPro: Bool = false) {
var mode = ServerConfirmationScreenMode.confirmation("matrix.org")
if restrictedFlow {
appSettings.override(accountProviders: ["matrix.org", "beta.matrix.org"],
allowOtherAccountProviders: false,
hideBrandChrome: false,
pushGatewayBaseURL: appSettings.pushGatewayBaseURL,
oAuthRedirectURL: appSettings.oAuthRedirectURL,
websiteURL: appSettings.websiteURL,
logoURL: appSettings.logoURL,
copyrightURL: appSettings.copyrightURL,
acceptableUseURL: appSettings.acceptableUseURL,
privacyURL: appSettings.privacyURL,
encryptionURL: appSettings.encryptionURL,
deviceVerificationURL: appSettings.deviceVerificationURL,
chatBackupDetailsURL: appSettings.chatBackupDetailsURL,
identityPinningViolationDetailsURL: appSettings.identityPinningViolationDetailsURL,
historySharingDetailsURL: appSettings.historySharingDetailsURL,
elementWebHosts: appSettings.elementWebHosts,
accountProvisioningHost: appSettings.accountProvisioningHost,
bugReportApplicationID: appSettings.bugReportApplicationID,
analyticsTermsURL: appSettings.analyticsTermsURL,
mapTilerConfiguration: appSettings.mapTilerConfiguration)
mode = .picker(appSettings.accountProviders)
}
// Manually create a configuration as the default homeserver address setting is immutable.
client = ClientSDKMock(configuration: .init(oAuthLoginURL: supportsOAuth ? "https://account.matrix.org/authorize" : nil,
supportsOAuthCreatePrompt: supportsOAuthCreatePrompt,
supportsPasswordLogin: supportsPasswordLogin,
elementWellKnown: requiresElementPro ? "{\"version\":1,\"enforce_element_pro\":true}" : nil))
let configuration = AuthenticationClientFactoryMock.Configuration(homeserverClients: ["matrix.org": client])
clientFactory = AuthenticationClientFactoryMock(configuration: configuration)
service = AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()),
encryptionKeyProvider: EncryptionKeyProvider(),
classicAppManager: nil,
clientFactory: clientFactory,
appSettings: appSettings,
appHooks: AppHooks())
viewModel = ServerConfirmationScreenViewModel(authenticationService: service,
mode: mode,
authenticationFlow: authenticationFlow,
appSettings: appSettings,
userIndicatorController: UserIndicatorControllerMock())
// Add a fake window in order for the OAuth flow to continue
viewModel.context.send(viewAction: .updateWindow(UIWindow()))
}
}
private extension ServerConfirmationScreenViewState {
var homeserverAddress: String {
switch mode {
case .confirmation(let accountProvider):
accountProvider
case .picker:
fatalError()
}
}
}
private extension ServerConfirmationScreenViewModelAction {
var isContinueWithOAuth: Bool {
switch self {
case .continueWithOAuth: true
default: false
}
}
var isContinueWithPassword: Bool {
switch self {
case .continueWithPassword: true
default: false
}
}
}