From e889804404f4f5637edf0090cc7430832cf5f5b6 Mon Sep 17 00:00:00 2001 From: Doug Date: Tue, 31 May 2022 18:48:25 +0100 Subject: [PATCH 1/2] Add initial tests on the authentication service. --- .../MatrixSDK/AuthenticationRestClient.swift | 8 +- .../MatrixSDK/AuthenticationService.swift | 25 ++- .../Service/MatrixSDK/LoginWizard.swift | 4 +- .../MatrixSDK/RegistrationParameters.swift | 4 +- .../MatrixSDK/RegistrationWizard.swift | 4 +- .../Service/MatrixSDK/SessionCreator.swift | 12 +- .../Service/MatrixSDK/ThreePIDModels.swift | 2 +- .../AuthenticationServiceTests.swift | 169 +++++++++++++- .../Fixtures/basic-loginsession.json | 13 ++ .../Fixtures/basic-registersession.json | 11 + .../Fixtures/basic-wellknown.json | 5 + .../Fixtures/loginOnly-loginsession.json | 13 ++ .../Fixtures/matrix-loginsession.json | 48 ++++ .../Fixtures/matrix-registersession.json | 28 +++ .../Fixtures/matrix-wellknown.json | 8 + .../Fixtures/ssoOnly-loginsession.json | 19 ++ .../Fixtures/ssoOnly-wellknown.json | 9 + .../Mocks/MockAuthenticationRestClient.swift | 211 ++++++++++++++++++ .../MockAuthenticationRestClientConfig.swift | 127 +++++++++++ .../Mocks/MockSessionCreator.swift | 27 +++ changelog.d/6179.wip | 1 + 21 files changed, 717 insertions(+), 31 deletions(-) create mode 100644 RiotTests/Modules/Authentication/Fixtures/basic-loginsession.json create mode 100644 RiotTests/Modules/Authentication/Fixtures/basic-registersession.json create mode 100644 RiotTests/Modules/Authentication/Fixtures/basic-wellknown.json create mode 100644 RiotTests/Modules/Authentication/Fixtures/loginOnly-loginsession.json create mode 100644 RiotTests/Modules/Authentication/Fixtures/matrix-loginsession.json create mode 100644 RiotTests/Modules/Authentication/Fixtures/matrix-registersession.json create mode 100644 RiotTests/Modules/Authentication/Fixtures/matrix-wellknown.json create mode 100644 RiotTests/Modules/Authentication/Fixtures/ssoOnly-loginsession.json create mode 100644 RiotTests/Modules/Authentication/Fixtures/ssoOnly-wellknown.json create mode 100644 RiotTests/Modules/Authentication/Mocks/MockAuthenticationRestClient.swift create mode 100644 RiotTests/Modules/Authentication/Mocks/MockAuthenticationRestClientConfig.swift create mode 100644 RiotTests/Modules/Authentication/Mocks/MockSessionCreator.swift create mode 100644 changelog.d/6179.wip diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationRestClient.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationRestClient.swift index df0084e279..10ed719f25 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationRestClient.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationRestClient.swift @@ -16,10 +16,14 @@ import Foundation -protocol AuthenticationRestClient { +protocol AuthenticationRestClient: AnyObject { // MARK: Configuration - var credentials: MXCredentials! { get } + var homeserver: String! { get } var identityServer: String! { get } + var credentials: MXCredentials! { get } + var acceptableContentTypes: Set! { get set } + + init(homeServer: URL, unrecognizedCertificateHandler handler: MXHTTPClientOnUnrecognizedCertificate?) // MARK: Login var loginFallbackURL: URL { get } diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift index 060dca3667..afbe3feee5 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/AuthenticationService.swift @@ -36,15 +36,15 @@ class AuthenticationService: NSObject { // MARK: Private - /// The rest client used to make authentication requests. - private var client: AuthenticationRestClient /// The object used to create a new `MXSession` when authentication has completed. - private var sessionCreator = SessionCreator() + private var sessionCreator: SessionCreatorProtocol // MARK: Public /// The current state of the authentication flow. private(set) var state: AuthenticationState + /// The rest client used to make authentication requests. + private(set) var client: AuthenticationRestClient /// The current login wizard or `nil` if `startFlow` hasn't been called. private(set) var loginWizard: LoginWizard? /// The current registration wizard or `nil` if `startFlow` hasn't been called for `.registration`. @@ -53,16 +53,21 @@ class AuthenticationService: NSObject { /// The authentication service's delegate. weak var delegate: AuthenticationServiceDelegate? + /// The type of client to use during the flow. + var clientType: AuthenticationRestClient.Type = MXRestClient.self + // MARK: - Setup - override init() { + init(sessionCreator: SessionCreatorProtocol = SessionCreator()) { guard let homeserverURL = URL(string: BuildSettings.serverConfigDefaultHomeserverUrlString) else { MXLog.failure("[AuthenticationService]: Failed to create URL from default homeserver URL string.") fatalError("Invalid default homeserver URL string.") } state = AuthenticationState(flow: .login, homeserverAddress: BuildSettings.serverConfigDefaultHomeserverUrlString) - client = MXRestClient(homeServer: homeserverURL, unrecognizedCertificateHandler: nil) + client = clientType.init(homeServer: homeserverURL, unrecognizedCertificateHandler: nil) + + self.sessionCreator = sessionCreator super.init() } @@ -96,12 +101,12 @@ class AuthenticationService: NSObject { func startFlow(_ flow: AuthenticationFlow, for homeserverAddress: String) async throws { var (client, homeserver) = try await loginFlow(for: homeserverAddress) - let loginWizard = LoginWizard(client: client) + let loginWizard = LoginWizard(client: client, sessionCreator: sessionCreator) self.loginWizard = loginWizard if flow == .register { do { - let registrationWizard = RegistrationWizard(client: client) + let registrationWizard = RegistrationWizard(client: client, sessionCreator: sessionCreator) homeserver.registrationFlow = try await registrationWizard.registrationFlow() self.registrationWizard = registrationWizard } catch { @@ -193,7 +198,7 @@ class AuthenticationService: NSObject { } #warning("Add an unrecognized certificate handler.") - let client = MXRestClient(homeServer: homeserverURL, unrecognizedCertificateHandler: nil) + let client = clientType.init(homeServer: homeserverURL, unrecognizedCertificateHandler: nil) let loginFlow = try await getLoginFlowResult(client: client) @@ -219,7 +224,7 @@ class AuthenticationService: NSObject { return (client, homeserver) } - private func getLoginFlowResult(client: MXRestClient) async throws -> LoginFlowResult { + private func getLoginFlowResult(client: AuthenticationRestClient) async throws -> LoginFlowResult { // Get the login flow let loginFlowResponse = try await client.getLoginSession() @@ -231,7 +236,7 @@ class AuthenticationService: NSObject { /// Perform a well-known request on the specified homeserver URL. private func wellKnown(for homeserverURL: URL) async throws -> MXWellKnown { - let wellKnownClient = MXRestClient(homeServer: homeserverURL, unrecognizedCertificateHandler: nil) + let wellKnownClient = clientType.init(homeServer: homeserverURL, unrecognizedCertificateHandler: nil) // The .well-known/matrix/client API is often just a static file returned with no content type. // Make our HTTP client compatible with this behaviour diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginWizard.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginWizard.swift index cdc64c70e8..56b5c06d26 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginWizard.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/LoginWizard.swift @@ -31,11 +31,11 @@ class LoginWizard { } let client: AuthenticationRestClient - let sessionCreator: SessionCreator + let sessionCreator: SessionCreatorProtocol private(set) var state: State - init(client: AuthenticationRestClient, sessionCreator: SessionCreator = SessionCreator()) { + init(client: AuthenticationRestClient, sessionCreator: SessionCreatorProtocol) { self.client = client self.sessionCreator = sessionCreator diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/RegistrationParameters.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/RegistrationParameters.swift index b66e4f9320..d4a1416e9a 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/RegistrationParameters.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/RegistrationParameters.swift @@ -17,7 +17,7 @@ import Foundation /// The parameters used for registration requests. -struct RegistrationParameters: DictionaryEncodable { +struct RegistrationParameters: DictionaryEncodable, Equatable { /// Authentication parameters var auth: AuthenticationParameters? @@ -44,7 +44,7 @@ struct RegistrationParameters: DictionaryEncodable { } /// The data passed to the `auth` parameter in authentication requests. -struct AuthenticationParameters: Encodable { +struct AuthenticationParameters: Encodable, Equatable { /// The type of authentication taking place. The identifier from `MXLoginFlowType`. let type: String diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/RegistrationWizard.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/RegistrationWizard.swift index 470a47b986..4f4df671a0 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/RegistrationWizard.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/RegistrationWizard.swift @@ -36,7 +36,7 @@ class RegistrationWizard { } let client: AuthenticationRestClient - let sessionCreator: SessionCreator + let sessionCreator: SessionCreatorProtocol private(set) var state: State @@ -59,7 +59,7 @@ class RegistrationWizard { state.isRegistrationStarted } - init(client: AuthenticationRestClient, sessionCreator: SessionCreator = SessionCreator()) { + init(client: AuthenticationRestClient, sessionCreator: SessionCreatorProtocol) { self.client = client self.sessionCreator = sessionCreator diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/SessionCreator.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/SessionCreator.swift index 255c2b608d..660ebe26c5 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/SessionCreator.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/SessionCreator.swift @@ -16,9 +16,17 @@ import Foundation -/// A WIP class that has common functionality to create a new session. -class SessionCreator { +protocol SessionCreatorProtocol { /// Creates an `MXSession` using the supplied credentials and REST client. + /// - Parameters: + /// - credentials: The `MXCredentials` for the account. + /// - client: The client that completed the authentication. + /// - Returns: A new `MXSession` for the account. + func createSession(credentials: MXCredentials, client: AuthenticationRestClient) -> MXSession +} + +/// A struct that provides common functionality to create a new session. +struct SessionCreator: SessionCreatorProtocol { func createSession(credentials: MXCredentials, client: AuthenticationRestClient) -> MXSession { // Report the new account in account manager if credentials.identityServer == nil { diff --git a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/ThreePIDModels.swift b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/ThreePIDModels.swift index 57682a88f2..478cbf9f24 100644 --- a/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/ThreePIDModels.swift +++ b/RiotSwiftUI/Modules/Authentication/Common/Service/MatrixSDK/ThreePIDModels.swift @@ -21,7 +21,7 @@ enum RegisterThreePID { case msisdn(msisdn: String, countryCode: String) } -struct ThreePIDCredentials: Codable { +struct ThreePIDCredentials: Codable, Equatable { var clientSecret: String? var identityServer: String? diff --git a/RiotTests/Modules/Authentication/AuthenticationServiceTests.swift b/RiotTests/Modules/Authentication/AuthenticationServiceTests.swift index 6dfe7dfbc0..217c5aa8e7 100644 --- a/RiotTests/Modules/Authentication/AuthenticationServiceTests.swift +++ b/RiotTests/Modules/Authentication/AuthenticationServiceTests.swift @@ -18,22 +18,33 @@ import XCTest @testable import Riot -class AuthenticationServiceTests: XCTestCase { - func testRegistrationWizardWhenStartingLoginFlow() async throws { +@MainActor class AuthenticationServiceTests: XCTestCase { + var service: AuthenticationService! + + /// Makes a new service configured for testing. + @MainActor override func setUp() { + service = AuthenticationService(sessionCreator: MockSessionCreator()) + service.clientType = MockAuthenticationRestClient.self + } + + // MARK: - Service State + + func testWizardsWhenStartingLoginFlow() async throws { // Given a fresh service. - let service = AuthenticationService() + XCTAssertNil(service.loginWizard, "A new service shouldn't have a login wizard.") XCTAssertNil(service.registrationWizard, "A new service shouldn't have a registration wizard.") // When starting a new login flow. try await service.startFlow(.login, for: "https://matrix.org") // Then a registration wizard shouldn't have been created. + XCTAssertNotNil(service.loginWizard, "The login wizard should exist after starting a login flow.") XCTAssertNil(service.registrationWizard, "The registration wizard should not exist if startFlow was called for login.") } - func testRegistrationWizard() async throws { + func testWizardsWhenStartingRegistrationFlow() async throws { // Given a fresh service. - let service = AuthenticationService() + XCTAssertNil(service.loginWizard, "A new service shouldn't have a login wizard.") XCTAssertNil(service.registrationWizard, "A new service shouldn't provide a registration wizard.") XCTAssertNil(service.state.homeserver.registrationFlow, "A new service shouldn't provide a registration flow for the homeserver.") @@ -41,13 +52,13 @@ class AuthenticationServiceTests: XCTestCase { try await service.startFlow(.register, for: "https://matrix.org") // Then a registration wizard should be available for use. + XCTAssertNotNil(service.loginWizard, "The login wizard should exist after starting a registration flow.") XCTAssertNotNil(service.registrationWizard, "The registration wizard should exist after starting a registration flow.") XCTAssertNotNil(service.state.homeserver.registrationFlow, "The supported registration flow should be stored after starting a registration flow.") } func testReset() async throws { // Given a service that has begun registration. - let service = AuthenticationService() try await service.startFlow(.register, for: "https://matrix.org") _ = try await service.registrationWizard?.createAccount(username: UUID().uuidString, password: UUID().uuidString, initialDeviceDisplayName: "Test") XCTAssertNotNil(service.loginWizard, "The login wizard should exist after starting a registration flow.") @@ -55,6 +66,8 @@ class AuthenticationServiceTests: XCTestCase { XCTAssertNotNil(service.state.homeserver.registrationFlow, "The supported registration flow should be stored after starting a registration flow.") XCTAssertTrue(service.isRegistrationStarted, "The service should show as having started registration.") XCTAssertEqual(service.state.flow, .register, "The service should show as using a registration flow.") + XCTAssertEqual(service.state.homeserver.address, "https://matrix-client.matrix.org", "The actual homeserver address should be discovered.") + XCTAssertEqual(service.state.homeserver.addressFromUser, "https://matrix.org", "The address from the startFlow call should be stored.") // When resetting the service. service.reset() @@ -65,14 +78,14 @@ class AuthenticationServiceTests: XCTestCase { XCTAssertNil(service.state.homeserver.registrationFlow, "The supported registration flow should be cleared when calling reset.") XCTAssertFalse(service.isRegistrationStarted, "The service should not indicate it has started registration after calling reset.") XCTAssertEqual(service.state.flow, .login, "The flow should have been set back to login when calling reset.") + XCTAssertEqual(service.state.homeserver.address, "https://matrix.org", "The address should reset to the value entered by the user.") } func testHomeserverState() async throws { // Given a service that has begun login for one homeserver. - let service = AuthenticationService() - try await service.startFlow(.login, for: "https://glasgow.social") - XCTAssertEqual(service.state.homeserver.addressFromUser, "https://glasgow.social", "The initial address entered by the user should be stored.") - XCTAssertEqual(service.state.homeserver.address, "https://matrix.glasgow.social", "The initial address discovered from the well-known should be stored.") + try await service.startFlow(.login, for: "https://example.com") + XCTAssertEqual(service.state.homeserver.addressFromUser, "https://example.com", "The initial address entered by the user should be stored.") + XCTAssertEqual(service.state.homeserver.address, "https://matrix.example.com", "The initial address discovered from the well-known should be stored.") // When switching to a different homeserver try await service.startFlow(.login, for: "https://matrix.org") @@ -82,6 +95,142 @@ class AuthenticationServiceTests: XCTestCase { XCTAssertEqual(service.state.homeserver.address, "https://matrix-client.matrix.org", "The new address discovered from the well-known should be stored.") } + func testStartingLoginWithInvalidURL() async throws { + // Given a service that has started the register flow for one homeserver. + try await service.startFlow(.login, for: "https://example.com") + XCTAssertEqual(service.client.homeserver, "https://matrix.example.com", "The client should be set up for the homeserver") + XCTAssertEqual(service.state.flow, .login, "The flow should be set as login.") + XCTAssertEqual(service.state.homeserver.addressFromUser, "https://example.com", "The initial address entered by the user should be stored.") + XCTAssertEqual(service.state.homeserver.address, "https://matrix.example.com", "The initial address discovered from the well-known should be stored.") + + // When failing to start login by entering an invalid address. + do { + try await service.startFlow(.login, for: "https://google.com") + XCTFail("The registration flow should fail for an incorrect homeserver address.") + } catch { + XCTAssertNotNil(error, "The client should throw an error for an incorrect address.") + } + + // Then the service's state and client should be unchanged. + XCTAssertEqual(service.client.homeserver, "https://matrix.example.com", "The client should be set up for the homeserver") + XCTAssertEqual(service.state.flow, .login, "The flow should still be set as login.") + XCTAssertEqual(service.state.homeserver.addressFromUser, "https://example.com", "The initial address entered by the user should be stored.") + XCTAssertEqual(service.state.homeserver.address, "https://matrix.example.com", "The initial address discovered from the well-known should be stored.") + } + + func testStartingRegistrationForLoginOnlyServer() async throws { + // Given a service that has started the register flow for one homeserver. + try await service.startFlow(.register, for: "https://example.com") + XCTAssertEqual(service.client.homeserver, "https://matrix.example.com", "The client should be set up for the homeserver") + XCTAssertEqual(service.state.flow, .register, "The flow should be set as registration.") + XCTAssertEqual(service.state.homeserver.addressFromUser, "https://example.com", "The initial address entered by the user should be stored.") + XCTAssertEqual(service.state.homeserver.address, "https://matrix.example.com", "The initial address discovered from the well-known should be stored.") + + // When failing to start registration for another homeserver that only supports login. + do { + try await service.startFlow(.register, for: "https://private.com") + XCTFail("The registration flow should fail for a server that doesn't support registration") + } catch { + XCTAssertEqual(error as? MockAuthenticationRestClient.MockError, MockAuthenticationRestClient.MockError.registrationDisabled, + "The client should throw with disabled registration.") + } + + // The the service's state and client should be unchanged. + XCTAssertEqual(service.client.homeserver, "https://matrix.example.com", "The client should still be set up for the homeserver") + XCTAssertEqual(service.state.flow, .register, "The flow should still be set as registration.") + XCTAssertEqual(service.state.homeserver.addressFromUser, "https://example.com", "The initial address entered by the user should still be stored.") + XCTAssertEqual(service.state.homeserver.address, "https://matrix.example.com", "The initial address discovered from the well-known should still be stored.") + } + + func testPasswordLogin() async throws { + // Given a server ready for login. + try await service.startFlow(.login, for: "https://matrix.org") + guard let loginWizard = service.loginWizard else { + XCTFail("The login wizard should exist after starting a login flow.") + return + } + + // When logging in with valid credentials. + let account = MockAuthenticationRestClient.registeredAccount + let session = try await loginWizard.login(login: account.username, + password: account.password, + initialDeviceName: UIDevice.current.initialDisplayName) + + // Then the MXSession should be created for the user ID. + XCTAssertEqual(session.myUserId, "@alice:matrix.org") + } + + func testBasicRegistration() async throws { + // Given a basic server ready for registration (only has a dummy stage). + try await service.startFlow(.register, for: "https://example.com") + guard let registrationWizard = service.registrationWizard else { + XCTFail("The registration wizard should exist after starting a registration flow.") + return + } + + // When registering with a username and password. + let result = try await registrationWizard.createAccount(username: "bob", + password: "password", + initialDeviceDisplayName: "whatever") + + // Then an MXSession should be created for the new account. + guard case let .success(session) = result else { + XCTFail("The dummy stage should be performed and registration should be successful.") + return + } + XCTAssertEqual(session.myUserId, "@bob:example.com") + } + + func testInteractiveRegistration() async throws { + // Given a server ready for registration with multiple mandatory stages. + try await service.startFlow(.register, for: "https://matrix.org") + guard let registrationWizard = service.registrationWizard else { + XCTFail("The registration wizard should exist after starting a registration flow.") + return + } + XCTAssertFalse(registrationWizard.state.isRegistrationStarted, "Registration should not be started yet.") + + // When registering with a username and password. + let createAccountResult = try await registrationWizard.createAccount(username: "bob", + password: "password", + initialDeviceDisplayName: "whatever") + + // Then the registration should be started and be waiting for all of the stages to be completed. + guard case let .flowResponse(flowResult) = createAccountResult else { + XCTFail("The registration should not have completed.") + return + } + XCTAssertEqual(flowResult.completedStages.count, 0) + XCTAssertEqual(flowResult.missingStages.count, 3) + XCTAssertTrue(registrationWizard.state.isRegistrationStarted, "Registration should be started after calling create account.") + + // TODO: Email step + + // When performing the terms stage. + let termsResult = try await registrationWizard.acceptTerms() + + // Then the completed and missing stages should be updated accordingly. + guard case let .flowResponse(termsFlowResult) = termsResult else { + XCTFail("The registration should not have completed.") + return + } + XCTAssertEqual(termsFlowResult.completedStages.count, 1) + XCTAssertEqual(termsFlowResult.missingStages.count, 2) + + // When performing the ReCaptcha stage. + let reCaptchaResult = try await registrationWizard.performReCaptcha(response: "trafficlights") + + // Then the completed and missing stages should be updated accordingly. + guard case let .flowResponse(reCaptchaFlowResult) = reCaptchaResult else { + XCTFail("The registration should not have completed.") + return + } + XCTAssertEqual(reCaptchaFlowResult.completedStages.count, 2) + XCTAssertEqual(reCaptchaFlowResult.missingStages.count, 1) + } + + // MARK: - Homeserver View Data + func testHomeserverViewDataForMatrixDotOrg() { // Given a homeserver such as matrix.org. let address = "https://matrix-client.matrix.org" diff --git a/RiotTests/Modules/Authentication/Fixtures/basic-loginsession.json b/RiotTests/Modules/Authentication/Fixtures/basic-loginsession.json new file mode 100644 index 0000000000..5787d22e3f --- /dev/null +++ b/RiotTests/Modules/Authentication/Fixtures/basic-loginsession.json @@ -0,0 +1,13 @@ +{ + "flows": [ + { + "type": "m.login.password" + }, + { + "type": "m.login.application_service" + }, + { + "type": "uk.half-shot.msc2778.login.application_service" + } + ] +} diff --git a/RiotTests/Modules/Authentication/Fixtures/basic-registersession.json b/RiotTests/Modules/Authentication/Fixtures/basic-registersession.json new file mode 100644 index 0000000000..9a0a042180 --- /dev/null +++ b/RiotTests/Modules/Authentication/Fixtures/basic-registersession.json @@ -0,0 +1,11 @@ +{ + "session": "123456", + "flows": [ + { + "stages": [ + "m.login.dummy" + ] + } + ], + "params": {} +} diff --git a/RiotTests/Modules/Authentication/Fixtures/basic-wellknown.json b/RiotTests/Modules/Authentication/Fixtures/basic-wellknown.json new file mode 100644 index 0000000000..1daf2951b3 --- /dev/null +++ b/RiotTests/Modules/Authentication/Fixtures/basic-wellknown.json @@ -0,0 +1,5 @@ +{ + "m.homeserver": { + "base_url": "https://matrix.example.com" + } +} diff --git a/RiotTests/Modules/Authentication/Fixtures/loginOnly-loginsession.json b/RiotTests/Modules/Authentication/Fixtures/loginOnly-loginsession.json new file mode 100644 index 0000000000..5787d22e3f --- /dev/null +++ b/RiotTests/Modules/Authentication/Fixtures/loginOnly-loginsession.json @@ -0,0 +1,13 @@ +{ + "flows": [ + { + "type": "m.login.password" + }, + { + "type": "m.login.application_service" + }, + { + "type": "uk.half-shot.msc2778.login.application_service" + } + ] +} diff --git a/RiotTests/Modules/Authentication/Fixtures/matrix-loginsession.json b/RiotTests/Modules/Authentication/Fixtures/matrix-loginsession.json new file mode 100644 index 0000000000..5b45d97b6f --- /dev/null +++ b/RiotTests/Modules/Authentication/Fixtures/matrix-loginsession.json @@ -0,0 +1,48 @@ +{ + "flows": [ + { + "type": "m.login.sso", + "identity_providers": [ + { + "id": "oidc-github", + "name": "GitHub", + "icon": "mxc://matrix.org/sVesTtrFDTpXRbYfpahuJsKP", + "brand": "github" + }, + { + "id": "oidc-google", + "name": "Google", + "icon": "mxc://matrix.org/ZlnaaZNPxtUuQemvgQzlOlkz", + "brand": "google" + }, + { + "id": "oidc-gitlab", + "name": "GitLab", + "icon": "mxc://matrix.org/MCVOEmFgVieKFshPxmnejWOq", + "brand": "gitlab" + }, + { + "id": "oidc-facebook", + "name": "Facebook", + "icon": "mxc://matrix.org/nsyeLIgzxazZmJadflMAsAWG", + "brand": "facebook" + }, + { + "id": "oidc-apple", + "name": "Apple", + "icon": "mxc://matrix.org/QQKNSOdLiMHtJhzeAObmkFiU", + "brand": "apple" + } + ] + }, + { + "type": "m.login.token" + }, + { + "type": "m.login.password" + }, + { + "type": "m.login.application_service" + } + ] +} diff --git a/RiotTests/Modules/Authentication/Fixtures/matrix-registersession.json b/RiotTests/Modules/Authentication/Fixtures/matrix-registersession.json new file mode 100644 index 0000000000..46f169410a --- /dev/null +++ b/RiotTests/Modules/Authentication/Fixtures/matrix-registersession.json @@ -0,0 +1,28 @@ +{ + "session": "123456", + "flows": [ + { + "stages": [ + "m.login.recaptcha", + "m.login.terms", + "m.login.email.identity" + ] + } + ], + "params": { + "m.login.recaptcha": { + "public_key": "1234" + }, + "m.login.terms": { + "policies": { + "privacy_policy": { + "version": "1.0", + "en": { + "name": "Terms and Conditions", + "url": "https://matrix-client.matrix.org/_matrix/consent?v=1.0" + } + } + } + } + } +} diff --git a/RiotTests/Modules/Authentication/Fixtures/matrix-wellknown.json b/RiotTests/Modules/Authentication/Fixtures/matrix-wellknown.json new file mode 100644 index 0000000000..ed726e2421 --- /dev/null +++ b/RiotTests/Modules/Authentication/Fixtures/matrix-wellknown.json @@ -0,0 +1,8 @@ +{ + "m.homeserver": { + "base_url": "https://matrix-client.matrix.org" + }, + "m.identity_server": { + "base_url": "https://vector.im" + } +} diff --git a/RiotTests/Modules/Authentication/Fixtures/ssoOnly-loginsession.json b/RiotTests/Modules/Authentication/Fixtures/ssoOnly-loginsession.json new file mode 100644 index 0000000000..9f2c7dfa21 --- /dev/null +++ b/RiotTests/Modules/Authentication/Fixtures/ssoOnly-loginsession.json @@ -0,0 +1,19 @@ +{ + "flows": [ + { + "type": "m.login.sso", + "identity_providers": [ + { + "id": "saml", + "name": "SAML" + } + ] + }, + { + "type": "m.login.token" + }, + { + "type": "m.login.application_service" + } + ] +} diff --git a/RiotTests/Modules/Authentication/Fixtures/ssoOnly-wellknown.json b/RiotTests/Modules/Authentication/Fixtures/ssoOnly-wellknown.json new file mode 100644 index 0000000000..f5f619cf02 --- /dev/null +++ b/RiotTests/Modules/Authentication/Fixtures/ssoOnly-wellknown.json @@ -0,0 +1,9 @@ +{ + "m.homeserver": { + "base_url": "https://matrix.company.com" + }, + "m.identity_server": { + "base_url": "https://identity.company.com" + } +} + diff --git a/RiotTests/Modules/Authentication/Mocks/MockAuthenticationRestClient.swift b/RiotTests/Modules/Authentication/Mocks/MockAuthenticationRestClient.swift new file mode 100644 index 0000000000..ff3c4e6af4 --- /dev/null +++ b/RiotTests/Modules/Authentication/Mocks/MockAuthenticationRestClient.swift @@ -0,0 +1,211 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +@testable import Riot + +/// A mock REST client that can be used for authentication. +class MockAuthenticationRestClient: AuthenticationRestClient { + + enum MockError: Error { + /// The fixture is missing. + case fixture + /// The method isn't implemented. + case unhandled + /// Login attempted with incorrect credentials. + case invalidCredentials + /// The homeserver doesn't allow for registration. + case registrationDisabled + /// A registration stage was attempted without first registering a username and password. + case createAccountNotCalled + /// The request is invalid. + case invalidRequest + } + + /// An account to test password based login with. + static let registeredAccount = (username: "alice", email: "alice@example.com", phone: "+447777777777", password: "password") + /// A token to test token based login with. + static var pendingLoginToken = "000000" + + // MARK: - Configuration + + /// The client's internal configuration. + var config: Config + /// The homeserver's URL string. + var homeserver: String! { homeserverURL.absoluteString } + /// Unused: The identity server. + var identityServer: String! + /// Unused: The credentials. + var credentials: MXCredentials! + /// Unused: The type of content to accept for responses. + var acceptableContentTypes: Set! + + // MARK: - Private + + /// The URL used to create the client with. + private var homeserverURL: URL + /// Unused: A callback for handling an unrecognized certificate. + private var unrecognizedCertificateHandler: MXHTTPClientOnUnrecognizedCertificate? + + /// The credentials for a pending account creation. + private var newAccount: (username: String, password: String)? + /// The stages completed in the registration flow. + private var completedStages: Set = [] + + // MARK: - Setup + + /// Creates a new mock client. + /// - Parameters: + /// - homeServer: See `MockAuthenticationRestClient.Config` for various URLs that can be used. + /// - handler: Unused. + required init(homeServer: URL, unrecognizedCertificateHandler handler: MXHTTPClientOnUnrecognizedCertificate?) { + self.config = Config(url: homeServer) + + self.homeserverURL = homeServer + self.unrecognizedCertificateHandler = handler + } + + // MARK: - Login + + var loginFallbackURL: URL { + homeserverURL.appendingPathComponent("_matrix/static/client/login") + } + + func wellKnown() async throws -> MXWellKnown { + try MXWellKnown(fromJSON: config.wellKnownJSON()) + } + + func getLoginSession() async throws -> MXAuthenticationSession { + try MXAuthenticationSession(fromJSON: config.loginSessionJSON()) + } + + func login(parameters: LoginParameters) async throws -> MXCredentials { + if let passwordParameters = parameters as? LoginPasswordParameters { + return try login(passwordParameters: passwordParameters) + } else if let tokenParameters = parameters as? LoginTokenParameters { + return try login(tokenParameters: tokenParameters) + } else { + throw MockError.unhandled + } + } + + /// Checks login against the `registeredAccount` and returns credentials if valid. + private func login(passwordParameters: LoginPasswordParameters) throws -> MXCredentials { + switch passwordParameters.id { + case .user(let username): + guard username == Self.registeredAccount.username else { throw MockError.invalidCredentials } + case .thirdParty(medium: let medium, address: let address): + guard medium == .email, address == Self.registeredAccount.email else { throw MockError.invalidCredentials } + case .phone(country: let country, phone: let phone): + guard "+\(country)\(phone)" == Self.registeredAccount.phone else { throw MockError.invalidCredentials } + } + + guard passwordParameters.password == Self.registeredAccount.password else { throw MockError.invalidCredentials } + + return makeCredentials() + } + + /// Checks login against the `pendingLoginToken` and returns credentials if valid. + private func login(tokenParameters: LoginTokenParameters) throws -> MXCredentials { + guard tokenParameters.token == Self.pendingLoginToken else { throw MockError.invalidCredentials } + return makeCredentials() + } + + /// Mock credentials for the registered account. + private func makeCredentials() -> MXCredentials { + MXCredentials(homeServer: homeserver, + userId: "@\(Self.registeredAccount.username):\(config.baseURL)", + accessToken: "1234") + } + + func login(parameters: [String : Any]) async throws -> MXCredentials { + throw MockError.unhandled + } + + // MARK: - Registration + + var registerFallbackURL: URL { + homeserverURL.appendingPathComponent("_matrix/static/client/register") + } + + func getRegisterSession() async throws -> MXAuthenticationSession { + try MXAuthenticationSession(fromJSON: config.registerSessionJSON()) + } + + func isUsernameAvailable(_ username: String) async throws -> Bool { + username != Self.registeredAccount.username + } + + func register(parameters: RegistrationParameters) async throws -> MXLoginResponse { + guard let supportedStages = config.supportedStages else { throw MockError.registrationDisabled } + + let success = attemptStage(with: parameters) + + guard success, completedStages == supportedStages, let newAccount = newAccount else { + var errorResponse = try config.registerSessionJSON() + errorResponse["completed"] = Array(completedStages) + let nsError = NSError(domain: "mock", + code: 401, + userInfo: [MXHTTPClientErrorResponseDataKey: errorResponse]) + throw nsError + } + + let response = MXLoginResponse() + response.accessToken = "1234" + response.homeserver = homeserver + response.userId = "@\(newAccount.username):\(config.baseURL)" + + return response + } + + /// Returns a boolean indicating whether the stage was completed or not. + private func attemptStage(with parameters: RegistrationParameters) -> Bool { + if let username = parameters.username, let password = parameters.password { + newAccount = (username: username, password: password) + return true + } + + guard newAccount != nil else { return false } + guard let auth = parameters.auth else { return false } + + completedStages.insert(auth.type) + + return true + } + + func register(parameters: [String : Any]) async throws -> MXLoginResponse { + throw MockError.unhandled + } + + func requestTokenDuringRegistration(for threePID: RegisterThreePID, clientSecret: String, sendAttempt: UInt) async throws -> RegistrationThreePIDTokenResponse { + throw MockError.unhandled + } + + // MARK: - Forgot password + + func forgetPassword(for email: String, clientSecret: String, sendAttempt: UInt) async throws -> String { + throw MockError.unhandled + } + + func resetPassword(parameters: CheckResetPasswordParameters) async throws { + throw MockError.unhandled + } + + func resetPassword(parameters: [String : Any]) async throws { + throw MockError.unhandled + } +} diff --git a/RiotTests/Modules/Authentication/Mocks/MockAuthenticationRestClientConfig.swift b/RiotTests/Modules/Authentication/Mocks/MockAuthenticationRestClientConfig.swift new file mode 100644 index 0000000000..3e5a95746c --- /dev/null +++ b/RiotTests/Modules/Authentication/Mocks/MockAuthenticationRestClientConfig.swift @@ -0,0 +1,127 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// Represents a homeserver configuration used for the mock authentication client. +extension MockAuthenticationRestClient { + enum Config: String { + /// A homeserver that mimics matrix.org with both passwords and SSO. + /// Create the client using https://matrix.org for this configuration. + case matrix + + /// A homeserver that supports login and registration using a password. + /// Create the client using https://example.com for this configuration. + case basic + + /// A homeserver that only supports login using a password and has registration disabled. + /// This configuration doesn't returns a well-known response. + /// Create the client using https://private.com for this configuration. + case loginOnly + + /// A homeserver the only supports login via SSO and has registration disabled. + /// This configuration has a custom identity server configured. + /// Create the client using https://company.com for this configuration. + case ssoOnly + + /// The client if configured to use an unknown address. + /// Create the client using any other address for this configuration. + case unknown + + init(url: URL) { + switch url.absoluteString { + case "https://matrix.org", "https://matrix-client.matrix.org": + self = .matrix + case "https://example.com", "https://matrix.example.com": + self = .basic + case "https://private.com": + self = .loginOnly + case "https://company.com", "https://matrix.company.com": + self = .ssoOnly + default: + self = .unknown + } + } + + /// The baseURL for the homeserver. + var baseURL: String { + switch self { + case .matrix: + return "matrix.org" + case .basic: + return "example.com" + case .loginOnly: + return "private.com" + case .ssoOnly: + return "company.com" + case .unknown: + return "" + } + } + + /// The supported stages when performing interactive registration. + var supportedStages: Set? { + switch self { + case .matrix: + return [kMXLoginFlowTypeRecaptcha, kMXLoginFlowTypeTerms, kMXLoginFlowTypeEmailIdentity] + case .basic: + return [kMXLoginFlowTypeDummy] + case .loginOnly, .ssoOnly, .unknown: + return nil + } + } + + /// Returns the well-known JSON for this configuration + func wellKnownJSON() throws -> [AnyHashable: Any] { + try fixtureJSON(named: "wellknown") + } + + /// Returns the login session JSON for this configuration + func loginSessionJSON() throws -> [AnyHashable: Any] { + try fixtureJSON(named: "loginsession") + } + + /// Returns the register session JSON for this configuration + func registerSessionJSON() throws -> [AnyHashable: Any] { + switch self { + case .matrix, .basic: + return try fixtureJSON(named: "registersession") + case .loginOnly, .ssoOnly: + throw MockError.registrationDisabled + case .unknown: + throw MockError.unhandled + } + } + + /// Loads a JSON fixture for this configuration. + /// - Parameter fileName: The file name of the fixture without the configuration prefix. + private func fixtureJSON(named fileName: String) throws -> [AnyHashable: Any] { + let fileName = "\(rawValue)-\(fileName)" + let data = try fixtureData(named: fileName) + guard let jsonDictionary = try JSONSerialization.jsonObject(with: data) as? [AnyHashable: Any] else { throw MockError.fixture } + return jsonDictionary + } + + /// Loads the raw data for a fixture from disk. + /// - Parameter fileName: The file name of the fixture as stored in the bundle. + private func fixtureData(named fileName: String) throws -> Data { + let bundle = Bundle(for: MockAuthenticationRestClient.self) + + guard let url = bundle.url(forResource: fileName, withExtension: "json") else { throw MockError.fixture } + return try Data(contentsOf: url) + } + } +} diff --git a/RiotTests/Modules/Authentication/Mocks/MockSessionCreator.swift b/RiotTests/Modules/Authentication/Mocks/MockSessionCreator.swift new file mode 100644 index 0000000000..b07b72b974 --- /dev/null +++ b/RiotTests/Modules/Authentication/Mocks/MockSessionCreator.swift @@ -0,0 +1,27 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +@testable import Riot + +struct MockSessionCreator: SessionCreatorProtocol { + /// Returns a basic session created from the supplied credentials. This prevents the app from setting up the account during tests. + func createSession(credentials: MXCredentials, client: AuthenticationRestClient) -> MXSession { + let client = MXRestClient(credentials: credentials) + return MXSession(matrixRestClient: client) + } +} diff --git a/changelog.d/6179.wip b/changelog.d/6179.wip new file mode 100644 index 0000000000..b91464a040 --- /dev/null +++ b/changelog.d/6179.wip @@ -0,0 +1 @@ +Authentication: Add tests covering the authentication service and wizards. From 4f701f594734531e56e3699e6f19c1ce5058fd53 Mon Sep 17 00:00:00 2001 From: Doug Date: Mon, 6 Jun 2022 12:17:59 +0100 Subject: [PATCH 2/2] Fix failing tests. --- .../Modules/Authentication/Mocks/MockSessionCreator.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/RiotTests/Modules/Authentication/Mocks/MockSessionCreator.swift b/RiotTests/Modules/Authentication/Mocks/MockSessionCreator.swift index b07b72b974..ada8513eb3 100644 --- a/RiotTests/Modules/Authentication/Mocks/MockSessionCreator.swift +++ b/RiotTests/Modules/Authentication/Mocks/MockSessionCreator.swift @@ -21,7 +21,8 @@ import Foundation struct MockSessionCreator: SessionCreatorProtocol { /// Returns a basic session created from the supplied credentials. This prevents the app from setting up the account during tests. func createSession(credentials: MXCredentials, client: AuthenticationRestClient) -> MXSession { - let client = MXRestClient(credentials: credentials) + let client = MXRestClient(credentials: credentials, + unauthenticatedHandler: { _,_,_,_ in }) // The handler is expected if credentials are set. return MXSession(matrixRestClient: client) } }