Files
letro-ios/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift
Doug 252e2f75df Verify Element X with an existing Element Classic account. (#5374)
* Read and import the secrets from ClassicAppAccounts.

* Record snapshots.

* Add some documentation, tidy up tests and fix the dismissal of the backup instructions.

* Workaround flakey tests (the fulfilments weren't always firing).

* Allow a custom Classic App deep link URL to be configured.
2026-04-13 15:30:09 +01:00

379 lines
21 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 Testing
import UIKit
@MainActor
final class AuthenticationStartScreenViewModelTests {
var clientFactory: AuthenticationClientFactoryMock!
var client: ClientSDKMock!
var classicAppManager: ClassicAppManagerMock?
var notificationCenter: NotificationCenter!
var appSettings: AppSettings!
var authenticationService: AuthenticationServiceProtocol!
var viewModel: AuthenticationStartScreenViewModel!
var context: AuthenticationStartScreenViewModel.Context {
viewModel.context
}
init() {
AppSettings.resetAllSettings()
appSettings = AppSettings()
// These app settings are kept local to the tests on purpose as if they are registered in the
// ServiceLocator, the providers override that we apply will break other tests in the suite.
}
deinit {
AppSettings.resetAllSettings()
}
@Test
func initialState() async throws {
// Given a view model that has no provisioning parameters.
await setupViewModel()
#expect(authenticationService.homeserver.value.loginMode == .unknown)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When tapping any of the buttons on the screen
let actions: [(AuthenticationStartScreenViewAction, AuthenticationStartScreenViewModelAction)] = [
(.loginWithQR, .loginWithQR),
(.login, .login),
(.register, .register),
(.reportProblem, .reportProblem)
]
for action in actions {
let deferred = deferFulfillment(viewModel.actions) { $0 == action.1 }
context.send(viewAction: action.0)
try await deferred.fulfill()
// Then the authentication service should not be used yet.
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
#expect(authenticationService.homeserver.value.loginMode == .unknown)
}
}
@Test
func provisionedOIDCState() async throws {
// Given a view model that has been provisioned with a server that supports OIDC.
await setupViewModel(provisioningParameters: .init(accountProvider: "company.com", loginHint: "user@company.com"))
#expect(authenticationService.homeserver.value.loginMode == .unknown)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When tapping the login button the authentication service should be used and the screen
// should request to continue the flow without any server selection needed.
let deferred = deferFulfillment(viewModel.actions) { $0.isLoginDirectlyWithOIDC }
context.send(viewAction: .login)
try await deferred.fulfill()
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.loginHint == "user@company.com")
#expect(authenticationService.homeserver.value.loginMode == .oidc(supportsCreatePrompt: false))
}
@Test
func provisionedPasswordState() async throws {
// Given a view model that has been provisioned with a server that does not support OIDC.
await setupViewModel(provisioningParameters: .init(accountProvider: "company.com", loginHint: "user@company.com"), supportsOIDC: false)
#expect(authenticationService.homeserver.value.loginMode == .unknown)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When tapping the login button the authentication service should be used and the screen
// should request to continue the flow without any server selection needed.
let deferred = deferFulfillment(viewModel.actions) { $0.isLoginDirectlyWithPassword }
context.send(viewAction: .login)
try await deferred.fulfill()
// Then a call to configure service should be made.
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(authenticationService.homeserver.value.loginMode == .password)
}
@Test
func singleProviderOIDCState() async throws {
// Given a view model that for an app that only allows the use of a single provider that supports OIDC.
setAllowedAccountProviders(["company.com"])
await setupViewModel()
#expect(authenticationService.homeserver.value.loginMode == .unknown)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When tapping the login button the authentication service should be used and the screen
// should request to continue the flow without any server selection needed.
let deferred = deferFulfillment(viewModel.actions) { $0.isLoginDirectlyWithOIDC }
context.send(viewAction: .login)
try await deferred.fulfill()
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.loginHint == nil)
#expect(authenticationService.homeserver.value.loginMode == .oidc(supportsCreatePrompt: false))
}
@Test
func singleProviderPasswordState() async throws {
// Given a view model that for an app that only allows the use of a single provider that does not support OIDC.
setAllowedAccountProviders(["company.com"])
await setupViewModel(supportsOIDC: false)
#expect(authenticationService.homeserver.value.loginMode == .unknown)
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When tapping the login button the authentication service should be used and the screen
// should request to continue the flow without any server selection needed.
let deferred = deferFulfillment(viewModel.actions) { $0.isLoginDirectlyWithPassword }
context.send(viewAction: .login)
try await deferred.fulfill()
// Then a call to configure service should be made.
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(authenticationService.homeserver.value.loginMode == .password)
}
// MARK: - Classic App Account
@Test
func classicAppAccount() async throws {
// Given a view model with a Classic app account whose server name resolves successfully.
let classicAppAccount = makeClassicAppAccount()
await setupViewModel(classicAppAccount: classicAppAccount)
guard case .welcomeBack(let account) = context.viewState.classicAppMode else {
Issue.record("Expected classicAppMode to be .welcomeBack")
return
}
#expect(account == classicAppAccount)
// When continuing with the Classic app account the authentication service should be used and the screen
// should request to continue the flow without any server selection needed.
let deferred = deferFulfillment(viewModel.actions) { $0.isLoginDirectlyWithOIDC }
context.send(viewAction: .continueWithClassic(classicAppAccount))
try await deferred.fulfill()
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksReceivedArguments?.homeserverAddress == "company.com")
#expect(authenticationService.homeserver.value.loginMode == .oidc(supportsCreatePrompt: false))
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.loginHint == "mxid:\(classicAppAccount.userID)")
}
@Test
func classicAppAccountWithoutWellKnown() async throws {
// Given a view model where the Classic app account's server name has no well-known file.
let classicAppAccount = makeClassicAppAccount(serverName: "unknown-server.org",
homeserverURL: "https://matrix.company.com")
await setupViewModel(classicAppAccount: classicAppAccount)
guard case .welcomeBack(let account) = context.viewState.classicAppMode else {
Issue.record("Expected classicAppMode to be .welcomeBack")
return
}
#expect(account == classicAppAccount)
// When continuing with the Classic app account the authentication service should be used with the direct homeserver URL
// and the screen should request to continue the flow without any server selection needed.
let deferred = deferFulfillment(viewModel.actions) { $0.isLoginDirectlyWithOIDC }
context.send(viewAction: .continueWithClassic(classicAppAccount))
try await deferred.fulfill()
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 2)
#expect(clientFactory.makeClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksReceivedArguments?.homeserverAddress == "https://matrix.company.com")
#expect(authenticationService.homeserver.value.loginMode == .oidc(supportsCreatePrompt: false))
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.loginHint == "mxid:\(classicAppAccount.userID)")
}
@Test
func classicAppAccountOnUnsupportedServer() async {
// Given a view model with a Classic app account whose server supports neither OIDC nor password login.
let classicAppAccount = makeClassicAppAccount()
await setupViewModel(classicAppAccount: classicAppAccount, supportsOIDC: false, supportsPasswordLogin: false)
guard case .welcomeBack(let account) = context.viewState.classicAppMode else {
Issue.record("Expected classicAppMode to be .welcomeBack")
return
}
#expect(account == classicAppAccount)
// Then the Classic app account should indicate that it isn't supported (so the view falls back to the standard content).
#expect(account.state.isServerSupported == false)
}
@Test
func classicAppAccountWithProvisioningLink() async {
// Given a view model that has been provisioned with a provisioning link (and a classic account exists).
let classicAppAccount = makeClassicAppAccount()
await setupViewModel(classicAppAccount: classicAppAccount,
provisioningParameters: .init(accountProvider: "company.com", loginHint: nil))
// Then the Classic app account should not be shown provisioning takes precedence.
#expect(context.viewState.classicAppMode == nil)
}
@Test
func singleProviderWithMatchingClassicAppAccount() async {
// Given a view model for an app that only allows a single provider that matches the Classic account's server.
let classicAppAccount = makeClassicAppAccount(serverName: "company.com",
homeserverURL: "https://matrix.company.com")
setAllowedAccountProviders(["company.com"])
await setupViewModel(classicAppAccount: classicAppAccount)
// Then the Classic app account should be shown as a welcome-back option.
guard case .welcomeBack(let account) = context.viewState.classicAppMode else {
Issue.record("Expected classicAppMode to be .welcomeBack")
return
}
#expect(account == classicAppAccount)
}
@Test
func singleProviderWithDisallowedClassicAppAccount() async {
// Given a view model for an app that only allows a single provider that does NOT match the Classic account's server.
let classicAppAccount = makeClassicAppAccount(serverName: "other-server.org",
homeserverURL: "https://matrix.other-server.org")
setAllowedAccountProviders(["company.com"])
await setupViewModel(classicAppAccount: classicAppAccount)
// Then the Classic app account should not be shown since the server is not in the allowed providers.
#expect(context.viewState.classicAppMode == nil)
}
@Test
func classicAppAccountRequiresBackup() async throws {
// Given a view model with a Classic app account that requires backup before signing in.
let classicAppAccount = makeClassicAppAccount()
await setupViewModel(classicAppAccount: classicAppAccount, availableSecrets: .requiresBackup)
guard case .welcomeBack(let account) = context.viewState.classicAppMode else {
Issue.record("Expected classicAppMode to be .welcomeBack")
return
}
#expect(account.state.availableSecrets == .requiresBackup)
// When continuing with the Classic account while backup is required.
var deferred = deferFulfillment(context.observe(\.viewState.bindings.showClassicAppBackupInstructions)) { $0 }
context.send(viewAction: .continueWithClassic(classicAppAccount))
// Then the backup instructions should be shown.
try await deferred.fulfill()
// When the user completes the backup in the Classic app and the app returns to the foreground.
classicAppManager?.availableSecretsForReturnValue = .complete
deferred = deferFulfillment(context.observe(\.viewState.bindings.showClassicAppBackupInstructions)) { !$0 }
notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil)
// Then the backup instructions sheet should be dismissed.
try await deferred.fulfill()
// When the user continues with the Classic account again.
let deferredAction = deferFulfillment(viewModel.actions) { $0.isLoginDirectlyWithOIDC }
context.send(viewAction: .continueWithClassic(classicAppAccount))
// Then the flow should continue the login process.
try await deferredAction.fulfill()
}
// MARK: - Helpers
private func setupViewModel(classicAppAccount: ClassicAppAccount? = nil,
provisioningParameters: AccountProvisioningParameters? = nil,
supportsOIDC: Bool = true,
supportsPasswordLogin: Bool = true,
availableSecrets: ClassicAppAccount.AvailableSecrets = .complete) async {
// Manually create a configuration as the default homeserver address setting is immutable.
client = ClientSDKMock(configuration: .init(oidcLoginURL: supportsOIDC ? "https://account.company.com/authorize" : nil,
supportsOIDCCreatePrompt: false,
supportsPasswordLogin: supportsPasswordLogin))
// Map both the server name and the homeserver URL so fallback lookups work.
let homeserverClients: [String: ClientSDKMock] = ["company.com": client,
"https://matrix.company.com": client]
let configuration = AuthenticationClientFactoryMock.Configuration(homeserverClients: homeserverClients)
if let classicAppAccount {
classicAppManager = ClassicAppManagerMock(.init(accounts: [classicAppAccount], availableSecrets: availableSecrets))
} else {
classicAppManager = nil
}
notificationCenter = NotificationCenter()
clientFactory = AuthenticationClientFactoryMock(configuration: configuration)
authenticationService = AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()),
encryptionKeyProvider: EncryptionKeyProvider(),
classicAppManager: classicAppManager,
clientFactory: clientFactory,
appSettings: appSettings,
appHooks: AppHooks())
await authenticationService.setupClassicAppAccountState()
viewModel = AuthenticationStartScreenViewModel(authenticationService: authenticationService,
provisioningParameters: provisioningParameters,
isBugReportServiceEnabled: true,
appMediator: AppMediatorMock(),
appSettings: appSettings,
mediaProvider: MediaProviderMock(configuration: .init()),
notificationCenter: notificationCenter,
userIndicatorController: UserIndicatorControllerMock())
// Add a fake window in order for the OIDC flow to continue
viewModel.context.send(viewAction: .updateWindow(UIWindow()))
}
private func makeClassicAppAccount(serverName: String = "company.com",
homeserverURL: URL = "https://matrix.company.com") -> ClassicAppAccount {
ClassicAppAccount(userID: "@user:\(serverName)",
displayName: "Classic User",
avatarURL: nil,
serverName: serverName,
homeserverURL: homeserverURL,
cryptoStoreURL: "file:///tmp/crypto-store",
cryptoStorePassphrase: "passphrase",
accessToken: "accessToken")
}
private func setAllowedAccountProviders(_ providers: [String]) {
appSettings.override(accountProviders: providers,
allowOtherAccountProviders: false,
hideBrandChrome: false,
pushGatewayBaseURL: appSettings.pushGatewayBaseURL,
oidcRedirectURL: appSettings.oidcRedirectURL,
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)
}
}
extension AuthenticationStartScreenViewModelAction {
var isLoginDirectlyWithOIDC: Bool {
switch self {
case .loginDirectlyWithOIDC: true
default: false
}
}
var isLoginDirectlyWithPassword: Bool {
switch self {
case .loginDirectlyWithPassword: true
default: false
}
}
}