From c4664c923659c6a2555c6d5397359b38846b62da Mon Sep 17 00:00:00 2001 From: Mike Nachbaur Date: Mon, 18 Jan 2021 13:04:26 -0800 Subject: [PATCH] Add support for messages, and improve test coverage for API response parsing --- Sources/IDXClientAPI.swift | 3 + .../Extensions/IDXClient+Combine.swift | 15 ++ .../Extensions/IDXClient+Extension.swift | 4 + .../Extensions/IDXClient+Response.swift | 114 +++++++--- .../Extensions/IDXVersion+Extension.swift | 11 +- .../IDXVersion+InternalExtension.swift | 17 ++ .../Implementations/IDXClientAPIImpl.swift | 42 ++++ .../v1.0.0/IDXClientAPIv1.swift | 4 + .../v1.0.0/Protocols/URLSessionProtocol.swift | 2 +- .../IDXClient+V1ResponseConstructors.swift | 17 +- .../v1.0.0/Responses/Responses.swift | 50 +++- Tests/IDXClientCombineTests.swift | 5 +- Tests/IDXClientTests.swift | 3 +- Tests/IDXClientV1ResponseTests.swift | 213 +++++++++++++++++- Tests/Mocks/IDXClientAPIMock.swift | 2 + Tests/URLSessionProtocolTests.swift | 2 +- okta-idx.xcodeproj/project.pbxproj | 24 +- 17 files changed, 458 insertions(+), 70 deletions(-) create mode 100644 Sources/Internal/Extensions/IDXVersion+InternalExtension.swift diff --git a/Sources/IDXClientAPI.swift b/Sources/IDXClientAPI.swift index eff06b61..96c43f1d 100644 --- a/Sources/IDXClientAPI.swift +++ b/Sources/IDXClientAPI.swift @@ -33,6 +33,9 @@ public protocol IDXClientAPI { /// - error: Describes the error that occurred, or `nil` if successful. func start(completion: @escaping (_ response: IDXClient.Response?, _ error: Error?) -> Void) + /// Indicates whether or not the current stage in the workflow can be cancelled. + var canCancel: Bool { get } + /// Cancels the current workflow. /// - Parameters: /// - completion: Invoked when the operation is cancelled. diff --git a/Sources/Internal/Extensions/IDXClient+Combine.swift b/Sources/Internal/Extensions/IDXClient+Combine.swift index acba50ad..00dabb5a 100644 --- a/Sources/Internal/Extensions/IDXClient+Combine.swift +++ b/Sources/Internal/Extensions/IDXClient+Combine.swift @@ -65,5 +65,20 @@ extension IDXClient.Response { } } } + + /// Requests to cancel a remediation step, returning a Future. + public func cancel() -> Future { + return Future { (promise) in + self.cancel() { (response, error) in + if let error = error { + promise(.failure(error)) + } else if let response = response { + promise(.success(response)) + } else { + promise(.failure(IDXClientError.invalidResponseData)) + } + } + } + } } #endif diff --git a/Sources/Internal/Extensions/IDXClient+Extension.swift b/Sources/Internal/Extensions/IDXClient+Extension.swift index 53f7fef9..49e5d8a0 100644 --- a/Sources/Internal/Extensions/IDXClient+Extension.swift +++ b/Sources/Internal/Extensions/IDXClient+Extension.swift @@ -32,6 +32,10 @@ extension IDXClient { } } + public var canCancel: Bool { + return self.api.canCancel + } + public func cancel(completion: @escaping (Response?, Error?) -> Void) { self.api.cancel { (response, error) in diff --git a/Sources/Internal/Extensions/IDXClient+Response.swift b/Sources/Internal/Extensions/IDXClient+Response.swift index 96b139ee..55788f71 100644 --- a/Sources/Internal/Extensions/IDXClient+Response.swift +++ b/Sources/Internal/Extensions/IDXClient+Response.swift @@ -28,11 +28,19 @@ public extension IDXClient { /// An object describing the sort of remediation steps available to the user, or `nil` if the workflow is ended. public let remediation: Remediation? + /// The list of messages sent from the server, or `nil` if no messages are available at the response level. + /// + /// Messages reported from the server are usually errors, but may include other information relevant to the user. They should be displayed to the user in the context of the remediation form itself. + public let messages: [Message]? + /// Indicates whether or not the user has logged in successfully. If this is `true`, this response object should be exchanged for access tokens utilizing the `exchangeCode` method. public var isLoginSuccessful: Bool { return successResponse != nil } + /// Indicates whether or not the response can be cancelled. + public let canCancel: Bool + /// Cancels the current workflow. /// - Parameters: /// - completion: Invoked when the operation is cancelled. @@ -64,7 +72,7 @@ public extension IDXClient { internal let cancelRemediationOption: Remediation.Option? internal let successResponse: Remediation.Option? - internal init(client: IDXClientAPIImpl, stateHandle: String, version: String, expiresAt: Date, intent: String, remediation: Remediation?, cancel: Remediation.Option?, success: Remediation.Option?) { + internal init(client: IDXClientAPIImpl, stateHandle: String, version: String, expiresAt: Date, intent: String, remediation: Remediation?, cancel: Remediation.Option?, success: Remediation.Option?, messages: [Message]?) { self.client = client self.stateHandle = stateHandle self.version = version @@ -73,6 +81,8 @@ public extension IDXClient { self.remediation = remediation self.cancelRemediationOption = cancel self.successResponse = success + self.messages = messages + self.canCancel = (cancel != nil) super.init() } @@ -169,10 +179,31 @@ public extension IDXClient { /// For form fields that have specific options the user can choose from (e.g. security question, passcode, etc), this indicates the different form options that should be displayed to the user. public let options: [FormValue]? + /// The list of messages sent from the server, or `nil` if no messages are available at the form value level. + /// + /// Messages reported from the server at the FormValue level should be considered relevant to the individual form field, and as a result should be displayed to the user alongside any UI elements associated with it. + public let messages: [Message]? + public func relatesTo() -> AnyObject? { return nil } + /// For composite or nested forms, this method composes the list of form values, merging the supplied parameters along with the defaults included in the form. + /// + /// Validation checks for required and immutable values are performed, which will throw exceptions if any of those parameters fail validation. + /// - Parameter params: User-supplied parameters, `nil` to simply retrieve the defaults. + /// - Throws: + /// - IDXClientError.invalidParameter + /// - IDXClientError.parameterImmutable + /// - IDXClientError.missingRequiredParameter + /// - Returns: Collection of key/value pairs, or `nil` if this form value does not contain a nested form. + /// - SeeAlso: IDXClient.Remediation.Option.formValues(with:) + public func formValues(with params: [String:Any]? = nil) throws -> [String:Any]? { + guard let form = form else { return nil } + + return try IDXClient.extractFormValues(from: form, with: params) + } + internal init(name: String?, label: String?, type: String?, @@ -182,7 +213,8 @@ public extension IDXClient { required: Bool, secret: Bool, form: [FormValue]?, - options: [FormValue]?) + options: [FormValue]?, + messages: [Message]?) { self.name = name self.label = label @@ -194,11 +226,12 @@ public extension IDXClient { self.secret = secret self.form = form self.options = options + self.messages = messages super.init() } } - + /// Instances of `IDXClient.Remediation.Option` describe choices the user can make to proceed through the authentication workflow. /// /// Either simple or complex authentication scenarios consist of a set of steps that may be followed, but at some times the user may have a choice in what they use to verify their identity. For example, a user may have multiple choices in verifying their account, such as: @@ -259,44 +292,51 @@ public extension IDXClient { } /// Apply the remediation option parameters, reconciling default values and mutability requirements. - /// - Parameter params: Optional parameters supplied by the user. - /// - Throws:: + /// + /// Validation checks for required and immutable values are performed, which will throw exceptions if any of those parameters fail validation. + /// - Parameter params: User-supplied parameters, `nil` to simply retrieve the defaults. + /// - Throws: /// - IDXClientError.invalidParameter /// - IDXClientError.parameterImmutable /// - IDXClientError.missingRequiredParameter - /// - Returns: Dictionary of key/value pairs to send to the remediation endpoint - internal func formValues(with params: [String:Any]? = nil) throws -> [String:Any] { - var result: [String:Any] = form - .filter { $0.value != nil && $0.name != nil } - .reduce(into: [:]) { (result, formValue) in - result[formValue.name!] = formValue.value - } - - let allFormValues = form.reduce(into: [String:FormValue]()) { (result, value) in - result[value.name] = value - } - - try params?.forEach { (key, value) in - guard let formValue = allFormValues[key] else { - throw IDXClientError.invalidParameter(name: key) - } - - guard formValue.mutable == true else { - throw IDXClientError.parameterImmutable(name: key) - } - - result[key] = value - } - - try allFormValues.values.filter { $0.required }.forEach { - /// TODO: Fix compound field support and relatesTo - guard result[$0.name!] != nil else { - throw IDXClientError.missingRequiredParameter(name: $0.name!) - } - } - - return result + /// - Returns: Collection of key/value pairs, or `nil` if this form value does not contain a nested form. + /// - SeeAlso: IDXClient.Remediation.FormValue.formValues(with:) + public func formValues(with params: [String:Any]? = nil) throws -> [String:Any] { + return try IDXClient.extractFormValues(from: form, with: params) } } } + + /// Represents messages sent from the server to indicate error or warning conditions related to responses or form values. + @objc(IDXMessage) + final class Message: NSObject { + /// Enumeration describing the type of message. + public enum MessageClass: String { + case error = "ERROR" + case info = "INFO" + case unknown + } + + /// The type of message received from the server + public let type: MessageClass + + /// A localization key representing this message. + /// + /// This allows the text represented by this message to be customized or localized as needed. + public let localizationKey: String + + /// The default text for this message. + public let message: String + + internal init(type: String, + localizationKey: String, + message: String) + { + self.type = MessageClass(rawValue: type) ?? .unknown + self.localizationKey = localizationKey + self.message = message + + super.init() + } + } } diff --git a/Sources/Internal/Extensions/IDXVersion+Extension.swift b/Sources/Internal/Extensions/IDXVersion+Extension.swift index 9a128542..6e3db235 100644 --- a/Sources/Internal/Extensions/IDXVersion+Extension.swift +++ b/Sources/Internal/Extensions/IDXVersion+Extension.swift @@ -1,8 +1,8 @@ // -// IdentityEngineVersion+Extension.swift +// IDXVersion+Extension.swift // okta-idx-ios // -// Created by Mike Nachbaur on 2020-12-08. +// Created by Mike Nachbaur on 2021-01-11. // import Foundation @@ -27,11 +27,4 @@ extension IDXClient.Version: RawRepresentable { return nil } } - - internal func clientImplementation(with configuration: IDXClient.Configuration) -> IDXClientAPIImpl { - switch self { - case .v1_0_0: - return IDXClient.APIVersion1(with: configuration) - } - } } diff --git a/Sources/Internal/Extensions/IDXVersion+InternalExtension.swift b/Sources/Internal/Extensions/IDXVersion+InternalExtension.swift new file mode 100644 index 00000000..c066fb66 --- /dev/null +++ b/Sources/Internal/Extensions/IDXVersion+InternalExtension.swift @@ -0,0 +1,17 @@ +// +// IdentityEngineVersion+Extension.swift +// okta-idx-ios +// +// Created by Mike Nachbaur on 2020-12-08. +// + +import Foundation + +extension IDXClient.Version { + internal func clientImplementation(with configuration: IDXClient.Configuration) -> IDXClientAPIImpl { + switch self { + case .v1_0_0: + return IDXClient.APIVersion1(with: configuration) + } + } +} diff --git a/Sources/Internal/Implementations/IDXClientAPIImpl.swift b/Sources/Internal/Implementations/IDXClientAPIImpl.swift index 76fbb360..b4e5a11f 100644 --- a/Sources/Internal/Implementations/IDXClientAPIImpl.swift +++ b/Sources/Internal/Implementations/IDXClientAPIImpl.swift @@ -40,3 +40,45 @@ internal protocol IDXClientAPIRequest { using configuration: IDXClient.Configuration, completion: @escaping (ResponseType?, Error?) -> Void) } + +internal extension IDXClient { + class func extractFormValues(from form: [IDXClient.Remediation.FormValue], with params: [String:Any]? = nil) throws -> [String:Any] { + var result: [String:Any] = try form + .filter { $0.value != nil && $0.name != nil } + .reduce(into: [:]) { (result, formValue) in + guard let name = formValue.name else { throw IDXClientError.invalidParameter(name: "") } + result[name] = formValue.value + } + + let allFormValues = form.reduce(into: [String:IDXClient.Remediation.FormValue]()) { (result, value) in + result[value.name] = value + } + + try params?.forEach { (key, value) in + guard let formValue = allFormValues[key] else { + throw IDXClientError.invalidParameter(name: key) + } + + guard formValue.mutable == true else { + throw IDXClientError.parameterImmutable(name: key) + } + + + if let nestedForm = value as? IDXClient.Remediation.FormValue { + result[key] = try nestedForm.formValues() + } else { + result[key] = value + } + } + + try allFormValues.values.filter { $0.required }.forEach { + /// TODO: Fix compound field support and relatesTo + guard result[$0.name!] != nil else { + throw IDXClientError.missingRequiredParameter(name: $0.name!) + } + } + + return result + } + +} diff --git a/Sources/Internal/Implementations/v1.0.0/IDXClientAPIv1.swift b/Sources/Internal/Implementations/v1.0.0/IDXClientAPIv1.swift index 45e36f1c..c2e79e5f 100644 --- a/Sources/Internal/Implementations/v1.0.0/IDXClientAPIv1.swift +++ b/Sources/Internal/Implementations/v1.0.0/IDXClientAPIv1.swift @@ -77,6 +77,10 @@ extension IDXClient.APIVersion1: IDXClientAPIImpl { } } + var canCancel: Bool { + return (cancelRemediationOption != nil) + } + func cancel(completion: @escaping (IDXClient.Response?, Error?) -> Void) { guard let cancelOption = cancelRemediationOption else { completion(nil, IDXClientError.unknownRemediationOption(name: "cancel")) diff --git a/Sources/Internal/Implementations/v1.0.0/Protocols/URLSessionProtocol.swift b/Sources/Internal/Implementations/v1.0.0/Protocols/URLSessionProtocol.swift index 93c81f94..64d5fcd6 100644 --- a/Sources/Internal/Implementations/v1.0.0/Protocols/URLSessionProtocol.swift +++ b/Sources/Internal/Implementations/v1.0.0/Protocols/URLSessionProtocol.swift @@ -37,7 +37,7 @@ extension URLSession: URLSessionProtocol { return } - guard httpResponse.statusCode == 200 else { + guard httpResponse.statusCode <= 400 else { completionHandler(data, httpResponse, IDXClientError.invalidHTTPResponse) return } diff --git a/Sources/Internal/Implementations/v1.0.0/Responses/IDXClient+V1ResponseConstructors.swift b/Sources/Internal/Implementations/v1.0.0/Responses/IDXClient+V1ResponseConstructors.swift index 39e666c7..9b02f79b 100644 --- a/Sources/Internal/Implementations/v1.0.0/Responses/IDXClient+V1ResponseConstructors.swift +++ b/Sources/Internal/Implementations/v1.0.0/Responses/IDXClient+V1ResponseConstructors.swift @@ -16,7 +16,17 @@ extension IDXClient.Response { intent: object.intent, remediation: IDXClient.Remediation(client: client, v1: object.remediation), cancel: IDXClient.Remediation.Option(client: client, v1: object.cancel), - success: IDXClient.Remediation.Option(client: client, v1: object.successWithInteractionCode)) + success: IDXClient.Remediation.Option(client: client, v1: object.successWithInteractionCode), + messages: object.messages?.value.compactMap { IDXClient.Message(client: client, v1: $0) }) + } +} + +extension IDXClient.Message { + internal convenience init?(client: IDXClientAPIImpl, v1 object: IDXClient.APIVersion1.Response.Message?) { + guard let object = object else { return nil } + self.init(type: object.type, + localizationKey: object.i18n.key, + message: object.message) } } @@ -56,8 +66,9 @@ extension IDXClient.Remediation.FormValue { mutable: object.mutable ?? true, required: object.required ?? false, secret: object.secret ?? false, - form: nil/*object.form?.map { IDXClient.FormValue(client: client, v1: $0) }*/, - options: nil/*object.options?.map { IDXClient.FormValue(client: client, v1: $0) }*/) + form: object.form?.value.map { IDXClient.Remediation.FormValue(client: client, v1: $0) }, + options: object.options?.map { IDXClient.Remediation.FormValue(client: client, v1: $0) }, + messages: object.messages?.value.compactMap { IDXClient.Message(client: client, v1: $0) }) } } diff --git a/Sources/Internal/Implementations/v1.0.0/Responses/Responses.swift b/Sources/Internal/Implementations/v1.0.0/Responses/Responses.swift index 405bee78..73d5679c 100644 --- a/Sources/Internal/Implementations/v1.0.0/Responses/Responses.swift +++ b/Sources/Internal/Implementations/v1.0.0/Responses/Responses.swift @@ -55,7 +55,7 @@ extension IDXClient.APIVersion1 { let expiresAt: Date let intent: String let remediation: FormCollection? -// let messages: Messages + let messages: MessageCollection? // let authenticatorEnrollments: AuthenticatorEnrollments // let currentAuthenticatorEnrollment: FormCollection? // let user: User @@ -100,11 +100,13 @@ extension IDXClient.APIVersion1 { let secret: Bool? let visible: Bool? let mutable: Bool? + let form: CompositeFormValue? let options: [FormValue]? let relatesTo: String? + let messages: MessageCollection? private enum CodingKeys: String, CodingKey { - case name, required, label, type, value, secret, visible, mutable, options, relatesTo + case name, required, label, type, value, secret, visible, mutable, options, form, relatesTo, messages } init(from decoder: Decoder) throws { @@ -118,14 +120,52 @@ extension IDXClient.APIVersion1 { mutable = try container.decodeIfPresent(Bool.self, forKey: .mutable) relatesTo = try container.decodeIfPresent(String.self, forKey: .relatesTo) options = try container.decodeIfPresent([FormValue].self, forKey: .options) + messages = try container.decodeIfPresent(MessageCollection.self, forKey: .messages) + + let formObj = try? container.decodeIfPresent(CompositeFormValue.self, forKey: .form) + let valueAsCompositeObj = try? container.decodeIfPresent(CompositeForm.self, forKey: .value) + let valueAsJsonObj = try? container.decodeIfPresent(JSONValue.self, forKey: .value) - if let obj = try? container.decodeIfPresent(CompositeForm.self, forKey: .value) { - value = .object(obj) + if formObj == nil && valueAsCompositeObj != nil { + form = valueAsCompositeObj?.form } else { - value = try? container.decodeIfPresent(JSONValue.self, forKey: .value) + form = formObj } + + if let valueAsCompositeObj = valueAsCompositeObj { + value = .object(valueAsCompositeObj) + } else { + value = valueAsJsonObj + } + } + } + + struct MessageCollection: Codable { + let type: String + let value: [Message] + } + + struct Message: Codable { + let type: String + let i18n: Localization + let message: String + + struct Localization: Codable { + let key: String + } + + private enum CodingKeys: String, CodingKey { + case type = "class", i18n, message + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + type = try container.decode(String.self, forKey: .type) + i18n = try container.decode(Localization.self, forKey: .i18n) + message = try container.decode(String.self, forKey: .message) } } + } /// Internal OIE API v1.0.0 token response. diff --git a/Tests/IDXClientCombineTests.swift b/Tests/IDXClientCombineTests.swift index caf3ed0b..b0ab8671 100644 --- a/Tests/IDXClientCombineTests.swift +++ b/Tests/IDXClientCombineTests.swift @@ -44,7 +44,8 @@ class IDXClientCombineTests: XCTestCase { intent: "Login", remediation: nil, cancel: remedationOption, - success: remedationOption) + success: remedationOption, + messages: nil) token = IDXClient.Token(accessToken: "accessToken", refreshToken: nil, expiresIn: 800, idToken: nil, scope: "", tokenType: "bear") } @@ -94,7 +95,7 @@ class IDXClientCombineTests: XCTestCase { XCTAssertNotNil(value) XCTAssertEqual(value, self.response) } - wait(for: [completion], timeout: 1) + wait(for: [completion], timeout: 2) XCTAssertTrue(called) } } diff --git a/Tests/IDXClientTests.swift b/Tests/IDXClientTests.swift index 300b3f05..cd676947 100644 --- a/Tests/IDXClientTests.swift +++ b/Tests/IDXClientTests.swift @@ -54,7 +54,8 @@ class IDXClientTests: XCTestCase { intent: "Login", remediation: nil, cancel: remedationOption, - success: remedationOption) + success: remedationOption, + messages: nil) var expect: XCTestExpectation! var call: IDXClientAPIv1Mock.RecordedCall? diff --git a/Tests/IDXClientV1ResponseTests.swift b/Tests/IDXClientV1ResponseTests.swift index 8e442f7e..686d476a 100644 --- a/Tests/IDXClientV1ResponseTests.swift +++ b/Tests/IDXClientV1ResponseTests.swift @@ -10,6 +10,11 @@ import XCTest class IDXClientV1ResponseTests: XCTestCase { typealias API = IDXClient.APIVersion1 + let clientMock = IDXClientAPIv1Mock(configuration: IDXClient.Configuration(issuer: "https://example.com", + clientId: "Bar", + clientSecret: nil, + scopes: ["scope"], + redirectUri: "redirect:/")) func data(for json: String) -> Data { return json.data(using: .utf8)! @@ -81,7 +86,7 @@ class IDXClientV1ResponseTests: XCTestCase { } } - func testFormValue() throws { + func testFormValueWithLabel() throws { try decode(type: API.Response.FormValue.self, """ { "name": "identifier", @@ -98,7 +103,9 @@ class IDXClientV1ResponseTests: XCTestCase { XCTAssertNil(obj.visible) XCTAssertNil(obj.value) } - + } + + func testFormValueWithNoLabel() throws { try decode(type: API.Response.FormValue.self, """ { "name": "stateHandle", @@ -118,7 +125,9 @@ class IDXClientV1ResponseTests: XCTestCase { XCTAssertFalse(obj.secret!) XCTAssertFalse(obj.mutable!) } - + } + + func testFormValueWithCompositeValue() throws { try decode(type: API.Response.FormValue.self, """ { "label": "Email", @@ -149,8 +158,39 @@ class IDXClientV1ResponseTests: XCTestCase { let form = obj.value?.toAnyObject() as? API.Response.CompositeForm XCTAssertNotNil(form) XCTAssertEqual(form?.form.value.count, 2) + + XCTAssertNotNil(obj.form) + XCTAssertEqual(obj.form?.value.count ?? 0, 2) } - + } + + func testFormValueWithNestedForm() throws { + try decode(type: API.Response.FormValue.self, """ + { + "name": "credentials", + "type": "object", + "form": { + "value": [{ + "name": "passcode", + "label": "Password", + "secret": true + }] + }, + "required": true + } + """) { (obj) in + XCTAssertNotNil(obj) + XCTAssertEqual(obj.name, "credentials") + XCTAssertEqual(obj.type, "object") + + let form = obj.form?.value + XCTAssertNotNil(form) + XCTAssertEqual(form?.count, 1) + XCTAssertEqual(form?.first?.name, "passcode") + } + } + + func testFormValueWithOptions() throws { try decode(type: API.Response.FormValue.self, """ { "name": "authenticator", @@ -168,6 +208,169 @@ class IDXClientV1ResponseTests: XCTestCase { XCTAssertNotNil(obj.options) XCTAssertEqual(obj.options?.count, 1) } - + } + + func testFormValueWithOptionsContainingCompositeValue() throws { + try decode(type: API.Response.FormValue.self, """ + { + "name" : "authenticator", + "options" : [ + { + "label" : "Email", + "relatesTo" : "$.authenticatorEnrollments.value[0]", + "value" : { + "form" : { + "value" : [ + { + "mutable" : false, + "name" : "id", + "required" : true, + "value" : "aut3jya5v1oIgaLuV0g7" + }, + { + "mutable" : false, + "name" : "methodType", + "required" : false, + "value" : "email" + } + ] + } + } + } + ], + "type" : "object" + } + """) { (obj) in + XCTAssertNotNil(obj) + XCTAssertEqual(obj.name, "authenticator") + XCTAssertEqual(obj.type, "object") + XCTAssertNotNil(obj.options) + XCTAssertEqual(obj.options?.count, 1) + + let option = obj.options?[0] + XCTAssertEqual(option?.label, "Email") + XCTAssertEqual(option?.form?.value.count, 2) + XCTAssertEqual(option?.form?.value[0].name, "id") + XCTAssertEqual(option?.form?.value[1].name, "methodType") + + let publicObj = IDXClient.Remediation.FormValue(client: clientMock, v1: obj) + XCTAssertNotNil(publicObj) + XCTAssertEqual(publicObj.name, "authenticator") + XCTAssertEqual(publicObj.type, "object") + XCTAssertNotNil(publicObj.options) + XCTAssertEqual(publicObj.options?.count, 1) + + let publicOption = publicObj.options?[0] + XCTAssertNotNil(publicOption) + XCTAssertEqual(publicOption?.label, "Email") + XCTAssertEqual(publicOption?.form?.count, 2) + XCTAssertEqual(publicOption?.form?[0].name, "id") + XCTAssertEqual(publicOption?.form?[1].name, "methodType") + } + } + + func testFormValueWithMessages() throws { + try decode(type: API.Response.FormValue.self, """ + { + "label" : "Answer", + "messages" : { + "type" : "array", + "value" : [ + { + "class" : "ERROR", + "i18n" : { + "key" : "authfactor.challenge.question_factor.answer_invalid" + }, + "message" : "Your answer doesn't match our records. Please try again." + } + ] + }, + "name" : "answer", + "required" : true + } + """) { (obj) in + XCTAssertNotNil(obj) + XCTAssertEqual(obj.name, "answer") + XCTAssertNotNil(obj.messages) + XCTAssertEqual(obj.messages?.type, "array") + XCTAssertEqual(obj.messages?.value.count, 1) + XCTAssertEqual(obj.messages?.value[0].type, "ERROR") + XCTAssertEqual(obj.messages?.value[0].i18n.key, "authfactor.challenge.question_factor.answer_invalid") + XCTAssertEqual(obj.messages?.value[0].message, "Your answer doesn't match our records. Please try again.") + + let publicObj = IDXClient.Remediation.FormValue(client: clientMock, v1: obj) + XCTAssertNotNil(publicObj) + XCTAssertNotNil(publicObj.messages) + XCTAssertEqual(publicObj.messages?.count, 1) + XCTAssertEqual(publicObj.messages?.first?.type, .error) + XCTAssertEqual(publicObj.messages?.first?.localizationKey, "authfactor.challenge.question_factor.answer_invalid") + XCTAssertEqual(publicObj.messages?.first?.message, "Your answer doesn't match our records. Please try again.") + } + } + + func testResponseWithMessages() throws { + try decode(type: API.Response.self, """ + { + "app" : { + "type" : "object", + "value" : { + "id" : "0ZczewGCFPlxNYYcLq5i", + "label" : "Test App", + "name" : "test_app" + } + }, + "cancel" : { + "accepts" : "application/json; okta-version=1.0.0", + "href" : "https://example.com/idp/idx/cancel", + "method" : "POST", + "name" : "cancel", + "produces" : "application/ion+json; okta-version=1.0.0", + "rel" : [ + "create-form" + ], + "value" : [ + { + "mutable" : false, + "name" : "stateHandle", + "required" : true, + "value" : "02n3QHV5ebMjjkDiCD53Iq439zXToRrX4QATZw4mEm", + "visible" : false + } + ] + }, + "expiresAt" : "2021-01-15T19:25:47.000Z", + "intent" : "LOGIN", + "messages" : { + "type" : "array", + "value" : [ + { + "class" : "ERROR", + "i18n" : { + "key" : "errors.E0000004" + }, + "message" : "Authentication failed" + } + ] + }, + "stateHandle" : "02n3QHV5ebMjjkDiCD53Iq439zXToRrX4QATZw4mEm", + "version" : "1.0.0" + } + """) { (obj) in + XCTAssertNotNil(obj) + XCTAssertNotNil(obj.messages) + XCTAssertEqual(obj.messages?.type, "array") + XCTAssertEqual(obj.messages?.value.count, 1) + XCTAssertEqual(obj.messages?.value[0].type, "ERROR") + XCTAssertEqual(obj.messages?.value[0].i18n.key, "errors.E0000004") + XCTAssertEqual(obj.messages?.value[0].message, "Authentication failed") + + let publicObj = IDXClient.Response(client: clientMock, v1: obj) + XCTAssertNotNil(publicObj) + XCTAssertNotNil(publicObj.messages) + XCTAssertEqual(publicObj.messages?.count, 1) + XCTAssertEqual(publicObj.messages?.first?.type, .error) + XCTAssertEqual(publicObj.messages?.first?.localizationKey, "errors.E0000004") + XCTAssertEqual(publicObj.messages?.first?.message, "Authentication failed") + } } } diff --git a/Tests/Mocks/IDXClientAPIMock.swift b/Tests/Mocks/IDXClientAPIMock.swift index 58e6df2a..23c44472 100644 --- a/Tests/Mocks/IDXClientAPIMock.swift +++ b/Tests/Mocks/IDXClientAPIMock.swift @@ -9,6 +9,8 @@ import Foundation @testable import OktaIdx class IDXClientAPIv1Mock: IDXClientAPIImpl { + var canCancel: Bool = true + let configuration: IDXClient.Configuration struct RecordedCall { diff --git a/Tests/URLSessionProtocolTests.swift b/Tests/URLSessionProtocolTests.swift index 8542ece8..6f9b02a7 100644 --- a/Tests/URLSessionProtocolTests.swift +++ b/Tests/URLSessionProtocolTests.swift @@ -17,7 +17,7 @@ class URLSessionProtocolTests: XCTestCase { httpVersion: nil, headerFields: nil) let httpFailureResponse = HTTPURLResponse(url: URL(string: "https://example.com/")!, - statusCode: 400, + statusCode: 401, httpVersion: nil, headerFields: nil) diff --git a/okta-idx.xcodeproj/project.pbxproj b/okta-idx.xcodeproj/project.pbxproj index bdfafda5..73afd14d 100644 --- a/okta-idx.xcodeproj/project.pbxproj +++ b/okta-idx.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 9617090F25AD109C000E9552 /* IDXVersion+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9617090E25AD109C000E9552 /* IDXVersion+Extension.swift */; }; 961BE4272583E73300DCE8AC /* IDXClientRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 961BE4262583E73300DCE8AC /* IDXClientRequestTests.swift */; }; 961BE42F2583EC6300DCE8AC /* TestExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 961BE42B2583EC6000DCE8AC /* TestExtensions.swift */; }; 961BE43A2583F8E900DCE8AC /* RequestHTTPHeaders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 961BE4392583F8E900DCE8AC /* RequestHTTPHeaders.swift */; }; @@ -31,7 +32,7 @@ 96DCDCFC258844AD009487CB /* ReceivesIONJSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96DCDCFB258844AD009487CB /* ReceivesIONJSON.swift */; }; 96F5417D258A843300D3995F /* IDXClient+V1ResponseConstructors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F5417C258A843300D3995F /* IDXClient+V1ResponseConstructors.swift */; }; 96F8F18E25802FAD00DA2701 /* OktaIdx.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 96F8F18425802FAD00DA2701 /* OktaIdx.framework */; }; - 96F8F1BA258049B000DA2701 /* IDXVersion+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F8F1B9258049B000DA2701 /* IDXVersion+Extension.swift */; }; + 96F8F1BA258049B000DA2701 /* IDXVersion+InternalExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F8F1B9258049B000DA2701 /* IDXVersion+InternalExtension.swift */; }; 96F8F1C125804A0C00DA2701 /* IDXClientVersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F8F1C025804A0C00DA2701 /* IDXClientVersionTests.swift */; }; 96F8F20B25815AFD00DA2701 /* IDXClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F8F20A25815AFD00DA2701 /* IDXClient.swift */; }; 96F8F2112581973F00DA2701 /* IDXClientAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F8F2102581973F00DA2701 /* IDXClientAPI.swift */; }; @@ -70,6 +71,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 9617090E25AD109C000E9552 /* IDXVersion+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IDXVersion+Extension.swift"; sourceTree = ""; }; 961BE4262583E73300DCE8AC /* IDXClientRequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IDXClientRequestTests.swift; sourceTree = ""; }; 961BE42B2583EC6000DCE8AC /* TestExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestExtensions.swift; sourceTree = ""; }; 961BE4392583F8E900DCE8AC /* RequestHTTPHeaders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestHTTPHeaders.swift; sourceTree = ""; }; @@ -96,7 +98,7 @@ 96F8F18425802FAD00DA2701 /* OktaIdx.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OktaIdx.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 96F8F18D25802FAD00DA2701 /* okta-idx-ios-tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "okta-idx-ios-tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 96F8F1AC25803BE100DA2701 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 96F8F1B9258049B000DA2701 /* IDXVersion+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IDXVersion+Extension.swift"; sourceTree = ""; }; + 96F8F1B9258049B000DA2701 /* IDXVersion+InternalExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IDXVersion+InternalExtension.swift"; sourceTree = ""; }; 96F8F1BF25804A0C00DA2701 /* okta-idx-ios-tests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "okta-idx-ios-tests-Bridging-Header.h"; sourceTree = ""; }; 96F8F1C025804A0C00DA2701 /* IDXClientVersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IDXClientVersionTests.swift; sourceTree = ""; }; 96F8F1CD25804B5B00DA2701 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -145,6 +147,16 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 9617090B25AD104E000E9552 /* Extensions */ = { + isa = PBXGroup; + children = ( + 964A44E4259E6870002CB250 /* IDXClient+Combine.swift */, + 966A1E7F258BEADB00864DB7 /* IDXClientError+Extensions.swift */, + 9617090E25AD109C000E9552 /* IDXVersion+Extension.swift */, + ); + path = Extensions; + sourceTree = ""; + }; 961BE42A2583EC4C00DCE8AC /* Utilities */ = { isa = PBXGroup; children = ( @@ -217,6 +229,7 @@ 96F8F1A125803B8500DA2701 /* Sources */ = { isa = PBXGroup; children = ( + 9617090B25AD104E000E9552 /* Extensions */, 96F8F2702583E2F800DA2701 /* Internal */, 96F8F1AC25803BE100DA2701 /* Info.plist */, 96F8F20A25815AFD00DA2701 /* IDXClient.swift */, @@ -228,11 +241,9 @@ 96F8F1B82580499900DA2701 /* Extensions */ = { isa = PBXGroup; children = ( - 964A44E4259E6870002CB250 /* IDXClient+Combine.swift */, 96F8F21825828F1300DA2701 /* IDXClient+Extension.swift */, 9696A40725892C74005D6E9D /* IDXClient+Response.swift */, - 966A1E7F258BEADB00864DB7 /* IDXClientError+Extensions.swift */, - 96F8F1B9258049B000DA2701 /* IDXVersion+Extension.swift */, + 96F8F1B9258049B000DA2701 /* IDXVersion+InternalExtension.swift */, ); path = Extensions; sourceTree = ""; @@ -448,6 +459,7 @@ 96F8F215258197D000DA2701 /* IDXClientAPIv1.swift in Sources */, 964A44AC259A6285002CB250 /* AcceptType.swift in Sources */, 961DE96325897C6B007CAB16 /* Responses.swift in Sources */, + 9617090F25AD109C000E9552 /* IDXVersion+Extension.swift in Sources */, 96F8F2722583E31800DA2701 /* IDXClientAPIImpl.swift in Sources */, 96F8F26C2582E9C900DA2701 /* IntrospectRequest.swift in Sources */, 961BE43E258406EC00DCE8AC /* PKCEExtensions.swift in Sources */, @@ -456,7 +468,7 @@ 96F8F26B2582E9C900DA2701 /* TokenRequest.swift in Sources */, 96F5417D258A843300D3995F /* IDXClient+V1ResponseConstructors.swift in Sources */, 966A1E80258BEADB00864DB7 /* IDXClientError+Extensions.swift in Sources */, - 96F8F1BA258049B000DA2701 /* IDXVersion+Extension.swift in Sources */, + 96F8F1BA258049B000DA2701 /* IDXVersion+InternalExtension.swift in Sources */, 96F8F2692582E9C900DA2701 /* RemediationRequest.swift in Sources */, 96F8F21925828F1300DA2701 /* IDXClient+Extension.swift in Sources */, );