// Copyright 2023 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 Foundation import XCTest @testable import FirebaseAuth // TODO(ncooke3): Investigate making this class support generics for the `request`. // TODO(ncooke3): Refactor to make checked Sendable. /// An implementation of `AuthBackendRPCIssuerProtocol` which is used to test /// backend request, response, and glue logic. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) final class FakeBackendRPCIssuer: AuthBackendRPCIssuerProtocol, @unchecked Sendable { /** @property requestURL @brief The URL which was requested. */ var requestURL: URL? /** @property requestData @brief The raw data in the POST body. */ var requestData: Data? /** @property decodedRequest @brief The raw data in the POST body decoded as JSON. */ var decodedRequest: [String: Any]? /** @property contentType @brief The value of the content type HTTP header in the request. */ var contentType: String? /** @property request @brief Save the request for validation. */ var request: (any AuthRPCRequest)? /** @property completeRequest @brief The last request to be processed by the backend. */ var completeRequest: Task! /** @var _handler @brief A block we must invoke when @c respondWithError or @c respondWithJSON are called. */ private var handler: ((Data?, Error?) -> Void)? /** @var verifyRequester @brief Optional function to run tests on the request. */ var verifyRequester: ((SendVerificationCodeRequest) -> (Data?, Error?))? var verifyClientRequester: ((VerifyClientRequest) -> (Data?, Error?))? var projectConfigRequester: ((GetProjectConfigRequest) -> (Data?, Error?))? var verifyPasswordRequester: ((VerifyPasswordRequest) -> (Data?, Error?))? var verifyPhoneNumberRequester: ((VerifyPhoneNumberRequest) -> Void)? var respondBlock: (() throws -> (Data?, Error?))? var nextRespondBlock: (() throws -> (Data?, Error?))? var fakeGetAccountProviderJSON: [[String: AnyHashable]]? var fakeSecureTokenServiceJSON: [String: AnyHashable]? var secureTokenNetworkError: NSError? var secureTokenErrorString: String? var recaptchaSiteKey = "projects/fakeProjectId/keys/mockSiteKey" var rceMode: String = "OFF" func asyncCallToURL(with request: T, body: Data?, contentType: String) async -> (Data?, Error?) where T: FirebaseAuth.AuthRPCRequest { self.contentType = contentType self.request = request requestURL = request.requestURL() // TODO: See if we can use the above generics to avoid all this. if let verifyRequester, let verifyRequest = request as? SendVerificationCodeRequest { return verifyRequester(verifyRequest) } else if let verifyClientRequester, let verifyClientRequest = request as? VerifyClientRequest { return verifyClientRequester(verifyClientRequest) } else if let projectConfigRequester, let projectConfigRequest = request as? GetProjectConfigRequest { return projectConfigRequester(projectConfigRequest) } else if let verifyPasswordRequester, let verifyPasswordRequest = request as? VerifyPasswordRequest { return verifyPasswordRequester(verifyPasswordRequest) } else if let verifyPhoneNumberRequester, let verifyPhoneNumberRequest = request as? VerifyPhoneNumberRequest { verifyPhoneNumberRequester(verifyPhoneNumberRequest) } if let _ = request as? GetAccountInfoRequest, let json = fakeGetAccountProviderJSON { guard let (data, error) = try? respond(withJSON: ["users": json]) else { fatalError("fakeGetAccountProviderJSON respond failed") } return (data, error) } else if let _ = request as? GetRecaptchaConfigRequest { if rceMode != "OFF" { // Check if reCAPTCHA is enabled let recaptchaKey = recaptchaSiteKey // iOS key from your config let enforcementState = [ ["provider": "EMAIL_PASSWORD_PROVIDER", "enforcementState": rceMode], ["provider": "PHONE_PROVIDER", "enforcementState": rceMode], ] guard let (data, error) = try? respond(withJSON: [ "recaptchaKey": recaptchaKey, "recaptchaEnforcementState": enforcementState, ]) else { fatalError("GetRecaptchaConfigRequest respond failed") } return (data, error) } else { // reCAPTCHA OFF let enforcementState = [ ["provider": "EMAIL_PASSWORD_PROVIDER", "enforcementState": "OFF"], ["provider": "PHONE_PROVIDER", "enforcementState": "OFF"], ] guard let (data, error) = try? respond(withJSON: [ "recaptchaEnforcementState": enforcementState, ]) else { fatalError("GetRecaptchaConfigRequest respond failed") } return (data, error) } } else if let _ = request as? SecureTokenRequest { if let secureTokenNetworkError { return (nil, secureTokenNetworkError) } else if let secureTokenErrorString { guard let (data, error) = try? respond(serverErrorMessage: secureTokenErrorString) else { fatalError("Failed to generate secureTokenErrorString") } return (data, error) } else if let json = fakeSecureTokenServiceJSON { guard let (data, error) = try? respond(withJSON: json) else { fatalError("fakeGetAccountProviderJSON respond failed") } return (data, error) } } if let body = body { requestData = body // Use the real implementation so that the complete request can // be verified during testing. let requestURL = request.requestURL() let requestConfiguration = request.requestConfiguration() completeRequest = Task { await AuthBackend .request( for: requestURL, httpMethod: requestData == nil ? "GET" : "POST", contentType: contentType, requestConfiguration: requestConfiguration ) } decodedRequest = try? JSONSerialization.jsonObject(with: body) as? [String: Any] } if let respondBlock { do { let (data, error) = try respondBlock() self.respondBlock = nextRespondBlock nextRespondBlock = nil return (data, error) } catch { return (nil, error) } } fatalError("Should never get here") } func respond(serverErrorMessage errorMessage: String) throws -> (Data, Error?) { let error = NSError(domain: NSCocoaErrorDomain, code: 0) return try respond(serverErrorMessage: errorMessage, error: error) } func respond(serverErrorMessage errorMessage: String, error: NSError) throws -> (Data, Error?) { return try respond(withJSON: ["error": ["message": errorMessage]], error: error) } func respond(underlyingErrorMessage errorMessage: String, message: String = "See the reason") throws -> (Data, Error?) { let error = NSError(domain: NSCocoaErrorDomain, code: 0) return try respond( withJSON: ["error": ["message": message, "errors": [["reason": errorMessage]]] as [String: Any]], error: error ) } func respond(withJSON json: [String: Any], error: NSError? = nil) throws -> (Data, Error?) { return try (JSONSerialization.data(withJSONObject: json, options: JSONSerialization.WritingOptions.prettyPrinted), error) } }