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.
This commit is contained in:
@@ -15,6 +15,8 @@ import UIKit
|
||||
final class AuthenticationStartScreenViewModelTests {
|
||||
var clientFactory: AuthenticationClientFactoryMock!
|
||||
var client: ClientSDKMock!
|
||||
var classicAppManager: ClassicAppManagerMock?
|
||||
var notificationCenter: NotificationCenter!
|
||||
var appSettings: AppSettings!
|
||||
var authenticationService: AuthenticationServiceProtocol!
|
||||
|
||||
@@ -37,7 +39,7 @@ final class AuthenticationStartScreenViewModelTests {
|
||||
@Test
|
||||
func initialState() async throws {
|
||||
// Given a view model that has no provisioning parameters.
|
||||
setupViewModel()
|
||||
await setupViewModel()
|
||||
#expect(authenticationService.homeserver.value.loginMode == .unknown)
|
||||
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||
|
||||
@@ -64,7 +66,7 @@ final class AuthenticationStartScreenViewModelTests {
|
||||
@Test
|
||||
func provisionedOIDCState() async throws {
|
||||
// Given a view model that has been provisioned with a server that supports OIDC.
|
||||
setupViewModel(provisioningParameters: .init(accountProvider: "company.com", loginHint: "user@company.com"))
|
||||
await setupViewModel(provisioningParameters: .init(accountProvider: "company.com", loginHint: "user@company.com"))
|
||||
#expect(authenticationService.homeserver.value.loginMode == .unknown)
|
||||
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||
|
||||
@@ -84,7 +86,7 @@ final class AuthenticationStartScreenViewModelTests {
|
||||
@Test
|
||||
func provisionedPasswordState() async throws {
|
||||
// Given a view model that has been provisioned with a server that does not support OIDC.
|
||||
setupViewModel(provisioningParameters: .init(accountProvider: "company.com", loginHint: "user@company.com"), supportsOIDC: false)
|
||||
await setupViewModel(provisioningParameters: .init(accountProvider: "company.com", loginHint: "user@company.com"), supportsOIDC: false)
|
||||
#expect(authenticationService.homeserver.value.loginMode == .unknown)
|
||||
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||
|
||||
@@ -103,7 +105,7 @@ final class AuthenticationStartScreenViewModelTests {
|
||||
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"])
|
||||
setupViewModel()
|
||||
await setupViewModel()
|
||||
#expect(authenticationService.homeserver.value.loginMode == .unknown)
|
||||
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||
|
||||
@@ -124,7 +126,7 @@ final class AuthenticationStartScreenViewModelTests {
|
||||
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"])
|
||||
setupViewModel(supportsOIDC: false)
|
||||
await setupViewModel(supportsOIDC: false)
|
||||
#expect(authenticationService.homeserver.value.loginMode == .unknown)
|
||||
#expect(client.urlForOidcOidcConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
|
||||
|
||||
@@ -139,34 +141,202 @@ final class AuthenticationStartScreenViewModelTests {
|
||||
#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(provisioningParameters: AccountProvisioningParameters? = nil, supportsOIDC: Bool = true) {
|
||||
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: true))
|
||||
let configuration = AuthenticationClientFactoryMock.Configuration(homeserverClients: ["company.com": client])
|
||||
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: nil,
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user