mirror of
https://github.com/google/app-check
synced 2026-11-09 22:05:52 +05:45
feat: Add Recaptcha attestation provider (#94)
Signed-off-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com> Co-authored-by: Hitesh Maurya <hiteshmaurya@google.com> Co-authored-by: Paul Beusterien <paulbeusterien@google.com>
This commit is contained in:
57 files changed
+1869
-272
No files matched your search
@@ -0,0 +1,160 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#if SWIFT_PACKAGE
|
||||
import AppCheckCore
|
||||
#endif
|
||||
import Foundation
|
||||
import Promises
|
||||
import RecaptchaInterop
|
||||
|
||||
/// Firebase App Check provider that verifies app integrity using the
|
||||
/// [reCAPTCHA Enterprise](https://cloud.google.com/recaptcha/docs/instrument-ios-apps)
|
||||
/// API. This class's platform and OS availability matches reCAPTCHA
|
||||
/// Enterprise's.
|
||||
@available(iOS 15.0, visionOS 1.0, *)
|
||||
@available(macOS, unavailable)
|
||||
@available(macCatalyst, unavailable)
|
||||
@available(tvOS, unavailable)
|
||||
@available(watchOS, unavailable)
|
||||
@objc(GACRecaptchaProvider)
|
||||
public final class AppCheckRecaptchaProvider: NSObject, AppCheckCoreProvider {
|
||||
// This action name should never change without coordination with the backend.
|
||||
private static let appCheckActionName = "app_check_ios"
|
||||
|
||||
@objc public static func isSupported() -> Bool {
|
||||
return RecaptchaEnterpriseSDKLoader.isLinked
|
||||
}
|
||||
|
||||
private let tokenGenerator: RecaptchaTokenGenerator?
|
||||
private let apiService: RecaptchaAPIService
|
||||
|
||||
/// The default initializer.
|
||||
/// - Parameters:
|
||||
/// - siteKey: The reCAPTCHA site key.
|
||||
/// - resourceName: The name of the resource protected by App Check; for a Firebase App this is
|
||||
/// "projects/{project_id}/apps/{app_id}".
|
||||
/// - APIKey: The Google Cloud Platform API key.
|
||||
/// - requestHooks: Hooks that will be invoked on requests through this service.
|
||||
// `@convention(block)` is required because the Swift compiler cannot automatically
|
||||
// bridge collections of closures (like an Array) to Objective-C blocks. This attribute
|
||||
// changes the closure's representation to match the Objective-C block heap layout.
|
||||
@objc public convenience init?(siteKey: String, resourceName: String, APIKey: String,
|
||||
requestHooks: [@convention(block) (NSMutableURLRequest) -> Void]? =
|
||||
nil) {
|
||||
self.init(
|
||||
siteKey: siteKey,
|
||||
resourceName: resourceName,
|
||||
APIKey: APIKey,
|
||||
requestHooks: requestHooks,
|
||||
actionName: Self.appCheckActionName
|
||||
)
|
||||
}
|
||||
|
||||
@objc public convenience init?(siteKey: String, resourceName: String, APIKey: String,
|
||||
requestHooks: [@convention(block) (NSMutableURLRequest) -> Void]? =
|
||||
nil,
|
||||
actionName: String) {
|
||||
guard let sdk = RecaptchaEnterpriseSDKLoader(customAction: actionName) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let backoffWrapper = _GACAppCheckBackoffWrapper()
|
||||
let tokenGenerator = RecaptchaTokenGenerator(
|
||||
siteKey: siteKey,
|
||||
recaptchaAction: sdk.action,
|
||||
recaptchaClass: sdk.recaptchaClass,
|
||||
backoffWrapper: backoffWrapper
|
||||
)
|
||||
|
||||
let urlSession = URLSession(configuration: .ephemeral)
|
||||
let appCheckAPIService = _GACAppCheckAPIService(urlSession: urlSession,
|
||||
baseURL: nil,
|
||||
apiKey: APIKey,
|
||||
requestHooks: requestHooks)
|
||||
let apiService = RecaptchaAPIService(
|
||||
apiService: appCheckAPIService,
|
||||
resourceName: resourceName
|
||||
)
|
||||
|
||||
self.init(tokenGenerator: tokenGenerator, apiService: apiService)
|
||||
}
|
||||
|
||||
init(tokenGenerator: RecaptchaTokenGenerator?,
|
||||
apiService: RecaptchaAPIService) {
|
||||
self.tokenGenerator = tokenGenerator
|
||||
self.apiService = apiService
|
||||
super.init()
|
||||
}
|
||||
|
||||
@objc(getTokenWithCompletion:)
|
||||
public func getToken(completion handler: @escaping (AppCheckCoreToken?, (any Error)?) -> Void) {
|
||||
getToken(limitedUse: false)
|
||||
.then { token in
|
||||
handler(token, nil)
|
||||
}.catch { error in
|
||||
handler(nil, error)
|
||||
}
|
||||
}
|
||||
|
||||
@objc(getLimitedUseTokenWithCompletion:)
|
||||
public func getLimitedUseToken(completion handler: @escaping (AppCheckCoreToken?, (any Error)?)
|
||||
-> Void) {
|
||||
getToken(limitedUse: true)
|
||||
.then { token in
|
||||
handler(token, nil)
|
||||
}.catch { error in
|
||||
handler(nil, error)
|
||||
}
|
||||
}
|
||||
|
||||
private func getToken(limitedUse: Bool) -> Promise<AppCheckCoreToken> {
|
||||
guard let tokenGenerator else {
|
||||
return Promise(_GACAppCheckErrorUtil.missingRecaptchaSDKError())
|
||||
}
|
||||
return tokenGenerator.getRecaptchaToken()
|
||||
.then { recaptchaToken in
|
||||
self.apiService.appCheckToken(
|
||||
with: recaptchaToken,
|
||||
limitedUse: limitedUse
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RecaptchaEnterpriseSDKLoader {
|
||||
// These symbols are specified in the RecaptchaEnterprise SDK.
|
||||
// See https://github.com/GoogleCloudPlatform/recaptcha-enterprise-mobile-sdk/blob/18.9.0/Sources/RecaptchaEnterprise/RecaptchaInteropBidings.swift
|
||||
private static let actionClass =
|
||||
NSClassFromString("RecaptchaEnterprise.RCAAction") as? RCAActionProtocol.Type
|
||||
private static let recaptchaClass =
|
||||
NSClassFromString("RecaptchaEnterprise.RCARecaptcha") as? RCARecaptchaProtocol.Type
|
||||
|
||||
static var isLinked: Bool {
|
||||
return actionClass != nil && recaptchaClass != nil
|
||||
}
|
||||
|
||||
let action: RCAActionProtocol
|
||||
let recaptchaClass: RCARecaptchaProtocol.Type
|
||||
|
||||
init?(customAction: String) {
|
||||
guard let actionClass = Self.actionClass,
|
||||
let recaptchaClass = Self.recaptchaClass else {
|
||||
return nil
|
||||
}
|
||||
|
||||
action = actionClass.init(customAction: customAction)
|
||||
self.recaptchaClass = recaptchaClass
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#if SWIFT_PACKAGE
|
||||
import AppCheckCore
|
||||
#endif
|
||||
import Foundation
|
||||
import Promises
|
||||
|
||||
private enum Constants {
|
||||
static let contentTypeKey = "Content-Type"
|
||||
static let jsonContentType = "application/json"
|
||||
static let recaptchaTokenField = "recaptcha_enterprise_token"
|
||||
static let limitedUseField = "limited_use"
|
||||
// This endpoint should never change without coordination with the backend.
|
||||
static let exchangeEndpoint = "exchangeRecaptchaEnterpriseToken"
|
||||
static let httpMethodPost = "POST"
|
||||
}
|
||||
|
||||
@available(iOS 15.0, visionOS 1.0, *)
|
||||
@available(macOS, unavailable)
|
||||
@available(macCatalyst, unavailable)
|
||||
@available(tvOS, unavailable)
|
||||
@available(watchOS, unavailable)
|
||||
final class RecaptchaAPIService: NSObject {
|
||||
private let apiService: _GACAppCheckAPIServiceProtocol
|
||||
private let resourceName: String
|
||||
|
||||
init(apiService: _GACAppCheckAPIServiceProtocol, resourceName: String) {
|
||||
self.apiService = apiService
|
||||
self.resourceName = resourceName
|
||||
}
|
||||
|
||||
func appCheckToken(with recaptchaToken: String,
|
||||
limitedUse: Bool) -> Promise<AppCheckCoreToken> {
|
||||
let urlString = "\(apiService.baseURL)/\(resourceName):\(Constants.exchangeEndpoint)"
|
||||
guard let url = URL(string: urlString) else {
|
||||
return Promise(_GACAppCheckErrorUtil
|
||||
.error(withFailureReason: "Invalid URL string: \(urlString)"))
|
||||
}
|
||||
|
||||
let httpBody: Data
|
||||
do {
|
||||
httpBody = try self.httpBody(with: recaptchaToken, limitedUse: limitedUse)
|
||||
} catch {
|
||||
return Promise(error)
|
||||
}
|
||||
|
||||
return Promise<_GACURLSessionDataResponse>(apiService.sendRequest(with: url,
|
||||
httpMethod: Constants
|
||||
.httpMethodPost,
|
||||
body: httpBody,
|
||||
additionalHeaders: [Constants
|
||||
.contentTypeKey: Constants
|
||||
.jsonContentType]))
|
||||
.then { response in
|
||||
Promise<AppCheckCoreToken>(self.apiService.appCheckToken(withAPIResponse: response))
|
||||
}
|
||||
}
|
||||
|
||||
private func httpBody(with recaptchaToken: String,
|
||||
limitedUse: Bool) throws -> Data {
|
||||
guard !recaptchaToken.isEmpty else {
|
||||
throw _GACAppCheckErrorUtil.error(withFailureReason: "Recaptcha token cannot be empty")
|
||||
}
|
||||
|
||||
let payload: [String: Any] = [
|
||||
Constants.recaptchaTokenField: recaptchaToken,
|
||||
Constants.limitedUseField: limitedUse,
|
||||
]
|
||||
|
||||
do {
|
||||
return try JSONSerialization.data(withJSONObject: payload, options: [])
|
||||
} catch {
|
||||
throw _GACAppCheckErrorUtil.jsonSerializationError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#if SWIFT_PACKAGE
|
||||
import AppCheckCore
|
||||
#endif
|
||||
import FBLPromises
|
||||
import Foundation
|
||||
import Promises
|
||||
import RecaptchaInterop
|
||||
|
||||
@available(iOS 15.0, visionOS 1.0, *)
|
||||
@available(macOS, unavailable)
|
||||
@available(macCatalyst, unavailable)
|
||||
@available(tvOS, unavailable)
|
||||
@available(watchOS, unavailable)
|
||||
final class RecaptchaTokenGenerator {
|
||||
// Corresponds to RecaptchaErrorNetworkError. These codes are not in the interop.
|
||||
// See https://docs.cloud.google.com/recaptcha/docs/reference/ios/client/api/Enums/RecaptchaErrorCode.html#recaptchaerrornetworkerror
|
||||
static let networkErrorCode = 1
|
||||
// Corresponds to RecaptchaErrorCodeInternalError. These codes are not in the interop.
|
||||
// See https://docs.cloud.google.com/recaptcha/docs/reference/ios/client/api/Enums/RecaptchaErrorCode.html#recaptchaerrorcodeinternalerror
|
||||
static let internalErrorCode = 100
|
||||
|
||||
private let recaptchaAction: RCAActionProtocol
|
||||
|
||||
private let recaptchaClient: Promise<RCARecaptchaClientProtocol>
|
||||
|
||||
private let backoffWrapper: _GACAppCheckBackoffWrapperProtocol
|
||||
|
||||
init(siteKey: String, recaptchaAction: RCAActionProtocol,
|
||||
recaptchaClass: RCARecaptchaProtocol.Type,
|
||||
backoffWrapper: _GACAppCheckBackoffWrapperProtocol) {
|
||||
self.recaptchaAction = recaptchaAction
|
||||
self.backoffWrapper = backoffWrapper
|
||||
// Note: `fetchClient` is called only once and its result (including
|
||||
// failure) is cached. reCAPTCHA engineers have confirmed that
|
||||
// `fetchClient` handles transient errors internally and only fails on
|
||||
// permanent integration errors (e.g., invalid site key). Therefore,
|
||||
// retrying `fetchClient` on failure is unnecessary and not recommended.
|
||||
recaptchaClient = Promise<RCARecaptchaClientProtocol> { fulfill, reject in
|
||||
recaptchaClass.fetchClient(withSiteKey: siteKey) { client, error in
|
||||
if let client {
|
||||
fulfill(client)
|
||||
} else {
|
||||
reject(error ?? _GACAppCheckErrorUtil
|
||||
.error(withFailureReason: "Failed to fetch Recaptcha client"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getRecaptchaToken() -> Promise<String> {
|
||||
return recaptchaClient.then { client in
|
||||
let operationProvider: GACAppCheckBackoffOperationProvider = {
|
||||
let swiftPromise = Promise<AnyObject> { fulfill, reject in
|
||||
client.execute(withAction: self.recaptchaAction) { token, error in
|
||||
if let token {
|
||||
fulfill(token as AnyObject)
|
||||
} else {
|
||||
reject(self.mapRecaptchaError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
return swiftPromise.asObjCPromise()
|
||||
}
|
||||
|
||||
let errorHandler: GACAppCheckBackoffErrorHandler = { error in
|
||||
let nsError = error as NSError
|
||||
if nsError.domain == AppCheckCoreErrorDomain && nsError.code == AppCheckCoreErrorCode
|
||||
.serverUnreachable.rawValue {
|
||||
return .typeExponential
|
||||
}
|
||||
return .typeNone
|
||||
}
|
||||
|
||||
let fblPromise = self.backoffWrapper.applyBackoff(
|
||||
toOperation: operationProvider,
|
||||
errorHandler: errorHandler
|
||||
)
|
||||
|
||||
return Promise<AnyObject>(fblPromise).then { result in
|
||||
guard let token = result as? String else {
|
||||
throw _GACAppCheckErrorUtil
|
||||
.error(
|
||||
withFailureReason: "Unexpected result type from reCAPTCHA token exchange: \(type(of: result)). Expected String."
|
||||
)
|
||||
}
|
||||
return token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func mapRecaptchaError(_ error: Error?) -> Error {
|
||||
guard let error = error as NSError? else {
|
||||
return _GACAppCheckErrorUtil.error(withFailureReason: "Failed to execute Recaptcha action")
|
||||
}
|
||||
|
||||
// Map RecaptchaErrorNetworkError and RecaptchaErrorCodeInternalError.
|
||||
// See https://docs.cloud.google.com/recaptcha/docs/reference/ios/client/api/Enums/RecaptchaErrorCode.html
|
||||
if error.code == Self.networkErrorCode || error.code == Self.internalErrorCode {
|
||||
return _GACAppCheckErrorUtil.apiError(withNetworkError: error)
|
||||
}
|
||||
|
||||
// Preserve underlying error for others
|
||||
var userInfo: [String: Any] = [NSUnderlyingErrorKey: error]
|
||||
if let reason = error.userInfo[NSLocalizedFailureReasonErrorKey] {
|
||||
userInfo[NSLocalizedFailureReasonErrorKey] = reason
|
||||
}
|
||||
return NSError(
|
||||
domain: AppCheckCoreErrorDomain,
|
||||
code: AppCheckCoreErrorCode.unknown.rawValue,
|
||||
userInfo: userInfo
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import AppCheckCore
|
||||
@testable import AppCheckRecaptchaProvider
|
||||
import Promises
|
||||
|
||||
@available(iOS 15.0, visionOS 1.0, *)
|
||||
@available(macOS, unavailable)
|
||||
@available(macCatalyst, unavailable)
|
||||
@available(tvOS, unavailable)
|
||||
@available(watchOS, unavailable)
|
||||
final class AppCheckRecaptchaProviderTests: XCTestCase {
|
||||
private var provider: AppCheckRecaptchaProvider!
|
||||
private let testSiteKey = "test-site-key"
|
||||
private let testResourceName = "projects/test-project/apps/test-app"
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
let mockCoreAPIService = MockAppCheckCoreAPIService()
|
||||
let apiService = RecaptchaAPIService(
|
||||
apiService: mockCoreAPIService,
|
||||
resourceName: testResourceName
|
||||
)
|
||||
provider = AppCheckRecaptchaProvider(
|
||||
tokenGenerator: nil,
|
||||
apiService: apiService
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
provider = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testIsSupportedReturnsFalse() {
|
||||
XCTAssertFalse(AppCheckRecaptchaProvider.isSupported())
|
||||
}
|
||||
|
||||
func testGetTokenWithoutRecaptchaSDK() {
|
||||
// When the Recaptcha SDK is not linked, the tokenGenerator will be nil.
|
||||
// We should expect an unsupported attestation provider error.
|
||||
|
||||
let expectation = self.expectation(description: "Get token fails without SDK")
|
||||
|
||||
provider.getToken { token, error in
|
||||
XCTAssertNil(token)
|
||||
XCTAssertNotNil(error)
|
||||
|
||||
let nsError = error as NSError?
|
||||
XCTAssertEqual(nsError?.domain, AppCheckCoreErrorDomain)
|
||||
XCTAssertEqual(nsError?.code, AppCheckCoreErrorCode.unsupported.rawValue)
|
||||
XCTAssertEqual(
|
||||
nsError?.localizedFailureReason,
|
||||
"The reCAPTCHA Enterprise SDK is not linked. See https://cloud.google.com/recaptcha/docs/instrument-ios-apps#prepare-environment"
|
||||
)
|
||||
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 1.0)
|
||||
}
|
||||
|
||||
func testGetLimitedUseTokenWithoutRecaptchaSDK() {
|
||||
let expectation = self.expectation(description: "Get limited use token fails without SDK")
|
||||
|
||||
provider.getLimitedUseToken { token, error in
|
||||
XCTAssertNil(token)
|
||||
XCTAssertNotNil(error)
|
||||
|
||||
let nsError = error as NSError?
|
||||
XCTAssertEqual(nsError?.domain, AppCheckCoreErrorDomain)
|
||||
XCTAssertEqual(nsError?.code, AppCheckCoreErrorCode.unsupported.rawValue)
|
||||
XCTAssertEqual(
|
||||
nsError?.localizedFailureReason,
|
||||
"The reCAPTCHA Enterprise SDK is not linked. See https://cloud.google.com/recaptcha/docs/instrument-ios-apps#prepare-environment"
|
||||
)
|
||||
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 1.0)
|
||||
}
|
||||
|
||||
func testInitReturnsNilWithoutRecaptchaSDK() {
|
||||
// When the Recaptcha SDK is not linked, the convenience initializer should return nil.
|
||||
let provider = AppCheckRecaptchaProvider(
|
||||
siteKey: testSiteKey,
|
||||
resourceName: testResourceName,
|
||||
APIKey: "test-api-key"
|
||||
)
|
||||
XCTAssertNil(provider)
|
||||
}
|
||||
|
||||
func testInitWithCustomActionNameReturnsNilWithoutRecaptchaSDK() {
|
||||
// When the Recaptcha SDK is not linked, the convenience initializer should return nil
|
||||
// even with a custom action name.
|
||||
let provider = AppCheckRecaptchaProvider(
|
||||
siteKey: testSiteKey,
|
||||
resourceName: testResourceName,
|
||||
APIKey: "test-api-key",
|
||||
actionName: "custom_action"
|
||||
)
|
||||
XCTAssertNil(provider)
|
||||
}
|
||||
|
||||
private func createProviderWithMocks(expectedToken: AppCheckCoreToken)
|
||||
-> AppCheckRecaptchaProvider {
|
||||
let mockClient = MockRecaptchaClient()
|
||||
mockClient.mockToken = "valid-recaptcha-token"
|
||||
MockRecaptcha.mockClient = mockClient
|
||||
|
||||
let tokenGenerator = RecaptchaTokenGenerator(
|
||||
siteKey: testSiteKey,
|
||||
recaptchaAction: MockRCAAction(customAction: "app_check_ios"),
|
||||
recaptchaClass: MockRecaptcha.self,
|
||||
backoffWrapper: MockBackoffWrapper()
|
||||
)
|
||||
|
||||
let mockCoreAPIService = MockAppCheckCoreAPIService()
|
||||
mockCoreAPIService.expectedToken = expectedToken
|
||||
|
||||
let apiService = RecaptchaAPIService(
|
||||
apiService: mockCoreAPIService,
|
||||
resourceName: testResourceName
|
||||
)
|
||||
|
||||
return AppCheckRecaptchaProvider(
|
||||
tokenGenerator: tokenGenerator,
|
||||
apiService: apiService
|
||||
)
|
||||
}
|
||||
|
||||
func testGetTokenSuccess() {
|
||||
// Arrange
|
||||
let expectedAppCheckToken = AppCheckCoreToken(
|
||||
token: "app-check-token-456",
|
||||
expirationDate: Date(timeIntervalSinceNow: 3600)
|
||||
)
|
||||
let providerWithMocks = createProviderWithMocks(expectedToken: expectedAppCheckToken)
|
||||
|
||||
let expectation = self.expectation(description: "Get token succeeds")
|
||||
|
||||
// Act
|
||||
providerWithMocks.getToken { token, error in
|
||||
// Assert
|
||||
XCTAssertNotNil(token)
|
||||
XCTAssertNil(error)
|
||||
XCTAssertEqual(token?.token, expectedAppCheckToken.token)
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 1.0)
|
||||
}
|
||||
|
||||
func testGetLimitedUseTokenSuccess() {
|
||||
// Arrange
|
||||
let expectedAppCheckToken = AppCheckCoreToken(
|
||||
token: "app-check-token-456",
|
||||
expirationDate: Date(timeIntervalSinceNow: 3600)
|
||||
)
|
||||
let providerWithMocks = createProviderWithMocks(expectedToken: expectedAppCheckToken)
|
||||
|
||||
let expectation = self.expectation(description: "Get limited use token succeeds")
|
||||
|
||||
// Act
|
||||
providerWithMocks.getLimitedUseToken { token, error in
|
||||
// Assert
|
||||
XCTAssertNotNil(token)
|
||||
XCTAssertNil(error)
|
||||
XCTAssertEqual(token?.token, expectedAppCheckToken.token)
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 1.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
@testable import AppCheckCore
|
||||
@testable import AppCheckRecaptchaProvider
|
||||
import FBLPromises
|
||||
import Foundation
|
||||
import Promises
|
||||
import RecaptchaInterop
|
||||
|
||||
class MockRCAAction: NSObject, RCAActionProtocol {
|
||||
var action: String { return customAction }
|
||||
|
||||
// The following properties are required by RCAActionProtocol but not used in these tests.
|
||||
static var login: RCAActionProtocol { return MockRCAAction(customAction: "login") }
|
||||
static var signup: RCAActionProtocol { return MockRCAAction(customAction: "signup") }
|
||||
|
||||
let customAction: String
|
||||
|
||||
required init(customAction: String) {
|
||||
self.customAction = customAction
|
||||
super.init()
|
||||
}
|
||||
}
|
||||
|
||||
final class MockRecaptcha: NSObject, RCARecaptchaProtocol {
|
||||
static var mockClient: MockRecaptchaClient?
|
||||
static var mockError: Error?
|
||||
|
||||
// This initializer bypasses the unavailable `init()` in the protocol.
|
||||
// The unlabeled `Void` parameter carries no data and is purely to change the signature.
|
||||
init(_: Void = ()) {
|
||||
super.init()
|
||||
}
|
||||
|
||||
static func fetchClient(withSiteKey siteKey: String,
|
||||
completion: @escaping (RCARecaptchaClientProtocol?, Error?) -> Void) {
|
||||
if let mockError {
|
||||
completion(nil, mockError)
|
||||
} else {
|
||||
completion(mockClient, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class MockRecaptchaClient: NSObject, RCARecaptchaClientProtocol {
|
||||
var mockToken: String?
|
||||
var mockError: Error?
|
||||
|
||||
// This initializer bypasses the unavailable `init()` in the protocol.
|
||||
// The unlabeled `Void` parameter carries no data and is purely to change the signature.
|
||||
init(_: Void = ()) {
|
||||
super.init()
|
||||
}
|
||||
|
||||
func execute(withAction action: RCAActionProtocol,
|
||||
completion: @escaping (String?, Error?) -> Void) {
|
||||
if let mockError {
|
||||
completion(nil, mockError)
|
||||
} else {
|
||||
completion(mockToken, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func execute(withAction action: RCAActionProtocol, withTimeout timeout: Double,
|
||||
completion: @escaping (String?, Error?) -> Void) {
|
||||
execute(withAction: action, completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
class MockAppCheckCoreAPIService: NSObject, _GACAppCheckAPIServiceProtocol {
|
||||
var baseURL: String = "https://test.com"
|
||||
|
||||
struct RequestData {
|
||||
let url: URL?
|
||||
let httpMethod: String?
|
||||
let body: Data?
|
||||
let additionalHeaders: [String: String]?
|
||||
}
|
||||
|
||||
var lastRequest: RequestData?
|
||||
var expectedResponse: _GACURLSessionDataResponse?
|
||||
var expectedToken: AppCheckCoreToken?
|
||||
var expectedError: Error?
|
||||
|
||||
func sendRequest(with url: URL, httpMethod: String, body: Data?,
|
||||
additionalHeaders: [String: String]?) -> FBLPromise<_GACURLSessionDataResponse> {
|
||||
lastRequest = RequestData(
|
||||
url: url,
|
||||
httpMethod: httpMethod,
|
||||
body: body,
|
||||
additionalHeaders: additionalHeaders
|
||||
)
|
||||
|
||||
let promise = Promise<_GACURLSessionDataResponse>.pending()
|
||||
|
||||
if let expectedError {
|
||||
promise.reject(expectedError)
|
||||
} else {
|
||||
let response = expectedResponse ?? _GACURLSessionDataResponse(
|
||||
response: HTTPURLResponse(),
|
||||
httpBody: Data()
|
||||
)
|
||||
promise.fulfill(response)
|
||||
}
|
||||
|
||||
return promise.asObjCPromise()
|
||||
}
|
||||
|
||||
func appCheckToken(withAPIResponse response: _GACURLSessionDataResponse)
|
||||
-> FBLPromise<AppCheckCoreToken> {
|
||||
let promise = Promise<AppCheckCoreToken>.pending()
|
||||
|
||||
if let expectedError {
|
||||
promise.reject(expectedError)
|
||||
} else {
|
||||
let token = expectedToken ?? AppCheckCoreToken(
|
||||
token: "placeholder_app_check_token",
|
||||
expirationDate: Date()
|
||||
)
|
||||
promise.fulfill(token)
|
||||
}
|
||||
|
||||
return promise.asObjCPromise()
|
||||
}
|
||||
}
|
||||
|
||||
class MockBackoffWrapper: NSObject, _GACAppCheckBackoffWrapperProtocol {
|
||||
var applyBackoffCalled = false
|
||||
var shouldReturnError = false
|
||||
var mockError: NSError?
|
||||
var mockResult: Any?
|
||||
var capturedErrorHandler: GACAppCheckBackoffErrorHandler?
|
||||
|
||||
func applyBackoff(toOperation operationProvider: @escaping GACAppCheckBackoffOperationProvider,
|
||||
errorHandler: @escaping GACAppCheckBackoffErrorHandler)
|
||||
-> FBLPromise<AnyObject> {
|
||||
applyBackoffCalled = true
|
||||
capturedErrorHandler = errorHandler
|
||||
if shouldReturnError {
|
||||
let error = mockError ?? NSError(domain: "MockBackoffWrapper", code: -1, userInfo: nil)
|
||||
let swiftPromise = Promise<AnyObject>(error as Error)
|
||||
return swiftPromise.asObjCPromise()
|
||||
}
|
||||
if let mockResult {
|
||||
let swiftPromise = Promise<AnyObject>(mockResult as AnyObject)
|
||||
return swiftPromise.asObjCPromise()
|
||||
}
|
||||
return operationProvider()
|
||||
}
|
||||
|
||||
func defaultAppCheckProviderErrorHandler() -> GACAppCheckBackoffErrorHandler {
|
||||
return { error in .typeExponential }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import AppCheckCore
|
||||
@testable import AppCheckRecaptchaProvider
|
||||
import FBLPromises
|
||||
|
||||
@available(iOS 15.0, visionOS 1.0, *)
|
||||
@available(macOS, unavailable)
|
||||
@available(macCatalyst, unavailable)
|
||||
@available(tvOS, unavailable)
|
||||
@available(watchOS, unavailable)
|
||||
final class RecaptchaAPIServiceTests: XCTestCase {
|
||||
private var apiService: RecaptchaAPIService!
|
||||
private var mockCoreAPIService: MockAppCheckCoreAPIService!
|
||||
private let testResourceName = "projects/test-project/apps/test-app"
|
||||
private let testRecaptchaToken = "recaptcha-token-123"
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
mockCoreAPIService = MockAppCheckCoreAPIService()
|
||||
apiService = RecaptchaAPIService(
|
||||
apiService: mockCoreAPIService,
|
||||
resourceName: testResourceName
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
apiService = nil
|
||||
mockCoreAPIService = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testAppCheckTokenSuccess() throws {
|
||||
// Arrange
|
||||
let expectedAppCheckToken = AppCheckCoreToken(
|
||||
token: "app-check-token-456",
|
||||
expirationDate: Date(timeIntervalSinceNow: 3600)
|
||||
)
|
||||
mockCoreAPIService.expectedToken = expectedAppCheckToken
|
||||
|
||||
let expectation = self.expectation(description: "Token exchange completes successfully")
|
||||
|
||||
// Act
|
||||
apiService.appCheckToken(with: testRecaptchaToken, limitedUse: false)
|
||||
.then { token in
|
||||
// Assert
|
||||
XCTAssertEqual(token.token, expectedAppCheckToken.token)
|
||||
XCTAssertEqual(token.expirationDate, expectedAppCheckToken.expirationDate)
|
||||
|
||||
// Verify request
|
||||
guard let request = self.mockCoreAPIService.lastRequest else {
|
||||
XCTFail("No request was sent")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(
|
||||
request.url?.absoluteString,
|
||||
"https://test.com/\(self.testResourceName):exchangeRecaptchaEnterpriseToken"
|
||||
)
|
||||
XCTAssertEqual(request.httpMethod, "POST")
|
||||
XCTAssertEqual(request.additionalHeaders?["Content-Type"], "application/json")
|
||||
|
||||
if let body = request.body {
|
||||
let json = try? JSONSerialization.jsonObject(with: body, options: []) as? [String: Any]
|
||||
XCTAssertEqual(json?["recaptcha_enterprise_token"] as? String, self.testRecaptchaToken)
|
||||
XCTAssertEqual(json?["limited_use"] as? Bool, false)
|
||||
} else {
|
||||
XCTFail("Request body was empty")
|
||||
}
|
||||
|
||||
expectation.fulfill()
|
||||
}.catch { error in
|
||||
XCTFail("Unexpected error: \(error)")
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 1.0)
|
||||
}
|
||||
|
||||
func testAppCheckTokenLimitedUseSuccess() throws {
|
||||
// Arrange
|
||||
let expectedAppCheckToken = AppCheckCoreToken(
|
||||
token: "app-check-token-456",
|
||||
expirationDate: Date(timeIntervalSinceNow: 3600)
|
||||
)
|
||||
mockCoreAPIService.expectedToken = expectedAppCheckToken
|
||||
|
||||
let expectation = self
|
||||
.expectation(description: "Limited use token exchange completes successfully")
|
||||
|
||||
// Act
|
||||
apiService.appCheckToken(with: testRecaptchaToken, limitedUse: true)
|
||||
.then { token in
|
||||
// Assert
|
||||
guard let request = self.mockCoreAPIService.lastRequest, let body = request.body else {
|
||||
XCTFail("No request or body")
|
||||
return
|
||||
}
|
||||
|
||||
let json = try? JSONSerialization.jsonObject(with: body, options: []) as? [String: Any]
|
||||
XCTAssertEqual(json?["limited_use"] as? Bool, true)
|
||||
|
||||
expectation.fulfill()
|
||||
}.catch { error in
|
||||
XCTFail("Unexpected error: \(error)")
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 1.0)
|
||||
}
|
||||
|
||||
func testAppCheckTokenEmptyRecaptchaToken() {
|
||||
let expectation = self.expectation(description: "Token exchange fails with empty token")
|
||||
|
||||
apiService.appCheckToken(with: "", limitedUse: false).then { token in
|
||||
XCTFail("Should not succeed with empty token")
|
||||
}.catch { error in
|
||||
XCTAssertNotNil(error)
|
||||
XCTAssertEqual((error as NSError).domain, AppCheckCoreErrorDomain)
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 1.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import AppCheckCore
|
||||
@testable import AppCheckRecaptchaProvider
|
||||
import FBLPromises
|
||||
import Promises
|
||||
import RecaptchaInterop
|
||||
|
||||
@available(iOS 15.0, visionOS 1.0, *)
|
||||
@available(macOS, unavailable)
|
||||
@available(macCatalyst, unavailable)
|
||||
@available(tvOS, unavailable)
|
||||
@available(watchOS, unavailable)
|
||||
final class RecaptchaTokenGeneratorTests: XCTestCase {
|
||||
private let testSiteKey = "test-site-key"
|
||||
private var mockAction: MockRCAAction!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
mockAction = MockRCAAction(customAction: "test_action")
|
||||
MockRecaptcha.mockClient = nil
|
||||
MockRecaptcha.mockError = nil
|
||||
}
|
||||
|
||||
func testGetRecaptchaTokenSuccess() {
|
||||
// Arrange
|
||||
let mockClient = MockRecaptchaClient()
|
||||
mockClient.mockToken = "valid-recaptcha-token"
|
||||
MockRecaptcha.mockClient = mockClient
|
||||
|
||||
let generator = RecaptchaTokenGenerator(
|
||||
siteKey: testSiteKey,
|
||||
recaptchaAction: mockAction,
|
||||
recaptchaClass: MockRecaptcha.self,
|
||||
backoffWrapper: MockBackoffWrapper()
|
||||
)
|
||||
|
||||
let expectation = self.expectation(description: "Generates token successfully")
|
||||
|
||||
// Act
|
||||
generator.getRecaptchaToken().then { token in
|
||||
// Assert
|
||||
XCTAssertEqual(token, "valid-recaptcha-token")
|
||||
expectation.fulfill()
|
||||
}.catch { error in
|
||||
XCTFail("Unexpected error: \(error)")
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 1.0)
|
||||
}
|
||||
|
||||
func testGetRecaptchaTokenFetchClientFailure() {
|
||||
// Arrange
|
||||
let expectedError = NSError(domain: "test", code: -1, userInfo: nil)
|
||||
MockRecaptcha.mockError = expectedError
|
||||
|
||||
let generator = RecaptchaTokenGenerator(
|
||||
siteKey: testSiteKey,
|
||||
recaptchaAction: mockAction,
|
||||
recaptchaClass: MockRecaptcha.self,
|
||||
backoffWrapper: MockBackoffWrapper()
|
||||
)
|
||||
|
||||
let expectation = self.expectation(description: "Fails when fetchClient fails")
|
||||
|
||||
// Act
|
||||
generator.getRecaptchaToken().then { token in
|
||||
XCTFail("Should not succeed when fetchClient fails")
|
||||
}.catch { error in
|
||||
// Assert
|
||||
XCTAssertEqual((error as NSError).domain, expectedError.domain)
|
||||
XCTAssertEqual((error as NSError).code, expectedError.code)
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 1.0)
|
||||
}
|
||||
|
||||
func testGetRecaptchaTokenExecutionFailure() {
|
||||
// Arrange
|
||||
let mockClient = MockRecaptchaClient()
|
||||
let expectedError = NSError(domain: "test", code: -2, userInfo: nil)
|
||||
mockClient.mockError = expectedError
|
||||
MockRecaptcha.mockClient = mockClient
|
||||
|
||||
let generator = RecaptchaTokenGenerator(
|
||||
siteKey: testSiteKey,
|
||||
recaptchaAction: mockAction,
|
||||
recaptchaClass: MockRecaptcha.self,
|
||||
backoffWrapper: MockBackoffWrapper()
|
||||
)
|
||||
|
||||
let expectation = self.expectation(description: "Fails when execute fails")
|
||||
|
||||
// Act
|
||||
generator.getRecaptchaToken().then { token in
|
||||
XCTFail("Should not succeed when execute fails")
|
||||
}.catch { error in
|
||||
// Assert
|
||||
let nsError = error as NSError
|
||||
XCTAssertEqual(nsError.domain, AppCheckCoreErrorDomain)
|
||||
XCTAssertEqual(nsError.code, AppCheckCoreErrorCode.unknown.rawValue)
|
||||
|
||||
let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] as? NSError
|
||||
XCTAssertNotNil(underlyingError)
|
||||
XCTAssertEqual(underlyingError?.domain, expectedError.domain)
|
||||
XCTAssertEqual(underlyingError?.code, expectedError.code)
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 1.0)
|
||||
}
|
||||
|
||||
func testGetRecaptchaTokenCallsBackoffWrapper() {
|
||||
// Arrange
|
||||
let mockClient = MockRecaptchaClient()
|
||||
mockClient.mockToken = "valid-recaptcha-token"
|
||||
MockRecaptcha.mockClient = mockClient
|
||||
|
||||
let mockBackoffWrapper = MockBackoffWrapper()
|
||||
|
||||
let generator = RecaptchaTokenGenerator(
|
||||
siteKey: testSiteKey,
|
||||
recaptchaAction: mockAction,
|
||||
recaptchaClass: MockRecaptcha.self,
|
||||
backoffWrapper: mockBackoffWrapper
|
||||
)
|
||||
|
||||
let expectation = self.expectation(description: "Calls backoff wrapper")
|
||||
|
||||
// Act
|
||||
generator.getRecaptchaToken().then { token in
|
||||
// Assert
|
||||
XCTAssertTrue(mockBackoffWrapper.applyBackoffCalled)
|
||||
expectation.fulfill()
|
||||
}.catch { error in
|
||||
XCTFail("Unexpected error: \(error)")
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 1.0)
|
||||
}
|
||||
|
||||
func testGetRecaptchaTokenBackoffWrapperError() {
|
||||
// Arrange
|
||||
let mockClient = MockRecaptchaClient()
|
||||
MockRecaptcha.mockClient = mockClient
|
||||
|
||||
let mockBackoffWrapper = MockBackoffWrapper()
|
||||
mockBackoffWrapper.shouldReturnError = true
|
||||
let expectedError = NSError(domain: "test", code: -3, userInfo: nil)
|
||||
mockBackoffWrapper.mockError = expectedError
|
||||
|
||||
let generator = RecaptchaTokenGenerator(
|
||||
siteKey: testSiteKey,
|
||||
recaptchaAction: mockAction,
|
||||
recaptchaClass: MockRecaptcha.self,
|
||||
backoffWrapper: mockBackoffWrapper
|
||||
)
|
||||
|
||||
let expectation = self.expectation(description: "Fails when backoff wrapper fails")
|
||||
|
||||
// Act
|
||||
generator.getRecaptchaToken().then { token in
|
||||
XCTFail("Should not succeed when backoff wrapper fails")
|
||||
}.catch { error in
|
||||
// Assert
|
||||
XCTAssertEqual((error as NSError).domain, expectedError.domain)
|
||||
XCTAssertEqual((error as NSError).code, expectedError.code)
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 1.0)
|
||||
}
|
||||
|
||||
func testGetRecaptchaTokenMapsNetworkErrorToServerUnreachable() {
|
||||
// Arrange
|
||||
let mockClient = MockRecaptchaClient()
|
||||
let recaptchaError = NSError(
|
||||
domain: "RecaptchaErrorDomain",
|
||||
code: RecaptchaTokenGenerator.networkErrorCode,
|
||||
userInfo: nil
|
||||
)
|
||||
mockClient.mockError = recaptchaError
|
||||
MockRecaptcha.mockClient = mockClient
|
||||
|
||||
let mockBackoffWrapper = MockBackoffWrapper()
|
||||
|
||||
let generator = RecaptchaTokenGenerator(
|
||||
siteKey: testSiteKey,
|
||||
recaptchaAction: mockAction,
|
||||
recaptchaClass: MockRecaptcha.self,
|
||||
backoffWrapper: mockBackoffWrapper
|
||||
)
|
||||
|
||||
let expectation = self.expectation(description: "Maps NetworkError to ServerUnreachable")
|
||||
|
||||
// Act
|
||||
generator.getRecaptchaToken().then { token in
|
||||
XCTFail("Should not succeed when execute fails")
|
||||
}.catch { error in
|
||||
// Assert
|
||||
let nsError = error as NSError
|
||||
XCTAssertEqual(nsError.domain, AppCheckCoreErrorDomain)
|
||||
XCTAssertEqual(nsError.code, AppCheckCoreErrorCode.serverUnreachable.rawValue)
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 1.0)
|
||||
}
|
||||
|
||||
func testGetRecaptchaTokenMapsInternalErrorToServerUnreachable() {
|
||||
// Arrange
|
||||
let mockClient = MockRecaptchaClient()
|
||||
let recaptchaError = NSError(
|
||||
domain: "RecaptchaErrorDomain",
|
||||
code: RecaptchaTokenGenerator.internalErrorCode,
|
||||
userInfo: nil
|
||||
)
|
||||
mockClient.mockError = recaptchaError
|
||||
MockRecaptcha.mockClient = mockClient
|
||||
|
||||
let mockBackoffWrapper = MockBackoffWrapper()
|
||||
|
||||
let generator = RecaptchaTokenGenerator(
|
||||
siteKey: testSiteKey,
|
||||
recaptchaAction: mockAction,
|
||||
recaptchaClass: MockRecaptcha.self,
|
||||
backoffWrapper: mockBackoffWrapper
|
||||
)
|
||||
|
||||
let expectation = self.expectation(description: "Maps InternalError to ServerUnreachable")
|
||||
|
||||
// Act
|
||||
generator.getRecaptchaToken().then { token in
|
||||
XCTFail("Should not succeed when execute fails")
|
||||
}.catch { error in
|
||||
// Assert
|
||||
let nsError = error as NSError
|
||||
XCTAssertEqual(nsError.domain, AppCheckCoreErrorDomain)
|
||||
XCTAssertEqual(nsError.code, AppCheckCoreErrorCode.serverUnreachable.rawValue)
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 1.0)
|
||||
}
|
||||
|
||||
func testErrorHandlerTriggersBackoffForServerUnreachable() {
|
||||
// Arrange
|
||||
let mockClient = MockRecaptchaClient()
|
||||
mockClient.mockToken = "valid-recaptcha-token"
|
||||
MockRecaptcha.mockClient = mockClient
|
||||
|
||||
let mockBackoffWrapper = MockBackoffWrapper()
|
||||
|
||||
let generator = RecaptchaTokenGenerator(
|
||||
siteKey: testSiteKey,
|
||||
recaptchaAction: mockAction,
|
||||
recaptchaClass: MockRecaptcha.self,
|
||||
backoffWrapper: mockBackoffWrapper
|
||||
)
|
||||
|
||||
let expectation = self.expectation(description: "Calls backoff wrapper")
|
||||
|
||||
// Act
|
||||
generator.getRecaptchaToken().then { _ in
|
||||
// Assert
|
||||
XCTAssertNotNil(mockBackoffWrapper.capturedErrorHandler)
|
||||
if let errorHandler = mockBackoffWrapper.capturedErrorHandler {
|
||||
let serverUnreachableError = NSError(
|
||||
domain: AppCheckCoreErrorDomain,
|
||||
code: AppCheckCoreErrorCode.serverUnreachable.rawValue,
|
||||
userInfo: nil
|
||||
)
|
||||
let backoffType = errorHandler(serverUnreachableError)
|
||||
XCTAssertEqual(backoffType, .typeExponential)
|
||||
}
|
||||
expectation.fulfill()
|
||||
}.catch { error in
|
||||
XCTFail("Unexpected error: \(error)")
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 1.0)
|
||||
}
|
||||
|
||||
func testErrorHandlerDoesNotTriggerBackoffForOtherErrors() {
|
||||
// Arrange
|
||||
let mockClient = MockRecaptchaClient()
|
||||
mockClient.mockToken = "valid-recaptcha-token"
|
||||
MockRecaptcha.mockClient = mockClient
|
||||
|
||||
let mockBackoffWrapper = MockBackoffWrapper()
|
||||
|
||||
let generator = RecaptchaTokenGenerator(
|
||||
siteKey: testSiteKey,
|
||||
recaptchaAction: mockAction,
|
||||
recaptchaClass: MockRecaptcha.self,
|
||||
backoffWrapper: mockBackoffWrapper
|
||||
)
|
||||
|
||||
let expectation = self.expectation(description: "Calls backoff wrapper")
|
||||
|
||||
// Act
|
||||
generator.getRecaptchaToken().then { _ in
|
||||
// Assert
|
||||
XCTAssertNotNil(mockBackoffWrapper.capturedErrorHandler)
|
||||
if let errorHandler = mockBackoffWrapper.capturedErrorHandler {
|
||||
let otherError = NSError(
|
||||
domain: AppCheckCoreErrorDomain,
|
||||
code: AppCheckCoreErrorCode.unknown.rawValue,
|
||||
userInfo: nil
|
||||
)
|
||||
let backoffType = errorHandler(otherError)
|
||||
XCTAssertEqual(backoffType, .typeNone)
|
||||
}
|
||||
expectation.fulfill()
|
||||
}.catch { error in
|
||||
XCTFail("Unexpected error: \(error)")
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 1.0)
|
||||
}
|
||||
|
||||
func testGetRecaptchaTokenExecutionNilNilFallback() {
|
||||
// Arrange
|
||||
let mockClient = MockRecaptchaClient()
|
||||
MockRecaptcha.mockClient = mockClient
|
||||
|
||||
let generator = RecaptchaTokenGenerator(
|
||||
siteKey: testSiteKey,
|
||||
recaptchaAction: mockAction,
|
||||
recaptchaClass: MockRecaptcha.self,
|
||||
backoffWrapper: MockBackoffWrapper()
|
||||
)
|
||||
|
||||
let expectation = self
|
||||
.expectation(description: "Fails with fallback error when execute returns nil, nil")
|
||||
|
||||
// Act
|
||||
generator.getRecaptchaToken().then { token in
|
||||
XCTFail("Should not succeed when execute returns nil, nil")
|
||||
}.catch { error in
|
||||
// Assert
|
||||
let nsError = error as NSError
|
||||
XCTAssertEqual(nsError.domain, AppCheckCoreErrorDomain)
|
||||
XCTAssertEqual(nsError.code, AppCheckCoreErrorCode.unknown.rawValue)
|
||||
XCTAssertEqual(nsError.localizedFailureReason, "Failed to execute Recaptcha action")
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 1.0)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user