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:
@@ -14,13 +14,14 @@ import Testing
|
||||
@MainActor
|
||||
struct AuthenticationServiceTests {
|
||||
var client: ClientSDKMock!
|
||||
var encryption: EncryptionSDKMock!
|
||||
var userSessionStore: UserSessionStoreMock!
|
||||
var encryptionKeyProvider: MockEncryptionKeyProvider!
|
||||
var service: AuthenticationService!
|
||||
|
||||
@Test
|
||||
mutating func passwordLogin() async {
|
||||
setup(serverAddress: "example.com")
|
||||
mutating func passwordLogin() async throws {
|
||||
try await setup(serverAddress: "example.com")
|
||||
|
||||
switch await service.configure(for: "example.com", flow: .login) {
|
||||
case .success:
|
||||
@@ -44,30 +45,20 @@ struct AuthenticationServiceTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
mutating func configureLoginWithOIDC() async {
|
||||
setup()
|
||||
mutating func configureLoginWithOIDC() async throws {
|
||||
try await setup()
|
||||
|
||||
switch await service.configure(for: "matrix.org", flow: .login) {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
Issue.record("Unexpected failure: \(error)")
|
||||
}
|
||||
try await service.configure(for: "matrix.org", flow: .login).get()
|
||||
|
||||
#expect(service.flow == .login)
|
||||
#expect(service.homeserver.value == .mockMatrixDotOrg)
|
||||
}
|
||||
|
||||
@Test
|
||||
mutating func configureRegisterWithOIDC() async {
|
||||
setup()
|
||||
mutating func configureRegisterWithOIDC() async throws {
|
||||
try await setup()
|
||||
|
||||
switch await service.configure(for: "matrix.org", flow: .register) {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
Issue.record("Unexpected failure: \(error)")
|
||||
}
|
||||
try await service.configure(for: "matrix.org", flow: .register).get()
|
||||
|
||||
#expect(service.flow == .register)
|
||||
#expect(service.homeserver.value == .mockMatrixDotOrg)
|
||||
@@ -75,37 +66,101 @@ struct AuthenticationServiceTests {
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
mutating func configureRegisterNoSupport() async {
|
||||
mutating func configureRegisterNoSupport() async throws {
|
||||
let homeserverAddress = "example.com"
|
||||
setup(serverAddress: homeserverAddress)
|
||||
try await setup(serverAddress: homeserverAddress)
|
||||
|
||||
switch await service.configure(for: homeserverAddress, flow: .register) {
|
||||
case .success:
|
||||
Issue.record("Configuration should have failed")
|
||||
case .failure(let error):
|
||||
#expect(error == .registrationNotSupported)
|
||||
try await #require(throws: AuthenticationServiceError.registrationNotSupported) {
|
||||
try await service.configure(for: homeserverAddress, flow: .register).get()
|
||||
}
|
||||
|
||||
#expect(service.flow == .login)
|
||||
#expect(service.homeserver.value == .init(address: "matrix.org", loginMode: .unknown))
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
mutating func classicAppAccountSecretsBundleIsUsed() async throws {
|
||||
// Given an authentication service with an Element Classic account for Alice.
|
||||
try await setup(classicAppAccounts: [.mockAlice])
|
||||
try await service.configure(for: "matrix.org", flow: .login).get()
|
||||
#expect(service.flow == .login)
|
||||
#expect(service.classicAppAccount?.state.availableSecrets == .complete)
|
||||
|
||||
// When logging in as Alice.
|
||||
_ = try await service.login(username: "alice", password: "12345678", initialDeviceName: nil, deviceID: nil).get()
|
||||
#expect(client.loginUsernamePasswordInitialDeviceNameDeviceIdCallsCount == 1)
|
||||
|
||||
// Then Alice's secrets from Element Classic should be imported.
|
||||
#expect(encryption.importSecretsBundleSecretsBundleCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
mutating func classicAppAccountSecretsBundleIsIgnoredWhenUnavailable() async throws {
|
||||
// Given an authentication service with an Element Classic account for Alice
|
||||
// which isn't configured with any available secrets.
|
||||
try await setup(classicAppAccounts: [.mockAlice], availableSecrets: .unavailable)
|
||||
try await service.configure(for: "matrix.org", flow: .login).get()
|
||||
#expect(service.flow == .login)
|
||||
#expect(service.classicAppAccount?.state.availableSecrets == .unavailable)
|
||||
|
||||
// When logging in as Alice.
|
||||
_ = try await service.login(username: "alice", password: "12345678", initialDeviceName: nil, deviceID: nil).get()
|
||||
#expect(client.loginUsernamePasswordInitialDeviceNameDeviceIdCallsCount == 1)
|
||||
|
||||
// Then an attempt to import Alice's secrets from Element Classic must not be made.
|
||||
#expect(!encryption.importSecretsBundleSecretsBundleCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
@MainActor
|
||||
mutating func classicAppAccountSecretsBundleIsIgnoredForDifferentUser() async throws {
|
||||
// Given an authentication service with an Element Classic account for Dan.
|
||||
try await setup(classicAppAccounts: [.mockDan])
|
||||
try await service.configure(for: "matrix.org", flow: .login).get()
|
||||
#expect(service.flow == .login)
|
||||
#expect(service.classicAppAccount?.state.availableSecrets == .complete)
|
||||
|
||||
// When logging in as Alice
|
||||
_ = try await service.login(username: "alice", password: "12345678", initialDeviceName: nil, deviceID: nil).get()
|
||||
#expect(client.loginUsernamePasswordInitialDeviceNameDeviceIdCallsCount == 1)
|
||||
|
||||
// Then Dan's secrets from Element Calssic should not be imported into Alice's client.
|
||||
#expect(!encryption.importSecretsBundleSecretsBundleCalled)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private mutating func setup(serverAddress: String = "matrix.org") {
|
||||
private mutating func setup(serverAddress: String = "matrix.org",
|
||||
classicAppAccounts: [ClassicAppAccount] = [],
|
||||
availableSecrets: ClassicAppAccount.AvailableSecrets = .complete) async throws {
|
||||
let configuration: AuthenticationClientFactoryMock.Configuration = .init()
|
||||
let clientFactory = AuthenticationClientFactoryMock(configuration: configuration)
|
||||
|
||||
client = configuration.homeserverClients[serverAddress]
|
||||
encryption = EncryptionSDKMock()
|
||||
client.encryptionReturnValue = encryption
|
||||
|
||||
userSessionStore = UserSessionStoreMock(configuration: .init())
|
||||
encryptionKeyProvider = MockEncryptionKeyProvider()
|
||||
|
||||
let classicAppManager = ClassicAppManagerMock(.init(accounts: classicAppAccounts,
|
||||
availableSecrets: availableSecrets,
|
||||
secretsBundle: .init(noHandle: .init())))
|
||||
|
||||
service = AuthenticationService(userSessionStore: userSessionStore,
|
||||
encryptionKeyProvider: encryptionKeyProvider,
|
||||
classicAppManager: nil,
|
||||
classicAppManager: classicAppManager,
|
||||
clientFactory: clientFactory,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
appHooks: AppHooks())
|
||||
|
||||
if let classicAppAccount = service.classicAppAccount {
|
||||
await service.setupClassicAppAccountState()
|
||||
try #require(classicAppAccount.state.isServerSupported == true)
|
||||
try #require(classicAppAccount.state.availableSecrets == availableSecrets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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