diff --git a/.github/scripts/integration-test.sh b/.github/scripts/integration-test.sh index 898f1e0..ec7899e 100755 --- a/.github/scripts/integration-test.sh +++ b/.github/scripts/integration-test.sh @@ -23,22 +23,9 @@ cat < ./IntegrationTester/Env.plist EOT -# Find iPhone 16 Pro device ID from the latest iOS version -DEVICE_ID=$(xcrun simctl list devices available | grep "iPhone 16 Pro" | tail -1 | grep -oE '[A-F0-9-]{36}') - -if [ -z "$DEVICE_ID" ]; then - echo "Error: No iPhone 16 Pro simulator found" - xcrun simctl list devices available - exit 1 -fi - -echo "Using device ID: $DEVICE_ID" - -xcrun simctl boot "$DEVICE_ID" 2>/dev/null || true - xcodebuild clean test \ -project ./IntegrationTester/IntegrationTester.xcodeproj \ -scheme IntegrationTester \ -configuration Debug \ - -destination "platform=iOS Simulator,id=$DEVICE_ID" \ + -destination platform="iOS Simulator,OS=18.6,name=iPhone 16 Pro" \ | xcpretty diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index d232f7c..65853fc 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -9,7 +9,7 @@ jobs: test: environment: PR name: Testing BasisTheoryElements Package - runs-on: macOS-latest + runs-on: macos-15 steps: - name: Checkout repository uses: actions/checkout@v3 @@ -27,7 +27,7 @@ jobs: pod-linting: environment: PR name: Pod Linting - runs-on: macOS-latest + runs-on: macos-15 steps: - name: Checkout repository uses: actions/checkout@v3 diff --git a/BasisTheoryElements/Sources/BasisTheoryElements/AnyCodableExtensions.swift b/BasisTheoryElements/Sources/BasisTheoryElements/AnyCodableExtensions.swift new file mode 100644 index 0000000..22e5cb8 --- /dev/null +++ b/BasisTheoryElements/Sources/BasisTheoryElements/AnyCodableExtensions.swift @@ -0,0 +1,30 @@ +import Foundation +import AnyCodable + +extension AnyCodable { + /// Recursively unwraps `AnyCodable`/`AnyDecodable` values into native Swift types + /// so that nested dictionaries become `[String: Any]` instead of `[String: AnyDecodable]`. + func deepUnwrap() -> Any { + let raw = self.value + return AnyCodable.unwrap(raw) + } + + private static func unwrap(_ value: Any) -> Any { + switch value { + case let dict as [String: Any]: + return dict.mapValues { unwrap($0) } + case let array as [Any]: + return array.map { unwrap($0) } + case let codable as AnyCodable: + return unwrap(codable.value) + default: + // Check via Mirror for AnyDecodable (same module, shares _AnyDecodable protocol) + let mirror = Mirror(reflecting: value) + if mirror.displayStyle == nil, + let child = mirror.children.first(where: { $0.label == "value" }) { + return unwrap(child.value) + } + return value + } + } +} diff --git a/BasisTheoryElements/Sources/BasisTheoryElements/BasisTheoryElements.swift b/BasisTheoryElements/Sources/BasisTheoryElements/BasisTheoryElements.swift index 8fff90e..af942c7 100644 --- a/BasisTheoryElements/Sources/BasisTheoryElements/BasisTheoryElements.swift +++ b/BasisTheoryElements/Sources/BasisTheoryElements/BasisTheoryElements.swift @@ -282,6 +282,51 @@ final public class BasisTheoryElements { } } + public static func updateToken(id: String, body: UpdateToken, apiKey: String? = nil, completion: @escaping ((_ data: CreateTokenResponse?, _ error: Error?) -> Void)) -> Void { + let endpoint = "PATCH /tokens/\(id)" + let btTraceId = UUID().uuidString + logBeginningOfApiCall(endpoint: endpoint, btTraceId: btTraceId) + + var mutableBody = body + var mutableData = body.data + do { + try replaceElementRefs(body: &mutableData, endpoint: endpoint, btTraceId: btTraceId) + } catch { + completion(nil, TokenizingError.invalidInput) + return + } + + mutableBody.data = mutableData + let updateTokenRequest = mutableBody.toUpdateTokenRequest() + + var headers = getBasisTheoryHeaders(apiKey: getApiKey(apiKey), btTraceId: btTraceId) + headers["Content-Type"] = "application/merge-patch+json" + let url = "\(basePath)/tokens/\(id)" + + HttpClientHelpers.executeTypedRequest( + method: .patch, + url: url, + headers: headers, + body: updateTokenRequest + ) { (result: CreateTokenResponse?, error: Error?) in + if let error = error { + TelemetryLogging.error("Unsuccessful API response", error: error, attributes: [ + "endpoint": endpoint, + "BT-TRACE-ID": btTraceId, + "apiSuccess": false + ]) + completion(nil, error) + } else { + TelemetryLogging.info("Successful API response", attributes: [ + "endpoint": endpoint, + "BT-TRACE-ID": btTraceId, + "apiSuccess": true + ]) + completion(result, nil) + } + } + } + public static func createTokenIntent( request: CreateTokenIntentRequest, apiKey: String? = nil, completion: @escaping ((_ data: TokenIntent?, _ error: Error?) -> Void) @@ -423,7 +468,7 @@ final public class BasisTheoryElements { ]) completion(nil, error) } else if let token = token { - guard let tokenData = token.data?.value as? [String: Any] else { + guard let tokenData = token.data?.deepUnwrap() as? [String: Any] else { TelemetryLogging.error("Invalid token data format", error: HttpClientError.invalidResponse, attributes: [ "endpoint": endpoint, "BT-TRACE-ID": btTraceId, diff --git a/BasisTheoryElements/Sources/BasisTheoryElements/TextElementUITextField.swift b/BasisTheoryElements/Sources/BasisTheoryElements/TextElementUITextField.swift index a4d5231..bc9ecbc 100644 --- a/BasisTheoryElements/Sources/BasisTheoryElements/TextElementUITextField.swift +++ b/BasisTheoryElements/Sources/BasisTheoryElements/TextElementUITextField.swift @@ -35,7 +35,7 @@ public class TextElementUITextField: UITextField, InternalElementProtocol, Eleme var inputMask: [Any]? var inputTransform: ElementTransform? var inputValidation: NSRegularExpression? - var previousValue: String = "" + var previousValue: String = UUID().uuidString var readOnly: Bool = false var valueRef: TextElementUITextField? var copyIconColor: UIColor? diff --git a/BasisTheoryElements/Sources/BasisTheoryElements/UpdateToken.swift b/BasisTheoryElements/Sources/BasisTheoryElements/UpdateToken.swift new file mode 100644 index 0000000..be786ba --- /dev/null +++ b/BasisTheoryElements/Sources/BasisTheoryElements/UpdateToken.swift @@ -0,0 +1,46 @@ +// +// UpdateToken.swift +// +// +// Created by Cascade on 02/11/26. +// + +import AnyCodable + +public struct UpdateToken { + public var data: [String: Any] + public var privacy: Privacy? + public var metadata: [String: String]? + public var searchIndexes: [String]? + public var fingerprintExpression: String? + public var mask: String? + public var deduplicateToken: Bool? + public var expiresAt: String? + public var containers: [String]? + + public init(data: [String: Any], privacy: Privacy? = nil, metadata: [String: String]? = nil, searchIndexes: [String]? = nil, fingerprintExpression: String? = nil, mask: String? = nil, deduplicateToken: Bool? = nil, expiresAt: String? = nil, containers: [String]? = nil) { + self.data = data + self.privacy = privacy + self.metadata = metadata + self.searchIndexes = searchIndexes + self.fingerprintExpression = fingerprintExpression + self.mask = mask + self.deduplicateToken = deduplicateToken + self.expiresAt = expiresAt + self.containers = containers + } + + func toUpdateTokenRequest() -> UpdateTokenRequest { + UpdateTokenRequest( + data: AnyCodable(self.data), + privacy: self.privacy, + metadata: self.metadata, + searchIndexes: self.searchIndexes, + fingerprintExpression: self.fingerprintExpression, + mask: AnyCodable(self.mask), + deduplicateToken: self.deduplicateToken, + expiresAt: self.expiresAt, + containers: self.containers + ) + } +} diff --git a/BasisTheoryElements/Sources/BasisTheoryElements/UpdateTokenRequest.swift b/BasisTheoryElements/Sources/BasisTheoryElements/UpdateTokenRequest.swift new file mode 100644 index 0000000..5ca58d4 --- /dev/null +++ b/BasisTheoryElements/Sources/BasisTheoryElements/UpdateTokenRequest.swift @@ -0,0 +1,14 @@ +import Foundation +import AnyCodable + +internal struct UpdateTokenRequest: Codable { + var data: AnyCodable? + var privacy: Privacy? + var metadata: [String: String]? + var searchIndexes: [String]? + var fingerprintExpression: String? + var mask: AnyCodable? + var deduplicateToken: Bool? + var expiresAt: String? + var containers: [String]? +} diff --git a/IntegrationTester/IntegrationTester.xcodeproj/project.pbxproj b/IntegrationTester/IntegrationTester.xcodeproj/project.pbxproj index e6448ef..c8546d4 100644 --- a/IntegrationTester/IntegrationTester.xcodeproj/project.pbxproj +++ b/IntegrationTester/IntegrationTester.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 0A5844202E32C81800D61EAF /* TokenIntentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A58441D2E32C81800D61EAF /* TokenIntentViewController.swift */; }; 0AB437902A4C9FB4000099CD /* HttpClientViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AB4378F2A4C9FB4000099CD /* HttpClientViewController.swift */; }; 0AD41CA82E32C8D4002070F8 /* TokenIntentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AD41CA72E32C8D4002070F8 /* TokenIntentTests.swift */; }; + CA5CAD022F0C000000000001 /* UpdateTokenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5CAD012F0C000000000001 /* UpdateTokenTests.swift */; }; 428CB1062937C53000C8220E /* CardExpirationDateUITextFieldTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428CB1052937C53000C8220E /* CardExpirationDateUITextFieldTests.swift */; }; 428CB10A293E398B00C8220E /* CardNumberUITextFieldTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428CB109293E398B00C8220E /* CardNumberUITextFieldTests.swift */; }; 5EC845072EEB95F100B10269 /* EnvironmentConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EC844F82EEA49ED00B10269 /* EnvironmentConfigurationTests.swift */; }; @@ -87,6 +88,7 @@ 0A58441D2E32C81800D61EAF /* TokenIntentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenIntentViewController.swift; sourceTree = ""; }; 0AB4378F2A4C9FB4000099CD /* HttpClientViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HttpClientViewController.swift; sourceTree = ""; }; 0AD41CA72E32C8D4002070F8 /* TokenIntentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenIntentTests.swift; sourceTree = ""; }; + CA5CAD012F0C000000000001 /* UpdateTokenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateTokenTests.swift; sourceTree = ""; }; 428CB1052937C53000C8220E /* CardExpirationDateUITextFieldTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardExpirationDateUITextFieldTests.swift; sourceTree = ""; }; 428CB109293E398B00C8220E /* CardNumberUITextFieldTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardNumberUITextFieldTests.swift; sourceTree = ""; }; 5EC844F82EEA49ED00B10269 /* EnvironmentConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentConfigurationTests.swift; sourceTree = ""; }; @@ -220,6 +222,7 @@ 023FB3372EC361D800CD88DF /* CardBrandSelectorUIButtonTests.swift */, 023FB3382EC361D800CD88DF /* CobadgeIntegrationTests.swift */, 0AD41CA72E32C8D4002070F8 /* TokenIntentTests.swift */, + CA5CAD012F0C000000000001 /* UpdateTokenTests.swift */, 646506012DC9490300660095 /* EncryptTokenTests.swift */, 929E30C22948D0F900A01A56 /* TokenizeAndCreateTokenServiceTests.swift */, 9253E8E1291EB66E00FDF1A9 /* ElementUITextFieldTests.swift */, @@ -473,6 +476,7 @@ 929E30C32948D0F900A01A56 /* TokenizeAndCreateTokenServiceTests.swift in Sources */, 92B7DCAF2939523C000DBB8A /* CardBrandTests.swift in Sources */, 0AD41CA82E32C8D4002070F8 /* TokenIntentTests.swift in Sources */, + CA5CAD022F0C000000000001 /* UpdateTokenTests.swift in Sources */, 023FB3392EC361D800CD88DF /* CardBrandSelectorUIButtonTests.swift in Sources */, 023FB33A2EC361D800CD88DF /* CobadgeIntegrationTests.swift in Sources */, 428CB1062937C53000C8220E /* CardExpirationDateUITextFieldTests.swift in Sources */, diff --git a/IntegrationTester/UnitTests/TextElementUITextFieldTests.swift b/IntegrationTester/UnitTests/TextElementUITextFieldTests.swift index 90eb210..49c0ab3 100644 --- a/IntegrationTester/UnitTests/TextElementUITextFieldTests.swift +++ b/IntegrationTester/UnitTests/TextElementUITextFieldTests.swift @@ -414,4 +414,89 @@ final class TextElementUITextFieldTests: XCTestCase { waitForExpectations(timeout: 3) } + + func testTextSetterTriggersChangeEventWithValidation() throws { + let textField = TextElementUITextField() + let customRegex = try! NSRegularExpression(pattern: "^\\d{16}$") + try! textField.setConfig(options: TextElementOptions(validation: customRegex)) + + let changeExpectation = self.expectation(description: "text setter triggers change event") + var cancellables = Set() + + textField.subject.sink { completion in + print(completion) + } receiveValue: { message in + XCTAssertEqual(message.type, "textChange") + XCTAssertEqual(message.complete, true) + XCTAssertEqual(message.valid, true) + XCTAssertEqual(message.empty, false) + + // assert metadata updated + XCTAssertEqual(textField.metadata.complete, message.complete) + XCTAssertEqual(textField.metadata.valid, message.valid) + XCTAssertEqual(textField.metadata.empty, message.empty) + + changeExpectation.fulfill() + }.store(in: &cancellables) + + textField.text = "4242424242424242" + + waitForExpectations(timeout: 1, handler: nil) + } + + func testTextSetterTriggersChangeEventWithInvalidValue() throws { + let textField = TextElementUITextField() + let customRegex = try! NSRegularExpression(pattern: "^\\d{16}$") + try! textField.setConfig(options: TextElementOptions(validation: customRegex)) + + let changeExpectation = self.expectation(description: "text setter triggers change event with invalid value") + var cancellables = Set() + + textField.subject.sink { completion in + print(completion) + } receiveValue: { message in + XCTAssertEqual(message.type, "textChange") + XCTAssertEqual(message.complete, false) + XCTAssertEqual(message.valid, false) + XCTAssertEqual(message.empty, false) + + // assert metadata updated + XCTAssertEqual(textField.metadata.complete, message.complete) + XCTAssertEqual(textField.metadata.valid, message.valid) + XCTAssertEqual(textField.metadata.empty, message.empty) + + changeExpectation.fulfill() + }.store(in: &cancellables) + + textField.text = "4242" + + waitForExpectations(timeout: 1, handler: nil) + } + + func testTextSetterTriggersChangeEventWithEmptyValue() throws { + let textField = TextElementUITextField() + + let changeExpectation = self.expectation(description: "text setter triggers change event with empty value") + var cancellables = Set() + + textField.subject.sink { completion in + print(completion) + } receiveValue: { message in + XCTAssertEqual(message.type, "textChange") + XCTAssertEqual(message.complete, true) + XCTAssertEqual(message.valid, true) + XCTAssertEqual(message.empty, true) + + // assert metadata updated + XCTAssertEqual(textField.metadata.complete, message.complete) + XCTAssertEqual(textField.metadata.valid, message.valid) + XCTAssertEqual(textField.metadata.empty, message.empty) + + changeExpectation.fulfill() + }.store(in: &cancellables) + + textField.text = "" + + waitForExpectations(timeout: 1, handler: nil) + } } diff --git a/IntegrationTester/UnitTests/UpdateTokenTests.swift b/IntegrationTester/UnitTests/UpdateTokenTests.swift new file mode 100644 index 0000000..b5ea06d --- /dev/null +++ b/IntegrationTester/UnitTests/UpdateTokenTests.swift @@ -0,0 +1,98 @@ +// +// UpdateTokenTests.swift +// IntegrationTesterTests +// + +import XCTest +import BasisTheoryElements + +final class UpdateTokenTests: XCTestCase { + private final var TIMEOUT_EXPECTATION = 5.0 + + override func setUpWithError() throws { + BasisTheoryElements.basePath = "https://api.flock-dev.com" + } + + override func tearDownWithError() throws {} + + func testUpdateTokenReturnsErrorFromApplicationCheck() throws { + let body = UpdateToken(data: [ + "cvc": "123" + ]) + + let updateExpectation = self.expectation(description: "Update token") + BasisTheoryElements.updateToken(id: "invalid-token-id", body: body, apiKey: "bad api key") { data, error in + XCTAssertNil(data) + XCTAssertNotNil(error) + + updateExpectation.fulfill() + } + + waitForExpectations(timeout: TIMEOUT_EXPECTATION) + } + + func testUpdateTokenWithElementRef() throws { + let btApiKey = Configuration.getConfiguration().btApiKey! + + // First create a token to update + let createExpectation = self.expectation(description: "Create token") + var tokenId: String? + + let createBody = CreateToken(type: "card", data: [ + "number": "4242424242424242", + "expiration_month": 12, + "expiration_year": 2026, + "cvc": "123" + ]) + + BasisTheoryElements.createToken(body: createBody, apiKey: btApiKey) { data, error in + XCTAssertNil(error) + XCTAssertNotNil(data?.id) + tokenId = data?.id + createExpectation.fulfill() + } + + waitForExpectations(timeout: TIMEOUT_EXPECTATION) + + guard let id = tokenId else { + XCTFail("Failed to create token for update test") + return + } + + // Now update the token with new CVC + let updateExpectation = self.expectation(description: "Update token") + + let updateBody = UpdateToken(data: [ + "cvc": "456" + ]) + + BasisTheoryElements.updateToken(id: id, body: updateBody, apiKey: btApiKey) { data, error in + XCTAssertNil(error) + XCTAssertNotNil(data) + XCTAssertEqual(data?.id, id) + + updateExpectation.fulfill() + } + + waitForExpectations(timeout: TIMEOUT_EXPECTATION) + } + + func testUpdateTokenWithInvalidId() throws { + let btApiKey = Configuration.getConfiguration().btApiKey! + + let updateExpectation = self.expectation(description: "Update token with invalid id") + + let updateBody = UpdateToken(data: [ + "cvc": "456" + ]) + + BasisTheoryElements.updateToken(id: "nonexistent-token-id", body: updateBody, apiKey: btApiKey) { data, error in + XCTAssertNil(data) + XCTAssertNotNil(error) + + updateExpectation.fulfill() + } + + waitForExpectations(timeout: TIMEOUT_EXPECTATION) + } +}