diff --git a/Scripts/Tests/Run.sh b/Scripts/Tests/Run.sh index 35958ed39..ff899332e 100755 --- a/Scripts/Tests/Run.sh +++ b/Scripts/Tests/Run.sh @@ -6,7 +6,7 @@ PROJECT='ProcessOut.xcodeproj' DESTINATION=$(./Scripts/Tests/Destination.swift) # Run Tests -for PRODUCT in "ProcessOut" "ProcessOutUI" "ProcessOutNetcetera3DS"; do +for PRODUCT in "ProcessOut" "ProcessOutUI" "ProcessOutCheckout3DS" "ProcessOutNetcetera3DS"; do xcodebuild clean test \ -destination "$DESTINATION" \ -project $PROJECT \ diff --git a/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSService.swift b/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSService.swift index c5a161d6a..35d7ed1c6 100644 --- a/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSService.swift +++ b/Sources/ProcessOutCheckout3DS/Sources/Service/POCheckout3DSService.swift @@ -9,6 +9,10 @@ import Checkout3DS /// 3DS2 service implementation that is based on Checkout3DS. +/// +/// 3DS flow is strictly sequential: an authentication, optionally followed by a challenge, and +/// `clean()` at the end. The service does not serialize concurrent calls. Overlapping operations +/// are considered a developer error and are rejected. @MainActor public final class POCheckout3DSService: PO3DS2Service { @@ -17,6 +21,7 @@ public final class POCheckout3DSService: PO3DS2Service { self.errorMapper = DefaultAuthenticationErrorMapper() self.configurationMapper = DefaultConfigurationMapper() self.eventEmitter = ProcessOut.shared.eventEmitter + self.logger = ProcessOut.shared.logger self.delegate = delegate self.environment = environment } @@ -28,20 +33,30 @@ public final class POCheckout3DSService: PO3DS2Service { ) async throws -> PO3DS2AuthenticationRequestParameters { delegate?.checkout3DSService(self, willCreateAuthenticationRequestParametersWith: configuration) do { - let service = try Standalone3DSService.initialize( - with: await serviceConfiguration(with: configuration), environment: environment - ) - self.service = service - guard await delegate?.checkout3DSService(self, shouldContinueWith: await service.warnings) ?? true else { - throw POFailure(code: .Mobile.cancelled) + if Task.isCancelled { + throw POFailure(message: "Authentication was cancelled.", code: .Mobile.cancelled) } - let authenticationRequest = authenticationRequest( - with: try await service.createTransaction().getAuthenticationRequestParameters() - ) - delegate?.checkout3DSService( - self, didCreateAuthenticationRequestParameters: .success(authenticationRequest) - ) - return authenticationRequest + switch state { + case .idle: + break + case .ready: + throw POFailure( + message: "Previous transaction was not cleaned, call clean() first.", code: .Mobile.generic + ) + case .authenticating, .challenging: + throw POFailure(message: "Another operation is already in progress.", code: .Mobile.generic) + } + state = .authenticating + let (context, parameters): (TransactionContext, PO3DS2AuthenticationRequestParameters) + do { + (context, parameters) = try await createTransaction(configuration: configuration) + } catch { + state = .idle + throw error + } + state = .ready(context) + delegate?.checkout3DSService(self, didCreateAuthenticationRequestParameters: .success(parameters)) + return parameters } catch { let failure = failure(with: error) delegate?.checkout3DSService(self, didCreateAuthenticationRequestParameters: .failure(failure)) @@ -52,16 +67,18 @@ public final class POCheckout3DSService: PO3DS2Service { public func performChallenge(with parameters: PO3DS2ChallengeParameters) async throws -> PO3DS2ChallengeResult { delegate?.checkout3DSService(self, willPerformChallengeWith: parameters) do { - guard let transaction = service?.createTransaction() else { - throw POFailure(code: .Mobile.generic) + if Task.isCancelled { + throw POFailure(message: "Challenge was cancelled.", code: .Mobile.cancelled) } - observeDeepLinks() - let authenticationResult = try await transaction.doChallenge( - challengeParameters: challengeParameters(with: parameters) - ) - let challengeResult = PO3DS2ChallengeResult( - transactionStatus: authenticationResult.transactionStatus ?? "N" - ) + guard case .ready(let context) = state, + context.transactionId == parameters.threeDSServerTransactionId else { + throw POFailure(message: "Unable to resolve current transaction.", code: .Mobile.generic) + } + state = .challenging(context) + defer { + state = .ready(context) + } + let challengeResult = try await performChallenge(with: parameters, in: context) delegate?.checkout3DSService(self, didPerformChallenge: .success(challengeResult)) return challengeResult } catch { @@ -72,9 +89,37 @@ public final class POCheckout3DSService: PO3DS2Service { } public func clean() async { - deepLinkObservation = nil - service?.cleanUp() - service = nil + switch state { + case .idle: + break + case .ready(let context): + tearDown(service: context.service, transaction: context.transaction) + deepLinkObservation = nil + state = .idle + case .authenticating, .challenging: + logger.warn("Ignoring clean() while another operation is in progress.") + } + } + + // MARK: - Private Nested Types + + private enum State { + + /// No transaction. The only state from which authentication may start. + case idle + + /// Authentication request parameters are being created. + case authenticating + + /// Transaction exists and may be challenged or cleaned. + case ready(TransactionContext) + + /// Challenge is being performed on the transaction. + case challenging(TransactionContext) + } + + private struct TransactionContext { + let service: ThreeDS2Service, transaction: Transaction, transactionId: String } // MARK: - Private Properties @@ -82,13 +127,66 @@ public final class POCheckout3DSService: PO3DS2Service { private let errorMapper: AuthenticationErrorMapper private let configurationMapper: ConfigurationMapper private let eventEmitter: POEventEmitter + private let logger: POLogger private let environment: Checkout3DS.Environment private let delegate: POCheckout3DSServiceDelegate? - private var service: ThreeDS2Service? + private var state: State = .idle private var deepLinkObservation: AnyObject? + // MARK: - Authentication + + /// Initializes the SDK and creates a transaction. Resources created before a failure are + /// released here, so a failed authentication leaves nothing behind to clean. + /// + /// - NOTE: Transaction must be retained and reused for the challenge. Checkout's + /// `createTransaction()` closes the current transaction and creates a new one with fresh + /// SDK transaction ID and ephemeral keys, which would not match authentication request. + private func createTransaction( + configuration: PO3DS2Configuration + ) async throws -> (TransactionContext, PO3DS2AuthenticationRequestParameters) { + var initializedService: ThreeDS2Service?, createdTransaction: Transaction? + do { + let service = try Standalone3DSService.initialize( + with: await serviceConfiguration(with: configuration), environment: environment + ) + initializedService = service + if let delegate, await !delegate.checkout3DSService(self, shouldContinueWith: await service.warnings) { + throw POFailure(code: .Mobile.cancelled) + } + let transaction = service.createTransaction() + createdTransaction = transaction + let parameters = authenticationRequest(with: try await transaction.getAuthenticationRequestParameters()) + let context = TransactionContext( + service: service, transaction: transaction, transactionId: configuration.directoryServerTransactionId + ) + return (context, parameters) + } catch { + tearDown(service: initializedService, transaction: createdTransaction) + throw error + } + } + + // MARK: - Challenge + + private func performChallenge( + with parameters: PO3DS2ChallengeParameters, in context: TransactionContext + ) async throws -> PO3DS2ChallengeResult { + observeDeepLinks() + let authenticationResult = try await context.transaction.doChallenge( + challengeParameters: challengeParameters(with: parameters) + ) + return PO3DS2ChallengeResult(transactionStatus: authenticationResult.transactionStatus ?? "N") + } + + // MARK: - Clean + + private func tearDown(service: ThreeDS2Service?, transaction: Transaction?) { + transaction?.close() + service?.cleanUp() + } + // MARK: - Mapping private func serviceConfiguration( diff --git a/Sources/ProcessOutNetcetera3DS/Sources/Service/PONetcetera3DS2Service.swift b/Sources/ProcessOutNetcetera3DS/Sources/Service/PONetcetera3DS2Service.swift index 21d73b956..f55e65130 100644 --- a/Sources/ProcessOutNetcetera3DS/Sources/Service/PONetcetera3DS2Service.swift +++ b/Sources/ProcessOutNetcetera3DS/Sources/Service/PONetcetera3DS2Service.swift @@ -5,6 +5,8 @@ // Created by Andrii Vysotskyi on 22.04.2025. // +// swiftlint:disable type_body_length + @_spi(PO) import ProcessOut @_spi(PO) import NetceteraShim import ThreeDS_SDK @@ -137,7 +139,8 @@ public actor PONetcetera3DS2Service: PO3DS2Service { // MARK: - Private Properties private let configuration: PONetcetera3DS2ServiceConfiguration - private let eventEmitter: POEventEmitter, logger: POLogger + private let eventEmitter: POEventEmitter + private let logger: POLogger private var state: State = .idle private var deepLinkObservation: AnyObject? @@ -327,3 +330,5 @@ public actor PONetcetera3DS2Service: PO3DS2Service { } } } + +// swiftlint:enable type_body_length diff --git a/Tests/ProcessOutCheckout3DSTests/Sources/Checkout3DSServiceTests.swift b/Tests/ProcessOutCheckout3DSTests/Sources/Checkout3DSServiceTests.swift deleted file mode 100644 index 48243d281..000000000 --- a/Tests/ProcessOutCheckout3DSTests/Sources/Checkout3DSServiceTests.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// Checkout3DSServiceTests.swift -// ProcessOutCheckout3DSTests -// -// Created by Andrii Vysotskyi on 17.04.2023. -// - -import Testing -import ProcessOutCheckout3DS - -struct Checkout3DSServiceTests { - - @Test - func example() throws { } -} diff --git a/Tests/ProcessOutCheckout3DSTests/Sources/Integration/Service/Checkout3DSServiceTests.swift b/Tests/ProcessOutCheckout3DSTests/Sources/Integration/Service/Checkout3DSServiceTests.swift new file mode 100644 index 000000000..d4c9e34af --- /dev/null +++ b/Tests/ProcessOutCheckout3DSTests/Sources/Integration/Service/Checkout3DSServiceTests.swift @@ -0,0 +1,263 @@ +// +// Checkout3DSServiceTests.swift +// ProcessOutCheckout3DSTests +// +// Created by Andrii Vysotskyi on 17.04.2023. +// + +import Foundation +import Testing +import ProcessOut +import ProcessOutCheckout3DS +import Checkout3DS + +@MainActor +@Suite(.serialized) +struct Checkout3DSServiceTests { + + init() { + ProcessOut.configure(configuration: .init(projectId: "proj_test")) + } + + // MARK: - + + @Test + func authenticationRequestParameters_whenConfigurationIsInvalid_fails() async throws { + // Given + let sut = POCheckout3DSService() + let configuration = try Self.invalidConfiguration() + + // When + await withKnownIssue { + _ = try await sut.authenticationRequestParameters(configuration: configuration) + } + + // Cleanup + await sut.clean() + } + + @Test + func authenticationRequestParameters_whenConfigurationIsValid_succeeds() async throws { + // Given + let sut = POCheckout3DSService() + + // When + let requestParameters = try await sut.authenticationRequestParameters( + configuration: try Self.validConfiguration() + ) + + // Then + #expect(!requestParameters.deviceData.isEmpty) + + // Cleanup + await sut.clean() + } + + // MARK: - State + + @Test + func authenticationRequestParameters_whenPreviousTransactionIsNotCleaned_fails() async throws { + // Given + let sut = POCheckout3DSService() + _ = try await sut.authenticationRequestParameters(configuration: try Self.validConfiguration()) + + // When + let failure = await #expect(throws: POFailure.self) { + _ = try await sut.authenticationRequestParameters(configuration: try Self.validConfiguration()) + } + + // Then + #expect(failure?.message == "Previous transaction was not cleaned, call clean() first.") + + // Cleanup + await sut.clean() + } + + @Test + func authenticationRequestParameters_whenAnotherAuthenticationIsInProgress_fails() async throws { + // Given + let delegate = SuspendingDelegate() + let sut = POCheckout3DSService(delegate: delegate) + let configuration = try Self.validConfiguration() + let authentication = Task { + try await sut.authenticationRequestParameters(configuration: configuration) + } + await delegate.waitForSuspension() + + // When + let failure = await #expect(throws: POFailure.self) { + _ = try await sut.authenticationRequestParameters(configuration: try Self.validConfiguration()) + } + + // Then + #expect(failure?.message == "Another operation is already in progress.") + delegate.resume() + let requestParameters = try await authentication.value + #expect(!requestParameters.deviceData.isEmpty) + + // Cleanup + await sut.clean() + } + + @Test + func authenticationRequestParameters_afterFailure_succeeds() async throws { + // Given + let sut = POCheckout3DSService() + let invalidConfiguration = try Self.invalidConfiguration() + await withKnownIssue { + _ = try await sut.authenticationRequestParameters(configuration: invalidConfiguration) + } + + // When + let requestParameters = try await sut.authenticationRequestParameters( + configuration: try Self.validConfiguration() + ) + + // Then + #expect(!requestParameters.deviceData.isEmpty) + + // Cleanup + await sut.clean() + } + + @Test + func authenticationRequestParameters_afterClean_succeeds() async throws { + // Given + let sut = POCheckout3DSService() + _ = try await sut.authenticationRequestParameters(configuration: try Self.validConfiguration()) + await sut.clean() + + // When + let requestParameters = try await sut.authenticationRequestParameters( + configuration: try Self.validConfiguration() + ) + + // Then + #expect(!requestParameters.deviceData.isEmpty) + + // Cleanup + await sut.clean() + } + + @Test + func performChallenge_whenThereIsNoTransaction_fails() async throws { + // Given + let sut = POCheckout3DSService() + let parameters = try Self.challengeParameters(transactionId: "") + + // When + let failure = await #expect(throws: POFailure.self) { + _ = try await sut.performChallenge(with: parameters) + } + + // Then + #expect(failure?.message == "Unable to resolve current transaction.") + } + + @Test + func performChallenge_whenTransactionIdDoesNotMatch_fails() async throws { + // Given + let sut = POCheckout3DSService() + _ = try await sut.authenticationRequestParameters(configuration: try Self.validConfiguration()) + let parameters = try Self.challengeParameters(transactionId: "other") + + // When + let failure = await #expect(throws: POFailure.self) { + _ = try await sut.performChallenge(with: parameters) + } + + // Then + #expect(failure?.message == "Unable to resolve current transaction.") + + // Cleanup + await sut.clean() + } + + // MARK: - Private Methods + + private static func validConfiguration() throws -> PO3DS2Configuration { + try decode( + [ + "directoryServerID": "0000123456", + "directoryServerPublicKey": + """ + ewogICJrdHkiOiAiUlNBIiwKICAia2lkIjogImU0ODA1NGNkLTkxNDgtNGE3ZS1h\ + MjE5LWYyMTA1NzEyZmZkMyIsCiAgIm4iOiAiejZjWDFIMDRrTHRqcnJ2elIxVndz\ + OUtMTmF0ZkVOQ0JkMDliLUx4eDlKbENTMjRQaFZwbnBmU1ZrTThTYmNlWW9sLXJB\ + UUZTanYtVVpsTEFiUGY1R2lCNG9aSUV4XzBMc2V1WlNRZHNQNDVjM3FsVXc3eXFY\ + RmZDUVVqbUl0NEtvWndwOWNGdG1rRDFSemg1OFo0cjAzTnB6OVJ4X2dZQWk3bXlT\ + WGhSZ3IxYTBla3h6QjdoTDZDckRoNGRyTFJ3dEhfTHN3ZzBpRzBnUmRVVEdrZXNp\ + TU14RnFRa3ZDQ0loQUIxVFEzNnctUHQtYk8yWXFFUDMteWIwd1dicU9CQ0lseGRH\ + UXh2V0gydkJsTkNCZDhrMjNVYUZiYlYzVE9rWVQtdGhkOXByM0RuQ3JXWUdpWTdB\ + NVF3MUdDS0FCd3QtOWF0OHZOQnQ4Q2Q4WXNXeFk0a3JRIiwKICAiZSI6ICJBUUFC\ + IiwKfQ + """, + "directoryServerRootCAs": [], + "threeDSServerTransID": UUID().uuidString, + "messageVersion": "2.2.0" + ] + ) + } + + private static func invalidConfiguration() throws -> PO3DS2Configuration { + try decode( + [ + "directoryServerID": "", + "directoryServerPublicKey": "", + "directoryServerRootCAs": [], + "threeDSServerTransID": "", + "messageVersion": "" + ] + ) + } + + private static func challengeParameters(transactionId: String) throws -> PO3DS2ChallengeParameters { + try decode( + [ + "acsTransID": "", + "acsReferenceNumber": "", + "acsSignedContent": "", + "threeDSServerTransID": transactionId + ] + ) + } + + /// Request types have no public initializers, so fixtures are decoded the same way the SDK + /// receives them from the API. + private static func decode(_ object: [String: Any]) throws -> T { + let data = try JSONSerialization.data(withJSONObject: object) + return try JSONDecoder().decode(T.self, from: data) + } +} + +/// Suspends authentication when asked whether to continue, until resumed by the test. +@MainActor +private final class SuspendingDelegate: POCheckout3DSServiceDelegate { + + func waitForSuspension() async { + guard continuation == nil else { + return + } + await withCheckedContinuation { suspensionContinuation = $0 } + } + + func resume() { + continuation?.resume(returning: true) + continuation = nil + } + + // MARK: - POCheckout3DSServiceDelegate + + func checkout3DSService(_ service: POCheckout3DSService, shouldContinueWith warnings: Set) async -> Bool { + await withCheckedContinuation { continuation in + self.continuation = continuation + suspensionContinuation?.resume() + suspensionContinuation = nil + } + } + + // MARK: - Private Properties + + private var continuation: CheckedContinuation? + private var suspensionContinuation: CheckedContinuation? +}