// // Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation @testable import SignalServiceKit import XCTest public class RegistrationSessionManagerTest: XCTestCase { private var registrationSessionManager: RegistrationSessionManagerImpl! private var date = Date() private var db: MockDB! private var kvStore: InMemoryKeyValueStore! private var mockURLSession: TSRequestOWSURLSessionMock! private var scheduler: TestScheduler! public override func setUp() { db = MockDB() let mockURLSession = TSRequestOWSURLSessionMock() self.mockURLSession = mockURLSession let mockSignalService = OWSSignalServiceMock() mockSignalService.mockUrlSessionBuilder = { _, _, _ in return mockURLSession } let kvStoreFactory = InMemoryKeyValueStoreFactory() kvStore = kvStoreFactory.keyValueStore( collection: RegistrationSessionManagerImpl.KvStore.collectionName ) as? InMemoryKeyValueStore scheduler = TestScheduler() // Don't care about time in these tests, just run everything sync. scheduler.start() registrationSessionManager = RegistrationSessionManagerImpl( dateProvider: { self.date }, db: db, keyValueStoreFactory: kvStoreFactory, schedulers: TestSchedulers(scheduler: scheduler), signalService: mockSignalService ) } public func testParseRegistrationSession() { let code = "1234" let oldSession = stubSession() let expectedRequest = RegistrationRequestFactory.submitVerificationCodeRequest( sessionId: oldSession.id, code: code ) // A standard response var responseJSON = """ { "id": "abcd", "nextSms": 1, "nextCall": 2, "nextVerificationAttempt": 3, "allowedToRequestCode": true, "requestedInformation": ["captcha", "pushChallenge"], "verified": false, } """ mockURLSession.addResponse(TSRequestOWSURLSessionMock.Response( matcher: { $0.url == expectedRequest.url }, statusCode: RegistrationServiceResponses.SubmitVerificationCodeResponseCodes.success.rawValue, bodyData: responseJSON.data(using: .utf8) )) registrationSessionManager.requestVerificationCode( for: oldSession, transport: [Registration.CodeTransport.sms, .voice].randomElement()! ).done(on: scheduler) { result in XCTAssertEqual(result, .success(RegistrationSession( id: "abcd", e164: oldSession.e164, receivedDate: self.date, nextSMS: 1, nextCall: 2, nextVerificationAttempt: 3, allowedToRequestCode: true, requestedInformation: [.captcha, .pushChallenge], hasUnknownChallengeRequiringAppUpdate: false, verified: false ))) } // Try empty time durations responseJSON = """ { "id": "abcd", "allowedToRequestCode": true, "requestedInformation": ["captcha", "pushChallenge"], "verified": false, } """ mockURLSession.addResponse(TSRequestOWSURLSessionMock.Response( matcher: { $0.url == expectedRequest.url }, statusCode: RegistrationServiceResponses.SubmitVerificationCodeResponseCodes.success.rawValue, bodyData: responseJSON.data(using: .utf8) )) registrationSessionManager.requestVerificationCode( for: oldSession, transport: [Registration.CodeTransport.sms, .voice].randomElement()! ).done(on: scheduler) { result in XCTAssertEqual(result, .success(RegistrationSession( id: "abcd", e164: oldSession.e164, receivedDate: self.date, nextSMS: nil, nextCall: nil, nextVerificationAttempt: nil, allowedToRequestCode: true, requestedInformation: [.captcha, .pushChallenge], hasUnknownChallengeRequiringAppUpdate: false, verified: false ))) } // Try requested info we don't know how to parse. responseJSON = """ { "id": "abcd", "nextSms": 1, "nextCall": 2, "nextVerificationAttempt": 3, "allowedToRequestCode": true, "requestedInformation": ["someRandomGarbage", "captcha", "pushChallenge"], "verified": false, } """ mockURLSession.addResponse(TSRequestOWSURLSessionMock.Response( matcher: { $0.url == expectedRequest.url }, statusCode: RegistrationServiceResponses.SubmitVerificationCodeResponseCodes.success.rawValue, bodyData: responseJSON.data(using: .utf8) )) registrationSessionManager.requestVerificationCode( for: oldSession, transport: [Registration.CodeTransport.sms, .voice].randomElement()! ).done(on: scheduler) { result in XCTAssertEqual(result, .success(RegistrationSession( id: "abcd", e164: oldSession.e164, receivedDate: self.date, nextSMS: 1, nextCall: 2, nextVerificationAttempt: 3, allowedToRequestCode: true, requestedInformation: [.captcha, .pushChallenge], // We saw unknown challenges hasUnknownChallengeRequiringAppUpdate: true, verified: false ))) } } public func testBeginOrRestoreSession() throws { let e164 = E164("+17875550100")! let apnsToken = "1234" let beginSessionRequest = RegistrationRequestFactory.beginSessionRequest( e164: e164, pushToken: apnsToken, mcc: nil, mnc: nil ) // Without any setup, we should try and begin a new session. var responseBody = stubWireSession() var responseSession = sessionConverter(responseBody) mockURLSession.addResponse( forUrlSuffix: beginSessionRequest.url!.relativeString, bodyJson: responseBody ) registrationSessionManager.beginOrRestoreSession( e164: e164, apnsToken: apnsToken ).done(on: scheduler) { result in XCTAssertEqual(result, .success(responseSession)) } // The session should be in storage. var savedSession = try db.read { transaction in let session: RegistrationSession? = try kvStore.getCodableValue( forKey: RegistrationSessionManagerImpl.KvStore.sessionKey, transaction: transaction ) return session } XCTAssertEqual(savedSession, responseSession) // Now we should get back the same session if we try again, with a request // only to check its validity. var fetchSessionRequest = RegistrationRequestFactory.fetchSessionRequest(sessionId: responseSession.id) // Make a new instance, which shuffles the id responseBody = stubWireSession() responseSession = sessionConverter(responseBody) mockURLSession.addResponse( forUrlSuffix: fetchSessionRequest.url!.relativeString, bodyJson: responseBody ) registrationSessionManager.beginOrRestoreSession( e164: e164, apnsToken: apnsToken ).done(on: scheduler) { result in XCTAssertEqual(result, .success(responseSession)) } // The new (shuffled id) session should be in storage. savedSession = try db.read { transaction in let session: RegistrationSession? = try kvStore.getCodableValue( forKey: RegistrationSessionManagerImpl.KvStore.sessionKey, transaction: transaction ) return session } XCTAssertEqual(savedSession, responseSession) // If we have the service respond that the session is invalid, we should get a fresh // session. fetchSessionRequest = RegistrationRequestFactory.fetchSessionRequest(sessionId: responseSession.id) // Make a new instance, which shuffles the id responseBody = stubWireSession() responseSession = sessionConverter(responseBody) mockURLSession.addResponse( forUrlSuffix: fetchSessionRequest.url!.relativeString, statusCode: RegistrationServiceResponses.FetchSessionResponseCodes.missingSession.rawValue ) mockURLSession.addResponse( forUrlSuffix: beginSessionRequest.url!.relativeString, bodyJson: responseBody ) registrationSessionManager.beginOrRestoreSession( e164: e164, apnsToken: apnsToken ).done(on: scheduler) { result in XCTAssertEqual(result, .success(responseSession)) } // The new (shuffled id) session should be in storage. savedSession = try db.read { transaction in let session: RegistrationSession? = try kvStore.getCodableValue( forKey: RegistrationSessionManagerImpl.KvStore.sessionKey, transaction: transaction ) return session } XCTAssertEqual(savedSession, responseSession) // If we complete the session, that should reset everything and behave like the first time. db.write { registrationSessionManager.clearPersistedSession($0) } // Should have no saved session savedSession = try db.read { transaction in let session: RegistrationSession? = try kvStore.getCodableValue( forKey: RegistrationSessionManagerImpl.KvStore.sessionKey, transaction: transaction ) return session } XCTAssertNil(savedSession) responseBody = stubWireSession() responseSession = sessionConverter(responseBody) mockURLSession.addResponse( forUrlSuffix: beginSessionRequest.url!.relativeString, bodyJson: responseBody ) registrationSessionManager.beginOrRestoreSession( e164: e164, apnsToken: apnsToken ).done(on: scheduler) { result in XCTAssertEqual(result, .success(responseSession)) } // The session should be in storage. savedSession = try db.read { transaction in let session: RegistrationSession? = try kvStore.getCodableValue( forKey: RegistrationSessionManagerImpl.KvStore.sessionKey, transaction: transaction ) return session } XCTAssertEqual(savedSession, responseSession) // If we try and request with a different e164 it should also get a new session and wipe the old one. let newE164 = E164("+17875550101")! responseBody = stubWireSession() responseSession = sessionConverter(responseBody, e164: newE164) mockURLSession.addResponse( forUrlSuffix: beginSessionRequest.url!.relativeString, bodyJson: responseBody ) registrationSessionManager.beginOrRestoreSession( e164: newE164, apnsToken: apnsToken ).done(on: scheduler) { result in XCTAssertEqual(result, .success(responseSession)) } // The session should be in storage. savedSession = try db.read { transaction in let session: RegistrationSession? = try kvStore.getCodableValue( forKey: RegistrationSessionManagerImpl.KvStore.sessionKey, transaction: transaction ) return session } XCTAssertEqual(savedSession, responseSession) } public func testFulfillChallenge() { let captchaToken = "1234" let pushChallengeToken = "ABCD" let oldSession = stubSession() let expectedRequest = RegistrationRequestFactory.fulfillChallengeRequest( sessionId: oldSession.id, captchaToken: captchaToken, // Put both, doesn't matter cuz we just match the url. pushChallengeToken: pushChallengeToken ) // will have a new id, which is fine cuz it lets us differentiate. let responseBody = stubWireSession() let responseSession = sessionConverter(responseBody) let statusCodeResponsePairs: [( RegistrationServiceResponses.FulfillChallengeResponseCodes, Registration.UpdateSessionResponse, Bool // expects session in response )] = [ (.success, .success(responseSession), true), (.notAccepted, .rejectedArgument(responseSession), true), (.missingSession, .invalidSession, false), (.notAccepted, .rejectedArgument(responseSession), true), (.unexpectedError, .genericError, false) ] for (statusCode, expectedResponse, sessionInBody) in statusCodeResponsePairs { mockURLSession.addResponse( forUrlSuffix: expectedRequest.url!.relativeString, statusCode: statusCode.rawValue, bodyJson: sessionInBody ? responseBody : nil ) registrationSessionManager.fulfillChallenge( for: oldSession, fulfillment: [ Registration.ChallengeFulfillment.captcha(captchaToken), .pushChallenge(pushChallengeToken) ].randomElement()! ).done(on: scheduler) { result in XCTAssertEqual(result, expectedResponse) } } } public func testFulfillChallenge_sucessResponseWithoutBody() { let captchaToken = "1234" let oldSession = stubSession() let expectedRequest = RegistrationRequestFactory.fulfillChallengeRequest( sessionId: oldSession.id, captchaToken: captchaToken, pushChallengeToken: nil ) mockURLSession.addResponse( forUrlSuffix: expectedRequest.url!.relativeString, statusCode: RegistrationServiceResponses.FulfillChallengeResponseCodes.success.rawValue, bodyJson: nil /* empty json */ ) registrationSessionManager.fulfillChallenge( for: oldSession, fulfillment: .captcha(captchaToken) ).done(on: scheduler) { result in XCTAssertEqual(result, Registration.UpdateSessionResponse.genericError) } } public func testRequestVerificationCode() { let oldSession = stubSession() let expectedRequest = RegistrationRequestFactory.requestVerificationCodeRequest( sessionId: oldSession.id, languageCode: nil, countryCode: nil, transport: .sms ) // will have a new id, which is fine cuz it lets us differentiate. let responseBody = stubWireSession() let statusCodeResponsePairs: [( RegistrationServiceResponses.RequestVerificationCodeResponseCodes, Registration.UpdateSessionResponse, Bool // expects session in response )] = [ (.success, .success(sessionConverter(responseBody)), true), (.malformedRequest, .genericError, false), (.disallowed, .disallowed(sessionConverter(responseBody)), true), (.missingSession, .invalidSession, false), (.retry, .retryAfterTimeout(sessionConverter(responseBody)), true), (.unexpectedError, .genericError, false) ] for (statusCode, expectedResponse, sessionInBody) in statusCodeResponsePairs { mockURLSession.addResponse( forUrlSuffix: expectedRequest.url!.relativeString, statusCode: statusCode.rawValue, bodyJson: sessionInBody ? responseBody : nil ) registrationSessionManager.requestVerificationCode( for: oldSession, transport: [Registration.CodeTransport.sms, .voice].randomElement()! ).done(on: scheduler) { result in XCTAssertEqual(result, expectedResponse) } } } public func testRequestVerificationCodeServiceError() { let oldSession = stubSession() let expectedRequest = RegistrationRequestFactory.requestVerificationCodeRequest( sessionId: oldSession.id, languageCode: nil, countryCode: nil, transport: .sms ) var errorResponseJSON = """ { "permanentFailure": false, "reason": "providerRejected" } """ mockURLSession.addResponse(TSRequestOWSURLSessionMock.Response( matcher: { $0.url == expectedRequest.url }, statusCode: RegistrationServiceResponses.RequestVerificationCodeResponseCodes.providerFailure.rawValue, bodyData: errorResponseJSON.data(using: .utf8) )) registrationSessionManager.requestVerificationCode( for: oldSession, transport: [Registration.CodeTransport.sms, .voice].randomElement()! ).done(on: scheduler) { result in XCTAssertEqual(result, .serverFailure(Registration.ServerFailureResponse( session: oldSession, isPermanent: false, reason: .providerRejected ))) } errorResponseJSON = """ { "permanentFailure": false, "reason": "someRandomValueTheClientCantParse" } """ mockURLSession.addResponse(TSRequestOWSURLSessionMock.Response( matcher: { $0.url == expectedRequest.url }, statusCode: RegistrationServiceResponses.RequestVerificationCodeResponseCodes.providerFailure.rawValue, bodyData: errorResponseJSON.data(using: .utf8) )) registrationSessionManager.requestVerificationCode( for: oldSession, transport: [Registration.CodeTransport.sms, .voice].randomElement()! ).done(on: scheduler) { result in XCTAssertEqual(result, .serverFailure(Registration.ServerFailureResponse( session: oldSession, isPermanent: false, reason: nil ))) } } public func testSubmitVerificationCode() { let code = "1234" let oldSession = stubSession() let expectedRequest = RegistrationRequestFactory.submitVerificationCodeRequest( sessionId: oldSession.id, code: code ) // will have a new ids, which is fine cuz it lets us differentiate. let verifiedResponseBody = stubWireSession(verified: true) let verifiedResponseSession = sessionConverter(verifiedResponseBody) let unVerifiedResponseBody = stubWireSession(verified: false) let unVerifiedResponseSession = sessionConverter(unVerifiedResponseBody) let unVerifiedWithNoAttemptResponseBody = stubWireSession(verified: false, hasNextVerificationAttempt: false) let unVerifiedWithNoAttemptResponseSession = sessionConverter(unVerifiedWithNoAttemptResponseBody) let statusCodeResponsePairs: [( RegistrationServiceResponses.SubmitVerificationCodeResponseCodes, Registration.UpdateSessionResponse, RegistrationServiceResponses.RegistrationSession? )] = [ (.success, .success(verifiedResponseSession), verifiedResponseBody), (.success, .rejectedArgument(unVerifiedResponseSession), unVerifiedResponseBody), (.malformedRequest, .genericError, nil), (.missingSession, .invalidSession, nil), (.newCodeRequired, .success(verifiedResponseSession), verifiedResponseBody), (.newCodeRequired, .retryAfterTimeout(unVerifiedResponseSession), unVerifiedResponseBody), (.newCodeRequired, .disallowed(unVerifiedWithNoAttemptResponseSession), unVerifiedWithNoAttemptResponseBody), (.retry, .retryAfterTimeout(unVerifiedResponseSession), unVerifiedResponseBody), (.unexpectedError, .genericError, nil) ] for (statusCode, expectedResponse, sessionInBody) in statusCodeResponsePairs { mockURLSession.addResponse( forUrlSuffix: expectedRequest.url!.relativeString, statusCode: statusCode.rawValue, bodyJson: sessionInBody ) registrationSessionManager.submitVerificationCode( for: oldSession, code: code ).done(on: scheduler) { result in XCTAssertEqual(result, expectedResponse) } } } // MARK: - Helpers // MARK: Stub objects private func stubSession() -> RegistrationSession { return RegistrationSession( id: UUID().uuidString, e164: E164("+17875550100")!, // For our purposes, can be fixed. receivedDate: date, nextSMS: 1, nextCall: 1, nextVerificationAttempt: nil, allowedToRequestCode: true, requestedInformation: [], hasUnknownChallengeRequiringAppUpdate: false, verified: false ) } private func stubWireSession( verified: Bool = false, hasNextVerificationAttempt: Bool = true ) -> RegistrationServiceResponses.RegistrationSession { return RegistrationServiceResponses.RegistrationSession( id: UUID().uuidString, nextSms: (0...100).randomElement(), nextCall: (0...100).randomElement(), nextVerificationAttempt: hasNextVerificationAttempt ? (0...100).randomElement() : nil, allowedToRequestCode: false, requestedInformation: [.captcha, .pushChallenge], verified: verified ) } // Keep this independent of the production code converter for an extra layer of durability. private func sessionConverter( _ wireSession: RegistrationServiceResponses.RegistrationSession, e164: E164 = E164("+17875550100")! ) -> RegistrationSession { let requestedInformation: [RegistrationSession.Challenge] = wireSession.requestedInformation.compactMap { switch $0 { case .captcha: return .captcha case .pushChallenge: return .pushChallenge case .unknown: XCTFail("If you want to test wire conversion for real be explicit") return .captcha } } return RegistrationSession( id: wireSession.id, e164: e164, receivedDate: date, nextSMS: wireSession.nextSms.map { TimeInterval($0) }, nextCall: wireSession.nextCall.map { TimeInterval($0) }, nextVerificationAttempt: wireSession.nextVerificationAttempt.map { TimeInterval($0) }, allowedToRequestCode: wireSession.allowedToRequestCode, requestedInformation: requestedInformation, hasUnknownChallengeRequiringAppUpdate: false, verified: wireSession.verified ) } }