Add an AccountProvider type and use it within the authentication flow. (#6103)

* Add an AccountProvider type and use it within AppSettings.

* Use the AccountProvider type within the Authentication flow too.

* Fix unit test compilation with Swift 6.4

* Make sure defaultAccountProvider takes allowOtherAccountProviders into account.

* Document a caveat on using AuthenticationService.homeserver.value.accountProvider.
This commit is contained in:
Doug
2026-09-14 15:25:33 +01:00
committed by GitHub
parent 26fddb8aa2
commit 64aae09c6d
29 changed files with 442 additions and 265 deletions
@@ -0,0 +1,44 @@
//
// Copyright 2026 Element Creations 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 Foundation
import Testing
struct AccountProviderTests {
@Test
func genericAddressIsSanitized() {
// Lowercased, whitespace trimmed, https:// dropped and trailing slashes removed.
#expect(AccountProvider.generic("Matrix.org").serverNameOrBaseURL == "matrix.org")
#expect(AccountProvider.generic(" matrix.org ").serverNameOrBaseURL == "matrix.org")
#expect(AccountProvider.generic("https://matrix.org").serverNameOrBaseURL == "matrix.org")
#expect(AccountProvider.generic("https://matrix.org/").serverNameOrBaseURL == "matrix.org")
#expect(AccountProvider.generic("HTTPS://Matrix.org///").serverNameOrBaseURL == "matrix.org")
}
@Test
func genericAddressKeepsHTTPScheme() {
#expect(AccountProvider.generic("http://localhost:8008").serverNameOrBaseURL == "http://localhost:8008",
"The http:// scheme should be preserved as an indicator")
}
@Test
func managedUsesServerNameWhenPresent() {
let provider = AccountProvider.managed(serverName: "Matrix.org",
baseURL: "https://matrix-client.matrix.org",
canUseServerName: true)
#expect(provider.serverNameOrBaseURL == "matrix.org")
}
@Test
func managedFallsBackToBaseURLWhenShouldNotUserServerName() {
let provider = AccountProvider.managed(serverName: "Matrix.org",
baseURL: "https://matrix-client.matrix.org",
canUseServerName: false)
#expect(provider.serverNameOrBaseURL == "matrix-client.matrix.org")
}
}
+51
View File
@@ -0,0 +1,51 @@
//
// Copyright 2026 Element Creations 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 Testing
struct AppSettingsTests {
@Test
func defaultAccountProvider() {
let appSettings = AppSettings.volatile()
#expect(appSettings.defaultAccountProvider == appSettings.accountProviders[0])
appSettings.previousServers = ["example.com"]
#expect(appSettings.defaultAccountProvider == .generic("example.com"))
appSettings.override(allowOtherAccountProviders: false)
#expect(appSettings.defaultAccountProvider == appSettings.accountProviders[0])
}
}
// MARK: - Helpers
private extension AppSettings {
func override(allowOtherAccountProviders: Bool) {
override(accountProviders: accountProviders,
allowOtherAccountProviders: allowOtherAccountProviders,
hideBrandChrome: hideBrandChrome,
pushGatewayBaseURL: pushGatewayBaseURL,
oAuthRedirectURL: oAuthRedirectURL,
oAuthClientURIPath: oAuthClientURIPath,
websiteURL: websiteURL,
logoURL: logoURL,
copyrightURL: copyrightURL,
acceptableUseURL: acceptableUseURL,
privacyURL: privacyURL,
encryptionURL: encryptionURL,
deviceVerificationURL: deviceVerificationURL,
chatBackupDetailsURL: chatBackupDetailsURL,
identityPinningViolationDetailsURL: identityPinningViolationDetailsURL,
historySharingDetailsURL: historySharingDetailsURL,
elementWebHosts: elementWebHosts,
accountProvisioningHost: accountProvisioningHost,
bugReportApplicationID: bugReportApplicationID,
analyticsTermsURL: analyticsTermsURL,
mapTilerConfiguration: mapTilerConfiguration.publisher.value)
}
}
@@ -21,7 +21,7 @@ struct AuthenticationServiceTests {
@Test
mutating func passwordLogin() async throws {
try await setup(serverAddress: "example.com")
try await setup(serverNameOrBaseURL: "example.com")
switch await service.configure(for: "example.com", flow: .login) {
case .success:
@@ -65,21 +65,20 @@ struct AuthenticationServiceTests {
}
@Test
@MainActor
mutating func configureRegisterNoSupport() async throws {
let homeserverAddress = "example.com"
try await setup(serverAddress: homeserverAddress)
let serverNameOrBaseURL = "example.com"
try await setup(serverNameOrBaseURL: serverNameOrBaseURL)
try await #require(throws: AuthenticationServiceError.registrationNotSupported) {
try await service.configure(for: homeserverAddress, flow: .register).get()
try await service.configure(for: serverNameOrBaseURL, flow: .register).get()
}
#expect(service.flow == .login)
#expect(service.homeserver.value == .init(address: "matrix.org", loginMode: .unknown))
#expect(service.homeserver.value == .init(accountProvider: .managed(serverName: "matrix.org", baseURL: "https://matrix-client.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])
@@ -96,7 +95,6 @@ struct AuthenticationServiceTests {
}
@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.
@@ -114,7 +112,6 @@ struct AuthenticationServiceTests {
}
@Test
@MainActor
mutating func classicAppAccountSecretsBundleIsIgnoredForDifferentUser() async throws {
// Given an authentication service with an Element Classic account for Dan.
try await setup(classicAppAccounts: [.mockDan])
@@ -132,13 +129,13 @@ struct AuthenticationServiceTests {
// MARK: - Helpers
private mutating func setup(serverAddress: String = "matrix.org",
private mutating func setup(serverNameOrBaseURL: String = "matrix.org",
classicAppAccounts: [ClassicAppAccount] = [],
availableSecrets: ClassicAppAccount.AvailableSecrets = .complete) async throws {
let configuration: ClientFactoryMock.Configuration = .init()
let clientFactory = ClientFactoryMock(configuration)
client = configuration.homeserverClients[serverAddress]
client = configuration.homeserverClients[serverNameOrBaseURL]
encryption = EncryptionSDKMock()
client.encryptionReturnValue = encryption
@@ -50,7 +50,7 @@ final class AuthenticationStartScreenViewModelTests {
try await deferred.fulfill()
// Then the authentication service should not be used yet.
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
#expect(authenticationService.homeserver.value.loginMode == .unknown)
}
@@ -69,7 +69,7 @@ final class AuthenticationStartScreenViewModelTests {
context.send(viewAction: .login)
try await deferred.fulfill()
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.loginHint == "user@company.com")
@@ -90,14 +90,14 @@ final class AuthenticationStartScreenViewModelTests {
try await deferred.fulfill()
// Then a call to configure service should be made.
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(authenticationService.homeserver.value.loginMode == .password)
}
@Test
func singleProviderOAuthState() async throws {
// Given a view model that for an app that only allows the use of a single provider that supports OAuth.
setAllowedAccountProviders(["company.com"])
setAllowedAccountProviders([.generic("company.com")])
await setupViewModel()
#expect(authenticationService.homeserver.value.loginMode == .unknown)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
@@ -108,7 +108,7 @@ final class AuthenticationStartScreenViewModelTests {
context.send(viewAction: .login)
try await deferred.fulfill()
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.loginHint == nil)
@@ -118,7 +118,7 @@ final class AuthenticationStartScreenViewModelTests {
@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 OAuth.
setAllowedAccountProviders(["company.com"])
setAllowedAccountProviders([.generic("company.com")])
await setupViewModel(supportsOAuth: false)
#expect(authenticationService.homeserver.value.loginMode == .unknown)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
@@ -130,7 +130,7 @@ final class AuthenticationStartScreenViewModelTests {
try await deferred.fulfill()
// Then a call to configure service should be made.
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(authenticationService.homeserver.value.loginMode == .password)
}
@@ -153,8 +153,8 @@ final class AuthenticationStartScreenViewModelTests {
context.send(viewAction: .continueWithClassic(classicAppAccount))
try await deferred.fulfill()
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksReceivedArguments?.homeserverAddress == "company.com")
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksReceivedArguments?.serverNameOrBaseURL == "company.com")
#expect(authenticationService.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: false))
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.loginHint == "mxid:\(classicAppAccount.userID)")
}
@@ -177,8 +177,8 @@ final class AuthenticationStartScreenViewModelTests {
context.send(viewAction: .continueWithClassic(classicAppAccount))
try await deferred.fulfill()
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 2)
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksReceivedArguments?.homeserverAddress == "https://matrix.company.com")
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 2)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksReceivedArguments?.serverNameOrBaseURL == "https://matrix.company.com")
#expect(authenticationService.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: false))
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.loginHint == "mxid:\(classicAppAccount.userID)")
}
@@ -214,7 +214,7 @@ final class AuthenticationStartScreenViewModelTests {
// 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"])
setAllowedAccountProviders([.generic("company.com")])
await setupViewModel(classicAppAccount: classicAppAccount)
// Then the Classic app account should be shown as a welcome-back option.
@@ -230,7 +230,7 @@ final class AuthenticationStartScreenViewModelTests {
// 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"])
setAllowedAccountProviders([.generic("company.com")])
await setupViewModel(classicAppAccount: classicAppAccount)
// Then the Classic app account should not be shown since the server is not in the allowed providers.
@@ -278,7 +278,7 @@ final class AuthenticationStartScreenViewModelTests {
supportsOAuth: Bool = true,
supportsPasswordLogin: Bool = true,
availableSecrets: ClassicAppAccount.AvailableSecrets = .complete) async {
// Manually create a configuration as the default homeserver address setting is immutable.
// Manually create a configuration as the default account provider setting is immutable.
client = ClientSDKMock(.init(oAuthLoginURL: supportsOAuth ? "https://account.company.com/authorize" : nil,
supportsOAuthCreatePrompt: false,
supportsPasswordLogin: supportsPasswordLogin))
@@ -330,7 +330,7 @@ final class AuthenticationStartScreenViewModelTests {
accessToken: "accessToken")
}
private func setAllowedAccountProviders(_ providers: [String]) {
private func setAllowedAccountProviders(_ providers: [AccountProvider]) {
appSettings.override(accountProviders: providers,
allowOtherAccountProviders: false,
hideBrandChrome: false,
@@ -226,7 +226,7 @@ struct LoginScreenViewModelTests {
// MARK: - Helpers
private mutating func setupViewModel(homeserverAddress: String = "example.com", loginHint: String? = nil) async {
private mutating func setupViewModel(serverNameOrBaseURL: String = "example.com", loginHint: String? = nil) async {
let appSettings = AppSettings.volatile()
clientFactory = ClientFactoryMock(.init())
@@ -238,7 +238,7 @@ struct LoginScreenViewModelTests {
appHooks: AppHooks())
guard case .success = await service
.configure(for: homeserverAddress, flow: .login) else {
.configure(for: serverNameOrBaseURL, flow: .login) else {
Issue.record("A valid server should be configured for the test.")
return
}
@@ -17,14 +17,14 @@ nonisolated struct NotificationContentBuilderTests {
var mediaProvider: MediaProviderMock
var notificationContent: UNMutableNotificationContent
init() {
init() async {
notificationContent = .init()
let stringBuilder = RoomMessageEventStringBuilder(attributedStringBuilder: AttributedStringBuilder(mentionBuilder: PlainMentionBuilder()),
style: .plain)
mediaProvider = MediaProviderMock(.init())
notificationContentBuilder = NotificationContentBuilder(messageEventStringBuilder: stringBuilder,
notificationSoundName: UNNotificationSoundName("message.caf"),
userSession: NSEUserSessionMock(.init()))
notificationContentBuilder = await NotificationContentBuilder(messageEventStringBuilder: stringBuilder,
notificationSoundName: UNNotificationSoundName("message.caf"),
userSession: NSEUserSessionMock(.init()))
}
@Test
@@ -14,7 +14,7 @@ import Testing
@MainActor
struct ServerSelectionScreenViewModelTests {
private static let passwordOnlyServer = "example.com" // password only, no OAuth
private static let pickerProviders = ["matrix.org", "beta.matrix.org", passwordOnlyServer]
private static let pickerProviders: [String] = ["matrix.org", "beta.matrix.org", passwordOnlyServer]
var appSettings: AppSettings!
var client: ClientSDKMock!
@@ -31,16 +31,16 @@ struct ServerSelectionScreenViewModelTests {
// Given a view model for login.
try setup(authenticationFlow: .login)
#expect(service.homeserver.value.loginMode == .unknown)
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
// When selecting matrix.org.
context.homeserverAddress = "matrix.org"
context.serverNameOrBaseURL = "matrix.org"
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth }
context.send(viewAction: .confirm)
try await deferred.fulfill()
// Then selection should succeed.
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(service.homeserver.value == .mockMatrixDotOrg)
}
@@ -51,13 +51,13 @@ struct ServerSelectionScreenViewModelTests {
#expect(service.homeserver.value.loginMode == .unknown)
// When entering a Matrix ID instead of an account provider.
context.homeserverAddress = "@alice:matrix.org"
context.serverNameOrBaseURL = "@alice:matrix.org"
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth }
context.send(viewAction: .confirm)
try await deferred.fulfill()
// Then the homeserver from the ID should be used.
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksReceivedArguments?.homeserverAddress == "matrix.org")
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksReceivedArguments?.serverNameOrBaseURL == "matrix.org")
#expect(service.homeserver.value == .mockMatrixDotOrg)
}
@@ -66,17 +66,17 @@ struct ServerSelectionScreenViewModelTests {
// Given a view model for login.
try setup(authenticationFlow: .login)
#expect(service.homeserver.value.loginMode == .unknown)
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(context.alertInfo == nil)
// When selecting a server that doesn't support login.
context.homeserverAddress = "server.net"
context.serverNameOrBaseURL = "server.net"
let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil }
context.send(viewAction: .confirm)
try await deferred.fulfill()
// Then selection should fail with an alert about not supporting registration.
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(context.alertInfo?.id == .loginAlert)
}
@@ -85,16 +85,16 @@ struct ServerSelectionScreenViewModelTests {
// Given a view model for registration.
try setup(authenticationFlow: .register)
#expect(service.homeserver.value.loginMode == .unknown)
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
// When selecting matrix.org.
context.homeserverAddress = "matrix.org"
context.serverNameOrBaseURL = "matrix.org"
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOAuth }
context.send(viewAction: .confirm)
try await deferred.fulfill()
// Then selection should succeed.
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(service.homeserver.value == .mockMatrixDotOrg)
}
@@ -103,17 +103,17 @@ struct ServerSelectionScreenViewModelTests {
// Given a view model for registration.
try setup(authenticationFlow: .register)
#expect(service.homeserver.value.loginMode == .unknown)
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(context.alertInfo == nil)
// When selecting a server that doesn't support registration.
context.homeserverAddress = "example.com"
context.serverNameOrBaseURL = "example.com"
let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil }
context.send(viewAction: .confirm)
try await deferred.fulfill()
// Then selection should fail with an alert about not supporting registration.
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(context.alertInfo?.id == .registrationAlert)
}
@@ -122,17 +122,17 @@ struct ServerSelectionScreenViewModelTests {
// Given a view model for login.
try setup(authenticationFlow: .login)
#expect(service.homeserver.value.loginMode == .unknown)
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(context.alertInfo == nil)
// When selecting a server that requires Element Pro
context.homeserverAddress = "secure.gov"
context.serverNameOrBaseURL = "secure.gov"
let deferred = deferFulfillment(context.observe(\.alertInfo)) { $0 != nil }
context.send(viewAction: .confirm)
try await deferred.fulfill()
// Then selection should fail with an alert telling the user to download Element Pro.
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(context.alertInfo?.id == .elementProAlert)
}
@@ -147,7 +147,7 @@ struct ServerSelectionScreenViewModelTests {
// When attempting to discover an invalid server
var deferred = deferFulfillment(context.observe(\.viewState.isShowingFooterError)) { $0 }
context.homeserverAddress = "idontexist"
context.serverNameOrBaseURL = "idontexist"
context.send(viewAction: .confirm)
try await deferred.fulfill()
@@ -159,7 +159,7 @@ struct ServerSelectionScreenViewModelTests {
// And when clearing the error.
deferred = deferFulfillment(context.observe(\.viewState.isShowingFooterError)) { !$0 }
context.homeserverAddress = ""
context.serverNameOrBaseURL = ""
context.send(viewAction: .clearFooterError)
try await deferred.fulfill()
@@ -175,9 +175,9 @@ struct ServerSelectionScreenViewModelTests {
mutating func userInputPasswordLoginWithoutConfiguration() async throws {
// Given a view model for login using a service that hasn't been configured against a server that doesn't support OAuth.
try setup(authenticationFlow: .login)
context.homeserverAddress = Self.passwordOnlyServer
context.serverNameOrBaseURL = Self.passwordOnlyServer
#expect(service.homeserver.value.loginMode == .unknown)
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
// When confirming from the server selection screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
@@ -185,7 +185,7 @@ struct ServerSelectionScreenViewModelTests {
try await deferred.fulfill()
// Then the service should be configured but no OAuth URL fetched.
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(service.homeserver.value.loginMode == .password)
}
@@ -193,13 +193,13 @@ struct ServerSelectionScreenViewModelTests {
mutating func userInputPasswordLoginAfterConfiguration() async throws {
// Given a view model for login using a service that has already been configured against a server that doesn't support OAuth.
try setup(authenticationFlow: .login)
context.homeserverAddress = Self.passwordOnlyServer
guard case .success = await service.configure(for: context.homeserverAddress, flow: .login) else {
context.serverNameOrBaseURL = Self.passwordOnlyServer
guard case .success = await service.configure(for: context.serverNameOrBaseURL, flow: .login) else {
Issue.record("The configuration should succeed.")
return
}
#expect(service.homeserver.value.loginMode == .password)
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
// When confirming from the server selection screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
@@ -207,7 +207,7 @@ struct ServerSelectionScreenViewModelTests {
try await deferred.fulfill()
// Then the service should be re-configured but no OAuth URL fetched.
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 2)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 2)
}
// MARK: - Picker mode
@@ -218,7 +218,7 @@ struct ServerSelectionScreenViewModelTests {
try setup(authenticationFlow: .login, mode: .picker(Self.pickerProviders))
#expect(service.homeserver.value.loginMode == .unknown)
#expect(context.viewState.mode == .picker(Self.pickerProviders))
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 0)
// When confirming from the picker.
@@ -227,7 +227,7 @@ struct ServerSelectionScreenViewModelTests {
try await deferred.fulfill()
// Then the service should be configured and the OAuth URL fetched.
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesCallsCount == 1)
#expect(client.urlForOauthOauthConfigurationPromptLoginHintDeviceIdAdditionalScopesReceivedArguments?.prompt == .consent)
#expect(service.homeserver.value.loginMode == .oAuth(supportsCreatePrompt: true))
@@ -237,9 +237,9 @@ struct ServerSelectionScreenViewModelTests {
mutating 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.
try setup(authenticationFlow: .login, mode: .picker(Self.pickerProviders))
context.homeserverAddress = Self.passwordOnlyServer
context.serverNameOrBaseURL = Self.passwordOnlyServer
#expect(service.homeserver.value.loginMode == .unknown)
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 0)
// When confirming from the picker.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
@@ -247,7 +247,7 @@ struct ServerSelectionScreenViewModelTests {
try await deferred.fulfill()
// Then the service should be configured but no OAuth URL fetched.
#expect(clientFactory.makeAuthenticationClientHomeserverAddressSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(clientFactory.makeAuthenticationClientServerNameOrBaseURLSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount == 1)
#expect(service.homeserver.value.loginMode == .password)
}
@@ -258,7 +258,7 @@ struct ServerSelectionScreenViewModelTests {
// Given a view model with a previous server in history.
try setup(authenticationFlow: .login)
appSettings.previousServers = ["myserver.com"]
context.homeserverAddress = ""
context.serverNameOrBaseURL = ""
let textField = UITextField()
context.send(viewAction: .updateTextField(textField))
@@ -268,14 +268,14 @@ struct ServerSelectionScreenViewModelTests {
let typed = typedSoFar + nextCharacter
let expectedAddress = "myserver.com"
textField.text = typedSoFar
let deferred = deferFulfillment(context.observe(\.homeserverAddress)) { $0 == expectedAddress }
let deferred = deferFulfillment(context.observe(\.serverNameOrBaseURL)) { $0 == expectedAddress }
_ = textField.delegate?.textField?(textField, shouldChangeCharactersIn: NSRange(location: typedSoFar.count, length: 0), replacementString: nextCharacter)
try await deferred.fulfill()
// Then the address should be completed and the appended portion selected.
let selectionStart = expectedAddress.index(expectedAddress.startIndex, offsetBy: typed.count)
#expect(context.homeserverAddress == expectedAddress)
#expect(context.homeserverSelection == TextSelection(range: selectionStart..<expectedAddress.endIndex))
#expect(context.serverNameOrBaseURL == expectedAddress)
#expect(context.serverNameOrBaseURLSelection == TextSelection(range: selectionStart..<expectedAddress.endIndex))
}
@Test
@@ -283,7 +283,7 @@ struct ServerSelectionScreenViewModelTests {
// Given a view model with no previous server history, falling back to the default account providers.
try setup(authenticationFlow: .login)
#expect(appSettings.previousServers.isEmpty)
context.homeserverAddress = ""
context.serverNameOrBaseURL = ""
let textField = UITextField()
context.send(viewAction: .updateTextField(textField))
@@ -293,14 +293,14 @@ struct ServerSelectionScreenViewModelTests {
let typed = typedSoFar + nextCharacter
let expectedAddress = "matrix.org"
textField.text = typedSoFar
let deferred = deferFulfillment(context.observe(\.homeserverAddress)) { $0 == expectedAddress }
let deferred = deferFulfillment(context.observe(\.serverNameOrBaseURL)) { $0 == expectedAddress }
_ = textField.delegate?.textField?(textField, shouldChangeCharactersIn: NSRange(location: typedSoFar.count, length: 0), replacementString: nextCharacter)
try await deferred.fulfill()
// Then the address should be completed and the appended portion selected.
let selectionStart = expectedAddress.index(expectedAddress.startIndex, offsetBy: typed.count)
#expect(context.homeserverAddress == expectedAddress)
#expect(context.homeserverSelection == TextSelection(range: selectionStart..<expectedAddress.endIndex))
#expect(context.serverNameOrBaseURL == expectedAddress)
#expect(context.serverNameOrBaseURLSelection == TextSelection(range: selectionStart..<expectedAddress.endIndex))
}
@Test
@@ -308,7 +308,7 @@ struct ServerSelectionScreenViewModelTests {
// Given a view model with a known set of servers.
try setup(authenticationFlow: .login)
appSettings.previousServers = ["myserver.com"]
context.homeserverAddress = ""
context.serverNameOrBaseURL = ""
let textField = UITextField()
context.send(viewAction: .updateTextField(textField))
@@ -318,7 +318,7 @@ struct ServerSelectionScreenViewModelTests {
try await Task.sleep(for: .milliseconds(50))
// Then the address should remain unchanged.
#expect(context.homeserverAddress == "")
#expect(context.serverNameOrBaseURL == "")
}
@Test
@@ -326,30 +326,30 @@ struct ServerSelectionScreenViewModelTests {
// Given a view model with an ordered server history.
try setup(authenticationFlow: .login)
appSettings.previousServers = ["abc.foo", "matrix.org", "mantis.asdf"]
context.homeserverAddress = ""
context.serverNameOrBaseURL = ""
let textField = UITextField()
context.send(viewAction: .updateTextField(textField))
// Step 1: type "m" — first "m" match in history is "matrix.org".
textField.text = ""
var deferred = deferFulfillment(context.observe(\.homeserverAddress)) { $0 == "matrix.org" }
var deferred = deferFulfillment(context.observe(\.serverNameOrBaseURL)) { $0 == "matrix.org" }
_ = textField.delegate?.textField?(textField, shouldChangeCharactersIn: NSRange(location: 0, length: 0), replacementString: "m")
try await deferred.fulfill()
#expect(context.homeserverAddress == "matrix.org")
#expect(context.serverNameOrBaseURL == "matrix.org")
// Step 2: type "a" replacing the highlighted suffix — still matches "matrix.org".
// textField.text = "matrix.org", "atrix.org" highlighted (loc: 1, len: 9).
deferred = deferFulfillment(context.observe(\.homeserverAddress)) { $0 == "matrix.org" }
deferred = deferFulfillment(context.observe(\.serverNameOrBaseURL)) { $0 == "matrix.org" }
_ = textField.delegate?.textField?(textField, shouldChangeCharactersIn: NSRange(location: 1, length: 9), replacementString: "a")
try await deferred.fulfill()
#expect(context.homeserverAddress == "matrix.org")
#expect(context.serverNameOrBaseURL == "matrix.org")
// Step 3: type "n" replacing the highlighted suffix — "man" now matches "mantis.asdf".
// textField.text = "matrix.org", "trix.org" highlighted (loc: 2, len: 8).
deferred = deferFulfillment(context.observe(\.homeserverAddress)) { $0 == "mantis.asdf" }
deferred = deferFulfillment(context.observe(\.serverNameOrBaseURL)) { $0 == "mantis.asdf" }
_ = textField.delegate?.textField?(textField, shouldChangeCharactersIn: NSRange(location: 2, length: 8), replacementString: "n")
try await deferred.fulfill()
#expect(context.homeserverAddress == "mantis.asdf")
#expect(context.serverNameOrBaseURL == "mantis.asdf")
}
// MARK: - Helpers