From f641b24b55af5a268304faecc32373983fc0a534 Mon Sep 17 00:00:00 2001 From: Peter-John Welcome Date: Thu, 9 Jul 2026 17:10:23 +0200 Subject: [PATCH] Switch Zap Pusher to terminal-only response and add shared PKCS#1 encryption Refactor the Zap listen path to the narrow Charge3DSResponse / ChargeCardTransaction shape shared with card 3DS and mobile money, replacing the reused PayWithTransfer shape. The server only publishes terminal success/failed events on the Zap channel, so the ZapViewModel loop collapses to a single-shot await per the PusherSubscriptionListener contract. - Zap: listenForZapResponse now returns Service; repository maps to ChargeCardTransaction; ZapViewModel drops the re-subscribe loop. - Cryptography: expose public Cryptography + CryptographyProtocol with an encryptPKCS1 (RSA PKCS#1 v1.5) method mirroring Node's crypto.publicEncrypt. - Add EmptyRequest Encodable model for bodyless POST endpoints. - Add provider assets (Capitec Pay, Scan to Pay, SnapScan, SA flag) and Pusher/response fixtures (CapitecPay, QR, Zap success/failed) as groundwork for the Capitec Pay and Scan-to-Pay charge flows. - Tests: PKCS#1 round-trip + non-determinism coverage, Zap success/failed decode tests, and updated Zap repository/view-model/mock expectations. --- Package.swift | 10 ++- Sources/PaystackSDK/API/Charge/Zap.swift | 18 ++--- .../Core/Models/Models/EmptyRequest.swift | 5 ++ .../Utils/Cryptography/Cryptography.swift | 22 ++++- .../Charge/Zap/Repository/ZapRepository.swift | 6 +- .../Charge/Zap/Viewmodels/ZapViewModel.swift | 21 ++--- .../CapitecPayMark.png | Bin 0 -> 5202 bytes .../capitecPayLogo.imageset/Contents.json | 21 +++++ .../scanToPayLogo.imageset/Contents.json | 21 +++++ .../scan-to-pay_logo-80-UG7U5qv_.png | Bin 0 -> 4621 bytes .../snapScanLogo.imageset/Contents.json | 21 +++++ .../snapscan_logo-BYsTYDYq.svg | 14 ++++ .../1f1ff-1f1e6.svg | 1 + .../Contents.json | 21 +++++ .../CapitecPayAuthenticateResponse.json | 11 +++ .../Resources/CapitecPayPusherFailed.json | 6 ++ .../Resources/CapitecPayPusherSuccess.json | 7 ++ .../Charge/Resources/QRGenerateResponse.json | 11 +++ .../API/Charge/Resources/QRPusherFailed.json | 6 ++ .../API/Charge/Resources/QRPusherSuccess.json | 7 ++ .../API/Charge/Resources/ZapPusherFailed.json | 6 ++ .../Charge/Resources/ZapPusherSuccess.json | 7 ++ .../API/Charge/ZapTests.swift | 17 +++- .../Core/CryptographyEncryptPKCS1Tests.swift | 76 ++++++++++++++++++ .../Core/CryptographyTests.swift | 51 ++++++++++++ .../UI/Charge/Mocks/MockZapRepository.swift | 4 +- .../UI/Charge/Zap/ZapViewModelTests.swift | 55 ++++--------- .../ZapRepositoryImplementationTests.swift | 22 +---- 28 files changed, 378 insertions(+), 89 deletions(-) create mode 100644 Sources/PaystackSDK/Core/Models/Models/EmptyRequest.swift create mode 100644 Sources/PaystackUI/Images/Images.xcassets/capitecPayLogo.imageset/CapitecPayMark.png create mode 100644 Sources/PaystackUI/Images/Images.xcassets/capitecPayLogo.imageset/Contents.json create mode 100644 Sources/PaystackUI/Images/Images.xcassets/scanToPayLogo.imageset/Contents.json create mode 100644 Sources/PaystackUI/Images/Images.xcassets/scanToPayLogo.imageset/scan-to-pay_logo-80-UG7U5qv_.png create mode 100644 Sources/PaystackUI/Images/Images.xcassets/snapScanLogo.imageset/Contents.json create mode 100644 Sources/PaystackUI/Images/Images.xcassets/snapScanLogo.imageset/snapscan_logo-BYsTYDYq.svg create mode 100644 Sources/PaystackUI/Images/Images.xcassets/southAfricaFlagLogo.imageset/1f1ff-1f1e6.svg create mode 100644 Sources/PaystackUI/Images/Images.xcassets/southAfricaFlagLogo.imageset/Contents.json create mode 100644 Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayAuthenticateResponse.json create mode 100644 Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherFailed.json create mode 100644 Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherSuccess.json create mode 100644 Tests/PaystackSDKTests/API/Charge/Resources/QRGenerateResponse.json create mode 100644 Tests/PaystackSDKTests/API/Charge/Resources/QRPusherFailed.json create mode 100644 Tests/PaystackSDKTests/API/Charge/Resources/QRPusherSuccess.json create mode 100644 Tests/PaystackSDKTests/API/Charge/Resources/ZapPusherFailed.json create mode 100644 Tests/PaystackSDKTests/API/Charge/Resources/ZapPusherSuccess.json create mode 100644 Tests/PaystackSDKTests/Core/CryptographyEncryptPKCS1Tests.swift diff --git a/Package.swift b/Package.swift index 0efc075..1943515 100644 --- a/Package.swift +++ b/Package.swift @@ -50,7 +50,15 @@ let package = Package( .copy("API/Charge/Resources/PayWithTransferPusherCreditPending.json"), .copy("API/Charge/Resources/PayWithTransferPusherCreditRejected.json"), .copy("API/Charge/Resources/PayWithTransferPusherIncorrectAmount.json"), - .copy("API/Charge/Resources/ZapMandateResponse.json") + .copy("API/Charge/Resources/ZapMandateResponse.json"), + .copy("API/Charge/Resources/CapitecPayAuthenticateResponse.json"), + .copy("API/Charge/Resources/QRGenerateResponse.json"), + .copy("API/Charge/Resources/ZapPusherSuccess.json"), + .copy("API/Charge/Resources/ZapPusherFailed.json"), + .copy("API/Charge/Resources/CapitecPayPusherSuccess.json"), + .copy("API/Charge/Resources/CapitecPayPusherFailed.json"), + .copy("API/Charge/Resources/QRPusherSuccess.json"), + .copy("API/Charge/Resources/QRPusherFailed.json") ]) ] diff --git a/Sources/PaystackSDK/API/Charge/Zap.swift b/Sources/PaystackSDK/API/Charge/Zap.swift index 393cd71..6568173 100644 --- a/Sources/PaystackSDK/API/Charge/Zap.swift +++ b/Sources/PaystackSDK/API/Charge/Zap.swift @@ -23,21 +23,21 @@ public extension Paystack { } /// Listens for Zap status updates on the Pusher channel returned by - /// ``initiateZapMandate(_:)``. The status taxonomy is shared with - /// Pay-with-Transfer, so this - /// helper returns the existing `PayWithTransferPusherResponse` shape. + /// ``initiateZapMandate(_:)``. The server publishes only terminal + /// events (`success` / `failed`) on the Zap channel, so this helper + /// returns the narrow ``Charge3DSResponse`` shape shared with card + /// 3-D Secure and mobile money authorization. /// /// The underlying listener is single-shot per the existing - /// `PusherSubscriptionListener` contract ; callers that need to keep - /// listening through transient statuses must re-subscribe after each - /// event. + /// `PusherSubscriptionListener` contract — one event resolves the + /// listener. /// /// - Parameter channelName: The `pusherChannel` value returned from /// `initiateZapMandate` (e.g. `DBMAN_6222375579`). - /// - Returns: A ``Service`` carrying a ``PayWithTransferPusherResponse`` - /// on the first event the channel emits. + /// - Returns: A ``Service`` carrying a ``Charge3DSResponse`` on the + /// first event the channel emits. func listenForZapResponse(onChannel channelName: String) - -> Service { + -> Service { let subscription: any Subscription = PusherSubscription( channelName: channelName, eventName: "response") return Service(subscription) diff --git a/Sources/PaystackSDK/Core/Models/Models/EmptyRequest.swift b/Sources/PaystackSDK/Core/Models/Models/EmptyRequest.swift new file mode 100644 index 0000000..2b95589 --- /dev/null +++ b/Sources/PaystackSDK/Core/Models/Models/EmptyRequest.swift @@ -0,0 +1,5 @@ +import Foundation + +public struct EmptyRequest: Encodable, Equatable { + public init() {} +} diff --git a/Sources/PaystackSDK/Core/Utils/Cryptography/Cryptography.swift b/Sources/PaystackSDK/Core/Utils/Cryptography/Cryptography.swift index b86229e..552c244 100644 --- a/Sources/PaystackSDK/Core/Utils/Cryptography/Cryptography.swift +++ b/Sources/PaystackSDK/Core/Utils/Cryptography/Cryptography.swift @@ -1,6 +1,12 @@ import Foundation -struct Cryptography { +public protocol CryptographyProtocol { + func encryptPKCS1(text: String, publicKey: String) throws -> String +} + +public struct Cryptography: CryptographyProtocol { + + public init() {} func encrypt(text: String, publicKey: String) throws -> String { let key = try createKey(from: publicKey, isPublic: true) @@ -21,6 +27,20 @@ struct Cryptography { } return try encrypt(text: jsonString, publicKey: publicKey) } + + /// RSA-encrypts `text` with PKCS#1 v1.5 padding and base64-encodes the result. + /// Mirrors Node's `crypto.publicEncrypt` with `constants.RSA_PKCS1_PADDING`. + public func encryptPKCS1(text: String, publicKey: String) throws -> String { + let key = try createKey(from: publicKey, isPublic: true) + + var encryptionError: Unmanaged? + guard let textData = text.data(using: .utf8), + let encryptedData = SecKeyCreateEncryptedData(key, .rsaEncryptionPKCS1, + textData as CFData, &encryptionError) as Data? else { + throw CryptographyError.encryptionFailed + } + return encryptedData.base64EncodedString() + } } // MARK: - Preparing Key diff --git a/Sources/PaystackUI/Charge/Zap/Repository/ZapRepository.swift b/Sources/PaystackUI/Charge/Zap/Repository/ZapRepository.swift index d38c2fa..c6bb20b 100644 --- a/Sources/PaystackUI/Charge/Zap/Repository/ZapRepository.swift +++ b/Sources/PaystackUI/Charge/Zap/Repository/ZapRepository.swift @@ -8,7 +8,7 @@ protocol ZapRepository { walletEmail: String) async throws -> ZapMandateResponse func listenForZapResponse(onChannel channelName: String) - async throws -> BankTransferTransactionUpdate + async throws -> ChargeCardTransaction } struct ZapRepositoryImplementation: ZapRepository { @@ -29,9 +29,9 @@ struct ZapRepositoryImplementation: ZapRepository { } func listenForZapResponse(onChannel channelName: String) - async throws -> BankTransferTransactionUpdate { + async throws -> ChargeCardTransaction { let response = try await paystack .listenForZapResponse(onChannel: channelName).async() - return BankTransferTransactionUpdate.from(response) + return ChargeCardTransaction.from(response) } } diff --git a/Sources/PaystackUI/Charge/Zap/Viewmodels/ZapViewModel.swift b/Sources/PaystackUI/Charge/Zap/Viewmodels/ZapViewModel.swift index 28d6383..0a356d1 100644 --- a/Sources/PaystackUI/Charge/Zap/Viewmodels/ZapViewModel.swift +++ b/Sources/PaystackUI/Charge/Zap/Viewmodels/ZapViewModel.swift @@ -97,33 +97,26 @@ class ZapViewModel: ObservableObject { } } - // MARK: - Pusher listen loop + // MARK: - Pusher (single-shot) private func startListeningForPusher(on details: ZapDetails) { pusherTask?.cancel() let channel = details.pusherChannel pusherTask = Task { [weak self] in - await self?.listenLoop(on: channel) - } - } - - private func listenLoop(on channel: String) async { - while !Task.isCancelled { + guard let self else { return } do { - let update = try await repository + let update = try await self.repository .listenForZapResponse(onChannel: channel) - await processTransactionUpdate(update) - if update.status.isTerminal { return } + await self.processTransactionUpdate(update) } catch { - Logger.error("Zap Pusher iteration failed: %@", + Logger.error("Zap Pusher await failed: %@", arguments: error.localizedDescription) - return } } } @MainActor - func processTransactionUpdate(_ update: BankTransferTransactionUpdate) async { + func processTransactionUpdate(_ update: ChargeCardTransaction) async { switch update.status { case .success: @@ -136,7 +129,7 @@ class ZapViewModel: ObservableObject { state = .error(ChargeError(message: message)) default: - Logger.info("Zap: unexpected Pusher status %@", + Logger.info("Zap: non-terminal transaction status %@", arguments: String(describing: update.status)) } } diff --git a/Sources/PaystackUI/Images/Images.xcassets/capitecPayLogo.imageset/CapitecPayMark.png b/Sources/PaystackUI/Images/Images.xcassets/capitecPayLogo.imageset/CapitecPayMark.png new file mode 100644 index 0000000000000000000000000000000000000000..8c779bd0edff25ead58a8fdc9810cee8112e6774 GIT binary patch literal 5202 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 D##`Uq literal 0 HcmV?d00001 diff --git a/Sources/PaystackUI/Images/Images.xcassets/snapScanLogo.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/snapScanLogo.imageset/Contents.json new file mode 100644 index 0000000..24e86a2 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/snapScanLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "snapscan_logo-BYsTYDYq.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/PaystackUI/Images/Images.xcassets/snapScanLogo.imageset/snapscan_logo-BYsTYDYq.svg b/Sources/PaystackUI/Images/Images.xcassets/snapScanLogo.imageset/snapscan_logo-BYsTYDYq.svg new file mode 100644 index 0000000..1124c4a --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/snapScanLogo.imageset/snapscan_logo-BYsTYDYq.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Sources/PaystackUI/Images/Images.xcassets/southAfricaFlagLogo.imageset/1f1ff-1f1e6.svg b/Sources/PaystackUI/Images/Images.xcassets/southAfricaFlagLogo.imageset/1f1ff-1f1e6.svg new file mode 100644 index 0000000..275c136 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/southAfricaFlagLogo.imageset/1f1ff-1f1e6.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Sources/PaystackUI/Images/Images.xcassets/southAfricaFlagLogo.imageset/Contents.json b/Sources/PaystackUI/Images/Images.xcassets/southAfricaFlagLogo.imageset/Contents.json new file mode 100644 index 0000000..6a25e50 --- /dev/null +++ b/Sources/PaystackUI/Images/Images.xcassets/southAfricaFlagLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "1f1ff-1f1e6.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayAuthenticateResponse.json b/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayAuthenticateResponse.json new file mode 100644 index 0000000..ad64c25 --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayAuthenticateResponse.json @@ -0,0 +1,11 @@ +{ + "status": true, + "type": "success", + "code": "ok", + "data": { + "status": "success", + "timeToLive": 120, + "expiryDate": "2026-07-07T12:29:20.000Z" + }, + "message": "Charge pending" +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherFailed.json b/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherFailed.json new file mode 100644 index 0000000..390b7bf --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherFailed.json @@ -0,0 +1,6 @@ +{ + "status": "failed", + "trans": "5900549926", + "trxref": "T_ref_5900549926", + "message": "Bank declined" +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherSuccess.json b/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherSuccess.json new file mode 100644 index 0000000..cc8acc0 --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/CapitecPayPusherSuccess.json @@ -0,0 +1,7 @@ +{ + "status": "success", + "trans": "5900549926", + "trxref": "T_ref_5900549926", + "reference": "T_ref_5900549926", + "message": "Payment approved" +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/QRGenerateResponse.json b/Tests/PaystackSDKTests/API/Charge/Resources/QRGenerateResponse.json new file mode 100644 index 0000000..af0f07a --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/QRGenerateResponse.json @@ -0,0 +1,11 @@ +{ + "status": true, + "message": "QR successfully generated", + "data": { + "errors": false, + "url": "https://s3.eu-west-1.amazonaws.com/files.paystack.co/qr/mpass_olti/518262/1783510266603.png?X-Amz-Signature=abc", + "qr_code": "1490884538", + "status": "success", + "channel": "api_mpass_olti_qr_51826223921246" + } +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherFailed.json b/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherFailed.json new file mode 100644 index 0000000..842c2e5 --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherFailed.json @@ -0,0 +1,6 @@ +{ + "status": "failed", + "trans": "5900549926", + "trxref": "T_qr_5900549926", + "message": "Wallet declined the payment" +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherSuccess.json b/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherSuccess.json new file mode 100644 index 0000000..6eda0ea --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/QRPusherSuccess.json @@ -0,0 +1,7 @@ +{ + "status": "success", + "trans": "5900549926", + "trxref": "T_qr_5900549926", + "reference": "T_qr_5900549926", + "message": "Payment received" +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/ZapPusherFailed.json b/Tests/PaystackSDKTests/API/Charge/Resources/ZapPusherFailed.json new file mode 100644 index 0000000..1a89c55 --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/ZapPusherFailed.json @@ -0,0 +1,6 @@ +{ + "status": "failed", + "trans": "6222375579", + "trxref": "T_zap_6222375579", + "message": "Bank declined the mandate" +} diff --git a/Tests/PaystackSDKTests/API/Charge/Resources/ZapPusherSuccess.json b/Tests/PaystackSDKTests/API/Charge/Resources/ZapPusherSuccess.json new file mode 100644 index 0000000..b5b107b --- /dev/null +++ b/Tests/PaystackSDKTests/API/Charge/Resources/ZapPusherSuccess.json @@ -0,0 +1,7 @@ +{ + "status": "success", + "trans": "6222375579", + "trxref": "T_zap_6222375579", + "reference": "T_zap_6222375579", + "message": "Payment successful" +} diff --git a/Tests/PaystackSDKTests/API/Charge/ZapTests.swift b/Tests/PaystackSDKTests/API/Charge/ZapTests.swift index 2d2c811..0b568b4 100644 --- a/Tests/PaystackSDKTests/API/Charge/ZapTests.swift +++ b/Tests/PaystackSDKTests/API/Charge/ZapTests.swift @@ -56,11 +56,24 @@ final class ZapTests: PSTestCase { let channelName = "DBMAN_6222375579" mockSubscriptionListener .expectSubscription(PusherSubscription(channelName: channelName, eventName: "response")) - .andReturnString(fromJson: "PayWithTransferPusherSuccess") + .andReturnString(fromJson: "ZapPusherSuccess") let result = try await serviceUnderTest .listenForZapResponse(onChannel: channelName).async() - XCTAssertEqual(result.status, "success") + XCTAssertEqual(result.status, .success) + } + + func testListenForZapResponseDecodesFailedShape() async throws { + let channelName = "DBMAN_6222375579" + mockSubscriptionListener + .expectSubscription(PusherSubscription(channelName: channelName, eventName: "response")) + .andReturnString(fromJson: "ZapPusherFailed") + + let result = try await serviceUnderTest + .listenForZapResponse(onChannel: channelName).async() + + XCTAssertEqual(result.status, .failed) + XCTAssertEqual(result.message, "Bank declined the mandate") } } diff --git a/Tests/PaystackSDKTests/Core/CryptographyEncryptPKCS1Tests.swift b/Tests/PaystackSDKTests/Core/CryptographyEncryptPKCS1Tests.swift new file mode 100644 index 0000000..20b6308 --- /dev/null +++ b/Tests/PaystackSDKTests/Core/CryptographyEncryptPKCS1Tests.swift @@ -0,0 +1,76 @@ +import XCTest +@testable import PaystackCore + +final class CryptographyEncryptPKCS1Tests: XCTestCase { + + private var privateKey: SecKey! + private var publicKeyBase64: String! + + override func setUpWithError() throws { + try super.setUpWithError() + let attributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeRSA, + kSecAttrKeySizeInBits as String: 2048 + ] + var error: Unmanaged? + guard let generated = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else { + throw XCTSkip("Could not generate RSA key pair: \(error.debugDescription)") + } + privateKey = generated + + guard let publicKey = SecKeyCopyPublicKey(privateKey) else { + throw XCTSkip("Could not copy public key from generated pair") + } + var exportError: Unmanaged? + guard let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, &exportError) as Data? else { + throw XCTSkip("Could not export public key: \(exportError.debugDescription)") + } + publicKeyBase64 = publicKeyData.base64EncodedString() + } + + func testEncryptPKCS1RoundTripsCellphoneIdentifierPlaintext() throws { + try assertRoundTrip("CELLPHONE*0609603632") + } + + func testEncryptPKCS1RoundTripsIDNumberIdentifierPlaintext() throws { + try assertRoundTrip("IDNUMBER*8001015009087") + } + + func testEncryptPKCS1RoundTripsAccountNumberIdentifierPlaintext() throws { + try assertRoundTrip("ACCOUNTNUMBER*123456789") + } + + func testEncryptPKCS1ProducesNonDeterministicCiphertext() throws { + let sut = Cryptography() + let first = try sut.encryptPKCS1(text: "CELLPHONE*0609603632", + publicKey: publicKeyBase64) + let second = try sut.encryptPKCS1(text: "CELLPHONE*0609603632", + publicKey: publicKeyBase64) + XCTAssertNotEqual(first, second, + "PKCS#1 v1.5 padding is random ; two encryptions of the same plaintext must not be byte-equal") + } + + private func assertRoundTrip(_ plaintext: String, + file: StaticString = #filePath, + line: UInt = #line) throws { + let sut = Cryptography() + let base64Ciphertext = try sut.encryptPKCS1(text: plaintext, + publicKey: publicKeyBase64) + let ciphertext = try XCTUnwrap(Data(base64Encoded: base64Ciphertext), + file: file, line: line) + + var decryptError: Unmanaged? + guard let decryptedCF = SecKeyCreateDecryptedData(privateKey, + .rsaEncryptionPKCS1, + ciphertext as CFData, + &decryptError) else { + XCTFail("Decryption failed: \(decryptError.debugDescription)", + file: file, line: line) + return + } + let decrypted = decryptedCF as Data + let recovered = try XCTUnwrap(String(data: decrypted, encoding: .utf8), + file: file, line: line) + XCTAssertEqual(recovered, plaintext, file: file, line: line) + } +} diff --git a/Tests/PaystackSDKTests/Core/CryptographyTests.swift b/Tests/PaystackSDKTests/Core/CryptographyTests.swift index bbbbcc4..bd3c052 100644 --- a/Tests/PaystackSDKTests/Core/CryptographyTests.swift +++ b/Tests/PaystackSDKTests/Core/CryptographyTests.swift @@ -30,6 +30,41 @@ final class CryptographyTests: XCTestCase { XCTAssertEqual(decryptedString, clearText) } + func testPKCS1EncryptionRoundTripsToOriginalText() throws { + let clearText = "Hello World" + + let encryptedData = try serviceUnderTest.encryptPKCS1(text: clearText, publicKey: publicKey) + let decryptedString = try serviceUnderTest.decryptPKCS1(base64String: encryptedData, + privateKey: privateKey) + + XCTAssertEqual(decryptedString, clearText) + } + + func testPKCS1EncryptionWithSpecialCharactersRoundTrips() throws { + let mockCardConcatenation = "1234567890123456*123*01*23" + + let encryptedData = try serviceUnderTest.encryptPKCS1(text: mockCardConcatenation, + publicKey: publicKey) + let decryptedString = try serviceUnderTest.decryptPKCS1(base64String: encryptedData, + privateKey: privateKey) + + XCTAssertEqual(decryptedString, mockCardConcatenation) + } + + func testPKCS1EncryptionOfTextOverLengthLimitThrowsError() { + let clearText = [String](repeating: "a", count: 200).joined(separator: "") + + XCTAssertThrowsError(try serviceUnderTest.encryptPKCS1(text: clearText, publicKey: publicKey)) { error in + XCTAssertEqual(error as? CryptographyError, CryptographyError.encryptionFailed) + } + } + + func testPKCS1EncryptionWithNonBase64PublicKeyThrowsError() { + XCTAssertThrowsError(try serviceUnderTest.encryptPKCS1(text: "Hello World", publicKey: "ABC")) { error in + XCTAssertEqual(error as? CryptographyError, CryptographyError.invalidBase64String) + } + } + func testEncryptionWithSpecialCharacters() { let mockCardConcatenation = "1234567890123456*123*01*23" guard let encryptedData = try? serviceUnderTest.encrypt(text: mockCardConcatenation, @@ -186,6 +221,22 @@ extension Cryptography { return decryptedString } + func decryptPKCS1(base64String: String, privateKey: String) throws -> String { + guard let data = Data(base64Encoded: base64String) else { + throw CryptographyError.invalidBase64String + } + let key = try createKey(from: privateKey, isPublic: false) + + var error: Unmanaged? + guard let decrypted = SecKeyCreateDecryptedData(key, .rsaEncryptionPKCS1, + data as CFData, &error), + let decryptedString = String(data: decrypted as Data, encoding: .utf8) else { + throw CryptographyError.decryptionFailed + } + + return decryptedString + } + func decrypt(base64String: String, privateKey: String) throws -> T { let decryptedString = try decrypt(base64String: base64String, privateKey: privateKey) guard let encodedData = decryptedString.data(using: .utf8), diff --git a/Tests/PaystackSDKTests/UI/Charge/Mocks/MockZapRepository.swift b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockZapRepository.swift index 02e42c9..4328776 100644 --- a/Tests/PaystackSDKTests/UI/Charge/Mocks/MockZapRepository.swift +++ b/Tests/PaystackSDKTests/UI/Charge/Mocks/MockZapRepository.swift @@ -7,7 +7,7 @@ class MockZapRepository: ZapRepository { var expectedMandateResponse: ZapMandateResponse? var expectedErrorResponse: Error? - var expectedListenForZapResponses: [BankTransferTransactionUpdate] = [] + var expectedListenForZapResponses: [ChargeCardTransaction] = [] var expectedListenForZapError: Error? var initiateZapMandateSubmitted: (supportedBankId: Int, @@ -30,7 +30,7 @@ class MockZapRepository: ZapRepository { } func listenForZapResponse(onChannel channelName: String) - async throws -> BankTransferTransactionUpdate { + async throws -> ChargeCardTransaction { listenForZapResponseCallCount += 1 lastListenedChannel = channelName diff --git a/Tests/PaystackSDKTests/UI/Charge/Zap/ZapViewModelTests.swift b/Tests/PaystackSDKTests/UI/Charge/Zap/ZapViewModelTests.swift index a3ee29c..ceb9a93 100644 --- a/Tests/PaystackSDKTests/UI/Charge/Zap/ZapViewModelTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/Zap/ZapViewModelTests.swift @@ -180,12 +180,11 @@ final class ZapViewModelTests: XCTestCase { XCTAssertEqual(serviceUnderTest.state, .error(error)) } - // MARK: - processTransactionUpdate — success + failed are the only - // statuses Zap emits ; everything else is logged + state unchanged. + // MARK: - processTransactionUpdate — success + failed only. func testProcessUpdateWithSuccessCallsContainerProcessSuccessfulTransaction() async { await serviceUnderTest.processTransactionUpdate( - .init(status: .success, message: nil, reference: nil, transactionId: nil)) + ChargeCardTransaction(status: .success)) XCTAssertTrue(mockChargeContainer.transactionSuccessful) } @@ -194,9 +193,7 @@ final class ZapViewModelTests: XCTestCase { await serviceUnderTest.initiateMandate() await serviceUnderTest.processTransactionUpdate( - .init(status: .failed, - message: "Bank declined", - reference: nil, transactionId: nil)) + ChargeCardTransaction(status: .failed, message: "Bank declined")) XCTAssertEqual(serviceUnderTest.state, .error(ChargeError(message: "Bank declined"))) @@ -204,45 +201,27 @@ final class ZapViewModelTests: XCTestCase { func testProcessUpdateWithFailedFallsBackToDefaultMessageWhenNil() async { await serviceUnderTest.processTransactionUpdate( - .init(status: .failed, message: nil, - reference: nil, transactionId: nil)) + ChargeCardTransaction(status: .failed)) XCTAssertEqual(serviceUnderTest.state, .error(ChargeError(message: ZapViewModel.failedFallbackMessage))) } - /// Zap doesn't emit any of the PWT-shared status cases beyond - /// `success` / `failed` ; if one ever arrives (forward compat with - /// shared types) the SDK logs + leaves state unchanged. Regression - /// guard against accidentally wiring a state change for these cases - /// in the future. - func testProcessUpdateWithUnexpectedStatusesDoesNotChangeState() async { - let unexpectedStatuses: [BankTransferStatus] = [ - .creditRequestPending, - .creditRequestReceived, - .creditRequestRejected, - .incorrectAmountSent, - .pending, - .requery, - .unknown("brand-new") - ] + func testProcessUpdateWithNonTerminalStatusDoesNotChangeState() async { mockRepository.expectedMandateResponse = .example await serviceUnderTest.initiateMandate() let stateBefore = serviceUnderTest.state - for status in unexpectedStatuses { - await serviceUnderTest.processTransactionUpdate( - .init(status: status, message: nil, - reference: nil, transactionId: nil)) - XCTAssertEqual(serviceUnderTest.state, stateBefore, - "Status \(status) unexpectedly changed state") - } + await serviceUnderTest.processTransactionUpdate( + ChargeCardTransaction(status: .pending)) + + XCTAssertEqual(serviceUnderTest.state, stateBefore) } - // MARK: - Listen loop + // MARK: - Listen loop (single-shot) - func testProvisioningStartsListenLoopOnReturnedChannel() async { + func testProvisioningStartsListenOnReturnedChannel() async { mockRepository.expectedMandateResponse = .example await serviceUnderTest.initiateMandate() @@ -252,11 +231,10 @@ final class ZapViewModelTests: XCTestCase { XCTAssertEqual(mockRepository.lastListenedChannel, "DBMAN_6222375579") } - func testListenLoopExitsOnSuccessAndRoutesToContainer() async { + func testListenResolvesOnSuccessAndRoutesToContainer() async { mockRepository.expectedMandateResponse = .example mockRepository.expectedListenForZapResponses = [ - .init(status: .success, message: nil, - reference: nil, transactionId: nil) + ChargeCardTransaction(status: .success) ] let expectation = expectation(description: "container receives success") @@ -269,11 +247,10 @@ final class ZapViewModelTests: XCTestCase { XCTAssertTrue(mockChargeContainer.transactionSuccessful) } - func testListenLoopExitsOnFailedStatusToErrorState() async { + func testListenResolvesOnFailedStatusToErrorState() async { mockRepository.expectedMandateResponse = .example mockRepository.expectedListenForZapResponses = [ - .init(status: .failed, message: "Bank declined", - reference: nil, transactionId: nil) + ChargeCardTransaction(status: .failed, message: "Bank declined") ] await serviceUnderTest.initiateMandate() @@ -284,7 +261,7 @@ final class ZapViewModelTests: XCTestCase { .error(ChargeError(message: "Bank declined"))) } - func testListenLoopExitsOnRepositoryErrorWithoutCrashing() async { + func testListenExitsOnRepositoryErrorWithoutCrashing() async { mockRepository.expectedMandateResponse = .example mockRepository.expectedListenForZapError = PaystackError.technical diff --git a/Tests/PaystackSDKTests/UI/Charge/ZapRepository/ZapRepositoryImplementationTests.swift b/Tests/PaystackSDKTests/UI/Charge/ZapRepository/ZapRepositoryImplementationTests.swift index c370dbe..35a9d74 100644 --- a/Tests/PaystackSDKTests/UI/Charge/ZapRepository/ZapRepositoryImplementationTests.swift +++ b/Tests/PaystackSDKTests/UI/Charge/ZapRepository/ZapRepositoryImplementationTests.swift @@ -102,47 +102,33 @@ final class ZapRepositoryImplementationTests: PSTestCase { // MARK: - listenForZapResponse - /// Subscribes to the Pusher channel with the same `eventName: "response"` - /// contract as Pay-with-Transfer, and the success payload maps cleanly - /// to a `BankTransferTransactionUpdate` (shared wire format). func testListenForZapResponseSubscribesToProvidedChannelAndMapsSuccess() async throws { let channel = "DBMAN_6222375579" mockSubscriptionListener .expectSubscription(PusherSubscription(channelName: channel, eventName: "response")) - .andReturnString(fromJson: "PayWithTransferPusherSuccess") + .andReturnString(fromJson: "ZapPusherSuccess") let result = try await serviceUnderTest.listenForZapResponse(onChannel: channel) XCTAssertEqual(result.status, .success) - XCTAssertEqual(result.message, "Payment Successful") - XCTAssertEqual(result.transactionId, "3818017015") - XCTAssertEqual(result.reference, "T3818017015I615243Sujjxh") } - /// `failed` is the second of the two statuses Zap is documented to - /// emit (the other being `success`). Reuses the existing PWT - /// "failed-shape" fixture since Zap shares the wire format. - func testListenForZapResponseMapsFailedStatusFromSharedWireFormat() async throws { + func testListenForZapResponseMapsFailedStatus() async throws { let channel = "DBMAN_6222375579" mockSubscriptionListener .expectSubscription(PusherSubscription(channelName: channel, eventName: "response")) - .andReturnString(fromJson: "PayWithTransferPusherIncorrectAmount") + .andReturnString(fromJson: "ZapPusherFailed") let result = try await serviceUnderTest.listenForZapResponse(onChannel: channel) XCTAssertEqual(result.status, .failed) - XCTAssertEqual(result.message, "incorrect amount sent") } - /// The channel name is passed through verbatim — the SDK doesn't - /// rewrite the `DBMAN_*` prefix or anything else. Mirror the PWT - /// repository's equivalent test so any future regression where the - /// channel name is mutated would fail loudly. func testListenForZapResponsePassesChannelNameThroughVerbatim() async throws { let channel = "DBMAN_arbitrary_123" mockSubscriptionListener .expectSubscription(PusherSubscription(channelName: channel, eventName: "response")) - .andReturnString(fromJson: "PayWithTransferPusherSuccess") + .andReturnString(fromJson: "ZapPusherSuccess") _ = try await serviceUnderTest.listenForZapResponse(onChannel: channel) }