Add tests for FlowResult.

This commit is contained in:
Doug 2022-05-18 13:51:23 +01:00 committed by Doug
parent a8f8d36314
commit badd44f426
6 changed files with 345 additions and 100 deletions

View file

@ -37,6 +37,7 @@ targets:
- target: DesignKit
- target: CommonKit
- package: Mapbox
- package: OrderedCollections
configFiles:
Debug: Debug.xcconfig

View file

@ -15,91 +15,7 @@
//
import Foundation
/// The parameters used for registration requests.
struct RegistrationParameters: Codable {
/// Authentication parameters
var auth: AuthenticationParameters?
/// The account username
var username: String?
/// The account password
var password: String?
/// Device name
var initialDeviceDisplayName: String?
/// Temporary flag to notify the server that we support MSISDN flow. Used to prevent old app
/// versions to end up in fallback because the HS returns the MSISDN flow which they don't support
var xShowMSISDN: Bool?
enum CodingKeys: String, CodingKey {
case auth
case username
case password
case initialDeviceDisplayName = "initial_device_display_name"
case xShowMSISDN = "x_show_msisdn"
}
/// The parameters as a JSON dictionary for use in MXRestClient.
func dictionary() throws -> [String: Any] {
let jsonData = try JSONEncoder().encode(self)
let object = try JSONSerialization.jsonObject(with: jsonData)
guard let dictionary = object as? [String: Any] else {
MXLog.error("[RegistrationParameters] dictionary: Unexpected type decoded \(type(of: object)). Expected a Dictionary.")
throw AuthenticationError.dictionaryError
}
return dictionary
}
}
/// The data passed to the `auth` parameter in authentication requests.
struct AuthenticationParameters: Codable {
/// The type of authentication taking place. The identifier from `MXLoginFlowType`.
let type: String
/// Note: session can be null for reset password request
var session: String?
/// parameter for "m.login.recaptcha" type
var captchaResponse: String?
/// parameter for "m.login.email.identity" type
var threePIDCredentials: ThreePIDCredentials?
enum CodingKeys: String, CodingKey {
case type
case session
case captchaResponse = "response"
case threePIDCredentials = "threepid_creds"
}
/// Creates the authentication parameters for a captcha step.
static func captchaParameters(session: String, captchaResponse: String) -> AuthenticationParameters {
AuthenticationParameters(type: kMXLoginFlowTypeRecaptcha, session: session, captchaResponse: captchaResponse)
}
/// Creates the authentication parameters for a third party ID step using an email address.
static func emailIdentityParameters(session: String, threePIDCredentials: ThreePIDCredentials) -> AuthenticationParameters {
AuthenticationParameters(type: kMXLoginFlowTypeEmailIdentity, session: session, threePIDCredentials: threePIDCredentials)
}
// Note that there is a bug in Synapse (needs investigation), but if we pass .msisdn,
// the homeserver answer with the login flow with MatrixError fields and not with a simple MatrixError 401.
/// Creates the authentication parameters for a third party ID step using a phone number.
static func msisdnIdentityParameters(session: String, threePIDCredentials: ThreePIDCredentials) -> AuthenticationParameters {
AuthenticationParameters(type: kMXLoginFlowTypeMSISDN, session: session, threePIDCredentials: threePIDCredentials)
}
/// Creates the authentication parameters for a password reset step.
static func resetPasswordParameters(clientSecret: String, sessionID: String) -> AuthenticationParameters {
AuthenticationParameters(type: kMXLoginFlowTypeEmailIdentity,
session: nil,
threePIDCredentials: ThreePIDCredentials(clientSecret: clientSecret, sessionID: sessionID))
}
}
import OrderedCollections
/// The result from a registration screen's coordinator
enum AuthenticationRegistrationStageResult {
@ -126,24 +42,24 @@ struct FlowResult {
/// A stage in the authentication flow.
enum Stage {
/// The stage with the type `m.login.recaptcha`.
case reCaptcha(isMandatory: Bool, siteKey: String)
/// The stage with the type `m.login.email.identity`.
case email(isMandatory: Bool)
/// The stage with the type `m.login.msisdn`.
case msisdn(isMandatory: Bool)
/// The stage with the type `m.login.terms`.
case terms(isMandatory: Bool, terms: MXLoginTerms?)
/// The stage with the type `m.login.recaptcha`.
case reCaptcha(isMandatory: Bool, siteKey: String)
/// The stage with the type `m.login.dummy`.
///
/// This stage can be mandatory if there is no other stages. In this case the account cannot
/// be created by just sending a username and a password, the dummy stage has to be completed.
case dummy(isMandatory: Bool)
/// The stage with the type `m.login.terms`.
case terms(isMandatory: Bool, terms: MXLoginTerms?)
/// A stage of an unknown type.
case other(isMandatory: Bool, type: String, params: [AnyHashable: Any])
@ -172,8 +88,8 @@ struct FlowResult {
}
}
/// Determines the next stage to be completed in the flow.
var nextUncompletedStage: Stage? {
/// Determines the next stage to be completed in the flow, following the order Email Terms ReCaptcha.
var nextUncompletedStageOrdered: Stage? {
if let emailStage = missingStages.first(where: { if case .email = $0 { return true } else { return false } }) {
return emailStage
}
@ -183,16 +99,23 @@ struct FlowResult {
if let reCaptchaStage = missingStages.first(where: { if case .reCaptcha = $0 { return true } else { return false } }) {
return reCaptchaStage
}
if let msisdnStage = missingStages.first(where: { if case .msisdn = $0 { return true } else { return false } }) {
return msisdnStage
}
MXLog.failure("[FlowResult.Stage] nextUncompletedStage: The dummy stage should be handled silently and any other stages should trigger the fallback flow.")
return nextUncompletedStage
}
/// Determines the next stage to be completed in the flow honouring the server's ordering.
/// This ordering is slightly broken when the are multiple flows as mandatory stages are
/// shown first and then optional ones afterwards.
var nextUncompletedStage: Stage? {
if let mandatoryStage = missingStages.filter(\.isMandatory).first {
return mandatoryStage
}
return missingStages.first
}
/// Whether fallback registration should be used due to unsupported stages.
var needsFallback : Bool {
missingStages.filter { $0.isMandatory }.contains { stage in
missingStages.filter(\.isMandatory).contains { stage in
if case .other = stage { return true } else { return false }
}
}
@ -201,7 +124,7 @@ struct FlowResult {
extension MXAuthenticationSession {
/// The flows from the session mapped as a `FlowResult` value.
var flowResult: FlowResult {
let allFlowTypes = Set(flows.flatMap { $0.stages ?? [] }) // Using a Set here loses the order, but an order is forced during presentation anyway.
let allFlowTypes = OrderedSet(flows.flatMap { $0.stages ?? [] })
var missingStages = [FlowResult.Stage]()
var completedStages = [FlowResult.Stage]()
@ -239,3 +162,28 @@ extension MXAuthenticationSession {
return FlowResult(missingStages: missingStages, completedStages: completedStages)
}
}
// MARK: - Equatable
extension FlowResult.Stage: Equatable {
// The [AnyHashable: Any] dictionary breaks automatic conformance, so add manually (but ignore this value).
static func == (lhs: FlowResult.Stage, rhs: FlowResult.Stage) -> Bool {
switch (lhs, rhs) {
case (.email(let lhsMandatory), .email(let rhsMandatory)):
return lhsMandatory == rhsMandatory
case (.msisdn(let lhsMandatory), .msisdn(let rhsMandatory)):
return lhsMandatory == rhsMandatory
case (.terms(let lhsMandatory, let lhsTerms), .terms(let rhsMandatory, let rhsTerms)):
// TODO: Add comprehensive Equatable conformance on MXLoginTerms
return lhsMandatory == rhsMandatory && lhsTerms?.policies == rhsTerms?.policies
case (.reCaptcha(let lhsMandatory, let lhsSiteKey), .reCaptcha(let rhsMandatory, let rhsSiteKey)):
return lhsMandatory == rhsMandatory && lhsSiteKey == rhsSiteKey
case (.dummy(let lhsMandatory), .dummy(let rhsMandatory)):
return lhsMandatory == rhsMandatory
case (.other(let lhsMandatory, let lhsType, _), .other(let rhsMandatory, let rhsType, _)):
return lhsMandatory == rhsMandatory && lhsType == rhsType
default:
return false
}
}
}

View file

@ -0,0 +1,102 @@
//
// 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
/// The parameters used for registration requests.
struct RegistrationParameters: Codable {
/// Authentication parameters
var auth: AuthenticationParameters?
/// The account username
var username: String?
/// The account password
var password: String?
/// Device name
var initialDeviceDisplayName: String?
/// Temporary flag to notify the server that we support MSISDN flow. Used to prevent old app
/// versions to end up in fallback because the HS returns the MSISDN flow which they don't support
var xShowMSISDN: Bool?
enum CodingKeys: String, CodingKey {
case auth
case username
case password
case initialDeviceDisplayName = "initial_device_display_name"
case xShowMSISDN = "x_show_msisdn"
}
/// The parameters as a JSON dictionary for use in MXRestClient.
func dictionary() throws -> [String: Any] {
let jsonData = try JSONEncoder().encode(self)
let object = try JSONSerialization.jsonObject(with: jsonData)
guard let dictionary = object as? [String: Any] else {
MXLog.error("[RegistrationParameters] dictionary: Unexpected type decoded \(type(of: object)). Expected a Dictionary.")
throw AuthenticationError.dictionaryError
}
return dictionary
}
}
/// The data passed to the `auth` parameter in registration requests.
struct AuthenticationParameters: Codable {
/// The type of authentication taking place. The identifier from `MXLoginFlowType`.
let type: String
/// Note: session can be null for reset password request
var session: String?
/// parameter for "m.login.recaptcha" type
var captchaResponse: String?
/// parameter for "m.login.email.identity" type
var threePIDCredentials: ThreePIDCredentials?
enum CodingKeys: String, CodingKey {
case type
case session
case captchaResponse = "response"
case threePIDCredentials = "threepid_creds"
}
/// Creates the authentication parameters for a captcha step.
static func captchaParameters(session: String, captchaResponse: String) -> AuthenticationParameters {
AuthenticationParameters(type: kMXLoginFlowTypeRecaptcha, session: session, captchaResponse: captchaResponse)
}
/// Creates the authentication parameters for a third party ID step using an email address.
static func emailIdentityParameters(session: String, threePIDCredentials: ThreePIDCredentials) -> AuthenticationParameters {
AuthenticationParameters(type: kMXLoginFlowTypeEmailIdentity, session: session, threePIDCredentials: threePIDCredentials)
}
// Note that there is a bug in Synapse (needs investigation), but if we pass .msisdn,
// the homeserver answer with the login flow with MatrixError fields and not with a simple MatrixError 401.
/// Creates the authentication parameters for a third party ID step using a phone number.
static func msisdnIdentityParameters(session: String, threePIDCredentials: ThreePIDCredentials) -> AuthenticationParameters {
AuthenticationParameters(type: kMXLoginFlowTypeMSISDN, session: session, threePIDCredentials: threePIDCredentials)
}
/// Creates the authentication parameters for a password reset step.
static func resetPasswordParameters(clientSecret: String, sessionID: String) -> AuthenticationParameters {
AuthenticationParameters(type: kMXLoginFlowTypeEmailIdentity,
session: nil,
threePIDCredentials: ThreePIDCredentials(clientSecret: clientSecret, sessionID: sessionID))
}
}

View file

@ -18,7 +18,6 @@ import XCTest
@testable import Riot
@available(iOS 14.0, *)
class AuthenticationServiceTests: XCTestCase {
func testRegistrationWizardWhenStartingLoginFlow() async throws {
// Given a fresh service.

View file

@ -0,0 +1,191 @@
//
// 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 XCTest
@testable import Riot
class RegistrationTests: XCTestCase {
/// Makes an authentication session that mimics the matrix.org flow.
func makeSession() -> MXAuthenticationSession {
let flow = MXLoginFlow()
flow.stages = [kMXLoginFlowTypeRecaptcha, kMXLoginFlowTypeTerms, kMXLoginFlowTypeEmailIdentity]
let session = MXAuthenticationSession()
session.flows = [flow]
session.params = [:]
return session
}
/// Makes an authentication session that has two flows.
func makeSessionWithTwoFlows() -> MXAuthenticationSession {
let flow1 = MXLoginFlow()
flow1.stages = [kMXLoginFlowTypeMSISDN, kMXLoginFlowTypeTerms, kMXLoginFlowTypeRecaptcha]
let flow2 = MXLoginFlow()
flow2.stages = [kMXLoginFlowTypeEmailIdentity, kMXLoginFlowTypeTerms, kMXLoginFlowTypeRecaptcha]
let session = MXAuthenticationSession()
session.flows = [flow1, flow2]
session.params = [:]
return session
}
func testRegistrationResultForNewSession() {
// Given a fresh session.
let session = makeSession()
// Then the result should have no completed stages.
let flowResult = session.flowResult
XCTAssertEqual(flowResult.completedStages.count, 0,
"There should be no completed stages for a new session.")
XCTAssertEqual(flowResult.missingStages.count, 3,
"The result should have 3 missing stages.")
XCTAssertEqual(flowResult.nextUncompletedStage, .reCaptcha(isMandatory: true, siteKey: ""),
"The first stage should match the order in the session.")
XCTAssertEqual(flowResult.nextUncompletedStageOrdered, .email(isMandatory: true),
"The first stage when ordered should be Email for a new session.")
XCTAssertFalse(flowResult.needsFallback,
"Fallback shouldn't be needed when the stages are all supported.")
}
func testRegistrationResultAfterEmail() {
// Given a fresh session.
let session = makeSession()
// When completing the email stage.
session.completed = [kMXLoginFlowTypeEmailIdentity]
// Then the result should reflect the first stage has been completed.
let flowResult = session.flowResult
XCTAssertEqual(flowResult.completedStages.count, 1,
"The result should have 1 completed stage.")
XCTAssertEqual(flowResult.missingStages.count, 2,
"The result should have 2 missing stages.")
XCTAssertEqual(flowResult.nextUncompletedStage, .reCaptcha(isMandatory: true, siteKey: ""),
"The next stage should be the ReCaptcha stage.")
XCTAssertEqual(flowResult.nextUncompletedStageOrdered, .terms(isMandatory: true, terms: MXLoginTerms(fromJSON: [:])),
"The next stage when ordered should be the Terms stage.")
}
func testRegistrationResultAfterEmailAndTerms() {
// Given a fresh session.
let session = makeSession()
// When completing the email and terms stages.
session.completed = [kMXLoginFlowTypeEmailIdentity, kMXLoginFlowTypeTerms]
// Then the result should reflect the first 2 stages have been completed.
let flowResult = session.flowResult
XCTAssertEqual(flowResult.completedStages.count, 2,
"The result should have 2 completed stages.")
XCTAssertEqual(flowResult.missingStages.count, 1,
"The result should have 1 missing stage.")
XCTAssertEqual(flowResult.nextUncompletedStage, .reCaptcha(isMandatory: true, siteKey: ""),
"The next stage should be the ReCaptcha stage.")
XCTAssertEqual(flowResult.nextUncompletedStageOrdered, .reCaptcha(isMandatory: true, siteKey: ""),
"The next stage when ordered should be the ReCaptcha stage.")
}
func testRegistrationResultAfterAllStages() {
// Given a fresh session.
let session = makeSession()
// When completing all of the stages.
session.completed = [kMXLoginFlowTypeEmailIdentity, kMXLoginFlowTypeTerms, kMXLoginFlowTypeRecaptcha]
// Then the result shouldn't have any missing stages.
let flowResult = session.flowResult
XCTAssertEqual(flowResult.completedStages.count, 3,
"The result should have all completed stages.")
XCTAssertEqual(flowResult.missingStages.count, 0,
"The result should have no missing stages.")
XCTAssertNil(flowResult.nextUncompletedStage,
"There shouldn't be any more stages to complete.")
XCTAssertNil(flowResult.nextUncompletedStageOrdered,
"There shouldn't be any more stages to complete.")
}
func testRegistrationResultCustomStage() {
// Given a session that contains a single flow with a custom stage.
let session = makeSession()
session.flows.first?.stages.append("test.flow")
// Then the result should indicate that fallback authentication should be used.
let flowResult = session.flowResult
XCTAssertTrue(flowResult.needsFallback, "Fallback should be required when a custom stage is present.")
}
func testRegistrationResultTwoFlows() {
// Given a session with two flows.
let session = makeSessionWithTwoFlows()
// Then the result should know the mandatory/optional stages and start with the mandatory stages unless ordered
let flowResult = session.flowResult
XCTAssertFalse(flowResult.needsFallback,
"Fallback shouldn't be needed when the stages are all supported.")
XCTAssertEqual(flowResult.nextUncompletedStage, .terms(isMandatory: true, terms: MXLoginTerms(fromJSON: [:])),
"The first stage should be the Terms stage.")
XCTAssertEqual(flowResult.nextUncompletedStageOrdered, .email(isMandatory: false),
"The first stage when ordered should be the Email stage.")
flowResult.missingStages.forEach { stage in
switch stage {
case .email(let isMandatory):
XCTAssertFalse(isMandatory, "The Email stage should be optional.")
case .msisdn(let isMandatory):
XCTAssertFalse(isMandatory, "The MSISDN stage should be optional.")
case .terms(let isMandatory, _):
XCTAssertTrue(isMandatory, "The Terms stage should be mandatory.")
case .reCaptcha(let isMandatory, _):
XCTAssertTrue(isMandatory, "The ReCaptcha stage should be mandatory.")
default:
XCTFail("There shouldn't be any other types of stage in the result.")
}
}
}
func testRegistrationResultTwoFlowsAfterMandatoryStages() {
// Given a session with two flows.
let session = makeSessionWithTwoFlows()
// When completing the terms and recaptcha stages.
session.completed = [kMXLoginFlowTypeTerms, kMXLoginFlowTypeRecaptcha]
// Then the result should have the optional stages remaining.
let flowResult = session.flowResult
XCTAssertEqual(flowResult.completedStages.count, 2,
"The result should have 2 completed stages.")
XCTAssertEqual(flowResult.missingStages.count, 2,
"The result should have 2 missing stage.")
XCTAssertEqual(flowResult.nextUncompletedStage, .msisdn(isMandatory: false),
"The next stage should be the MSISDN stage.")
XCTAssertEqual(flowResult.nextUncompletedStageOrdered, .email(isMandatory: false),
"The next stage when ordered should be the Email stage.")
}
func testRegistrationResultTwoFlowsCustomStage() {
// Given a session with a custom stage in a second flow.
let session = makeSession()
let flow = MXLoginFlow()
flow.stages = ["test.flow"]
session.flows.append(flow)
// Then the session shouldn't need fallback.
let flowResult = session.flowResult
XCTAssertFalse(flowResult.needsFallback, "Fallback shouldn't be required when a custom stage is optional.")
}
}

View file

@ -44,3 +44,7 @@ packages:
url: https://github.com/maplibre/maplibre-gl-native-distribution
minVersion: 5.12.2
maxVersion: 5.13.0
OrderedCollections:
url: https://github.com/apple/swift-collections
minVersion: 1.0.2
maxVersion: 2.0.0