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:
Doug
2026-04-13 15:30:09 +01:00
committed by GitHub
parent f146ba835d
commit 252e2f75df
40 changed files with 947 additions and 103 deletions

View File

@@ -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,