4191 lines
168 KiB
Swift
4191 lines
168 KiB
Swift
//
|
|
// Copyright 2023 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
import LibSignalClient
|
|
import Testing
|
|
|
|
@testable import Signal
|
|
@testable import SignalServiceKit
|
|
|
|
public class RegistrationCoordinatorTest {
|
|
private var scheduler: TestScheduler!
|
|
private var stubs = Stubs()
|
|
|
|
private var date: Date { self.stubs.date }
|
|
private var dateProvider: DateProvider!
|
|
|
|
private var appExpiryMock: MockAppExpiry!
|
|
private var changeNumberPniManager: ChangePhoneNumberPniManagerMock!
|
|
private var contactsStore: RegistrationCoordinatorImpl.TestMocks.ContactsStore!
|
|
private var experienceManager: RegistrationCoordinatorImpl.TestMocks.ExperienceManager!
|
|
private var featureFlags: RegistrationCoordinatorImpl.TestMocks.FeatureFlags!
|
|
private var localUsernameManagerMock: MockLocalUsernameManager!
|
|
private var mockMessagePipelineSupervisor: RegistrationCoordinatorImpl.TestMocks.MessagePipelineSupervisor!
|
|
private var mockMessageProcessor: RegistrationCoordinatorImpl.TestMocks.MessageProcessor!
|
|
private var mockURLSession: TSRequestOWSURLSessionMock!
|
|
private var ows2FAManagerMock: RegistrationCoordinatorImpl.TestMocks.OWS2FAManager!
|
|
private var phoneNumberDiscoverabilityManagerMock: MockPhoneNumberDiscoverabilityManager!
|
|
private var preKeyManagerMock: RegistrationCoordinatorImpl.TestMocks.PreKeyManager!
|
|
private var profileManagerMock: RegistrationCoordinatorImpl.TestMocks.ProfileManager!
|
|
private var pushRegistrationManagerMock: RegistrationCoordinatorImpl.TestMocks.PushRegistrationManager!
|
|
private var receiptManagerMock: RegistrationCoordinatorImpl.TestMocks.ReceiptManager!
|
|
private var registrationStateChangeManagerMock: MockRegistrationStateChangeManager!
|
|
private var sessionManager: RegistrationSessionManagerMock!
|
|
private var storageServiceManagerMock: FakeStorageServiceManager!
|
|
private var svr: SecureValueRecoveryMock!
|
|
private var svrKeyDeriver: SVRKeyDeriverMock!
|
|
private var svrAuthCredentialStore: SVRAuthCredentialStorageMock!
|
|
private var tsAccountManagerMock: MockTSAccountManager!
|
|
private var usernameApiClientMock: MockUsernameApiClient!
|
|
private var usernameLinkManagerMock: MockUsernameLinkManager!
|
|
private var coordinatorFactory: ((RegistrationMode) -> RegistrationCoordinatorImpl)!
|
|
|
|
init() {
|
|
dateProvider = { self.date }
|
|
let db = InMemoryDB()
|
|
|
|
appExpiryMock = MockAppExpiry()
|
|
changeNumberPniManager = ChangePhoneNumberPniManagerMock(
|
|
mockKyberStore: MockKyberPreKeyStore(dateProvider: Date.provider)
|
|
)
|
|
contactsStore = RegistrationCoordinatorImpl.TestMocks.ContactsStore()
|
|
experienceManager = RegistrationCoordinatorImpl.TestMocks.ExperienceManager()
|
|
featureFlags = RegistrationCoordinatorImpl.TestMocks.FeatureFlags()
|
|
localUsernameManagerMock = {
|
|
let mock = MockLocalUsernameManager()
|
|
// This should result in no username reclamation. Tests that want to
|
|
// test reclamation should overwrite this.
|
|
mock.startingUsernameState = .unset
|
|
return mock
|
|
}()
|
|
svr = SecureValueRecoveryMock()
|
|
svrKeyDeriver = SVRKeyDeriverMock()
|
|
svrAuthCredentialStore = SVRAuthCredentialStorageMock()
|
|
mockMessagePipelineSupervisor = RegistrationCoordinatorImpl.TestMocks.MessagePipelineSupervisor()
|
|
mockMessageProcessor = RegistrationCoordinatorImpl.TestMocks.MessageProcessor()
|
|
ows2FAManagerMock = RegistrationCoordinatorImpl.TestMocks.OWS2FAManager()
|
|
phoneNumberDiscoverabilityManagerMock = MockPhoneNumberDiscoverabilityManager()
|
|
preKeyManagerMock = RegistrationCoordinatorImpl.TestMocks.PreKeyManager()
|
|
profileManagerMock = RegistrationCoordinatorImpl.TestMocks.ProfileManager()
|
|
pushRegistrationManagerMock = RegistrationCoordinatorImpl.TestMocks.PushRegistrationManager()
|
|
receiptManagerMock = RegistrationCoordinatorImpl.TestMocks.ReceiptManager()
|
|
registrationStateChangeManagerMock = MockRegistrationStateChangeManager()
|
|
sessionManager = RegistrationSessionManagerMock()
|
|
storageServiceManagerMock = FakeStorageServiceManager()
|
|
tsAccountManagerMock = MockTSAccountManager()
|
|
usernameApiClientMock = MockUsernameApiClient()
|
|
usernameLinkManagerMock = MockUsernameLinkManager()
|
|
|
|
let mockURLSession = TSRequestOWSURLSessionMock()
|
|
self.mockURLSession = mockURLSession
|
|
let mockSignalService = OWSSignalServiceMock()
|
|
mockSignalService.mockUrlSessionBuilder = { _, _, _ in
|
|
return mockURLSession
|
|
}
|
|
|
|
scheduler = TestScheduler()
|
|
|
|
let dependencies = RegistrationCoordinatorDependencies(
|
|
appExpiry: appExpiryMock,
|
|
changeNumberPniManager: changeNumberPniManager,
|
|
contactsManager: RegistrationCoordinatorImpl.TestMocks.ContactsManager(),
|
|
contactsStore: contactsStore,
|
|
dateProvider: { self.dateProvider() },
|
|
db: db,
|
|
experienceManager: experienceManager,
|
|
featureFlags: featureFlags,
|
|
localUsernameManager: localUsernameManagerMock,
|
|
messageBackupKeyMaterial: MessageBackupKeyMaterialMock(),
|
|
messageBackupErrorPresenter: NoOpMessageBackupErrorPresenter(),
|
|
messageBackupManager: MessageBackupManagerMock(),
|
|
messagePipelineSupervisor: mockMessagePipelineSupervisor,
|
|
messageProcessor: mockMessageProcessor,
|
|
ows2FAManager: ows2FAManagerMock,
|
|
phoneNumberDiscoverabilityManager: phoneNumberDiscoverabilityManagerMock,
|
|
pniHelloWorldManager: PniHelloWorldManagerMock(),
|
|
preKeyManager: preKeyManagerMock,
|
|
profileManager: profileManagerMock,
|
|
pushRegistrationManager: pushRegistrationManagerMock,
|
|
receiptManager: receiptManagerMock,
|
|
registrationStateChangeManager: registrationStateChangeManagerMock,
|
|
schedulers: TestSchedulers(scheduler: scheduler),
|
|
sessionManager: sessionManager,
|
|
signalService: mockSignalService,
|
|
storageServiceRecordIkmCapabilityStore: StorageServiceRecordIkmCapabilityStoreImpl(),
|
|
storageServiceManager: storageServiceManagerMock,
|
|
svr: svr,
|
|
svrKeyDeriver: svrKeyDeriver,
|
|
svrAuthCredentialStore: svrAuthCredentialStore,
|
|
tsAccountManager: tsAccountManagerMock,
|
|
udManager: RegistrationCoordinatorImpl.TestMocks.UDManager(),
|
|
usernameApiClient: usernameApiClientMock,
|
|
usernameLinkManager: usernameLinkManagerMock
|
|
)
|
|
let loader = RegistrationCoordinatorLoaderImpl(dependencies: dependencies)
|
|
coordinatorFactory = { mode in
|
|
db.write {
|
|
return loader.coordinator(
|
|
forDesiredMode: mode,
|
|
transaction: $0
|
|
) as! RegistrationCoordinatorImpl
|
|
}
|
|
}
|
|
}
|
|
|
|
static let testCases = [
|
|
RegistrationMode.registering,
|
|
RegistrationMode.reRegistering(.init(e164: Stubs.e164, aci: Stubs.aci)),
|
|
]
|
|
|
|
// MARK: - Opening Path
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testOpeningPath_splash(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
|
|
// Don't care about timing, just start it.
|
|
scheduler.start()
|
|
|
|
setupDefaultAccountAttributes()
|
|
|
|
switch mode {
|
|
case .registering:
|
|
// With no state set up, should show the splash.
|
|
#expect(coordinator.nextStep().value == .registrationSplash)
|
|
// Once we show it, don't show it again.
|
|
#expect(coordinator.continueFromSplash().value != .registrationSplash)
|
|
case .reRegistering, .changingNumber:
|
|
#expect(coordinator.nextStep().value != .registrationSplash)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testOpeningPath_appExpired(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
|
|
// Don't care about timing, just start it.
|
|
scheduler.start()
|
|
|
|
appExpiryMock.expirationDate = .distantPast
|
|
|
|
setupDefaultAccountAttributes()
|
|
|
|
// We should start with the banner.
|
|
#expect(coordinator.nextStep().value == .appUpdateBanner)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testOpeningPath_permissions(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
|
|
// Don't care about timing, just start it.
|
|
scheduler.start()
|
|
|
|
setupDefaultAccountAttributes()
|
|
|
|
contactsStore.doesNeedContactsAuthorization = true
|
|
pushRegistrationManagerMock.doesNeedNotificationAuthorization = true
|
|
|
|
var nextStep: Guarantee<RegistrationStep>
|
|
switch mode {
|
|
case .registering:
|
|
// Gotta get the splash out of the way.
|
|
#expect(coordinator.nextStep().value == .registrationSplash)
|
|
nextStep = coordinator.continueFromSplash()
|
|
case .reRegistering, .changingNumber:
|
|
// No splash for these.
|
|
nextStep = coordinator.nextStep()
|
|
}
|
|
|
|
// Now we should show the permissions.
|
|
#expect(nextStep.value == .permissions)
|
|
// Doesn't change even if we try and proceed.
|
|
#expect(coordinator.nextStep().value == .permissions)
|
|
|
|
// Once the state is updated we can proceed.
|
|
nextStep = coordinator.requestPermissions()
|
|
#expect(nextStep.value != nil)
|
|
#expect(nextStep.value != .registrationSplash)
|
|
#expect(nextStep.value != .permissions)
|
|
}
|
|
|
|
// MARK: - Reg Recovery Password Path
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases, [true, false])
|
|
func runRegRecoverPwPathTestHappyPath(mode: RegistrationMode, wasReglockEnabled: Bool) throws {
|
|
let coordinator = coordinatorFactory(mode)
|
|
|
|
// Don't care about timing, just start it.
|
|
scheduler.start()
|
|
|
|
// Set profile info so we skip those steps.
|
|
setupDefaultAccountAttributes()
|
|
|
|
ows2FAManagerMock.isReglockEnabledMock = { wasReglockEnabled }
|
|
|
|
// Set a PIN on disk.
|
|
ows2FAManagerMock.pinCodeMock = { Stubs.pinCode }
|
|
|
|
// Make SVR give us back a reg recovery password.
|
|
svrKeyDeriver.dataGenerator = {
|
|
switch $0 {
|
|
case .registrationRecoveryPassword:
|
|
return Stubs.regRecoveryPwData
|
|
case .registrationLock:
|
|
return Stubs.reglockData
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// NOTE: We expect to skip opening path steps because
|
|
// if we have a SVR master key locally, this _must_ be
|
|
// a previously registered device, and we can skip intros.
|
|
|
|
// We haven't set a phone number so it should ask for that.
|
|
#expect(coordinator.nextStep().value == .phoneNumberEntry(self.stubs.phoneNumberEntryState(mode: mode)))
|
|
|
|
// Give it a phone number, which should show the PIN entry step.
|
|
var nextStep = coordinator.submitE164(Stubs.e164).value
|
|
// Now it should ask for the PIN to confirm the user knows it.
|
|
#expect(nextStep == .pinEntry(Stubs.pinEntryStateForRegRecoveryPath(mode: mode)))
|
|
|
|
// Give it the pin code, which should make it try and register.
|
|
|
|
// It needs an apns token to register.
|
|
pushRegistrationManagerMock.requestPushTokenMock = {
|
|
return .value(.success(Stubs.apnsRegistrationId))
|
|
}
|
|
// It needs prekeys as well.
|
|
preKeyManagerMock.createPreKeysMock = {
|
|
return .value(Stubs.prekeyBundles())
|
|
}
|
|
// And will finalize prekeys after success.
|
|
preKeyManagerMock.finalizePreKeysMock = { didSucceed in
|
|
#expect(didSucceed)
|
|
return .value(())
|
|
}
|
|
|
|
let expectedRequest = RegistrationRequestFactory.createAccountRequest(
|
|
verificationMethod: .recoveryPassword(Stubs.regRecoveryPw),
|
|
e164: Stubs.e164,
|
|
authPassword: "", // Doesn't matter for request generation.
|
|
accountAttributes: Stubs.accountAttributes(),
|
|
skipDeviceTransfer: true,
|
|
apnRegistrationId: Stubs.apnsRegistrationId,
|
|
prekeyBundles: Stubs.prekeyBundles()
|
|
)
|
|
let identityResponse = Stubs.accountIdentityResponse()
|
|
var authPassword: String!
|
|
mockURLSession.addResponse(TSRequestOWSURLSessionMock.Response(
|
|
matcher: { request in
|
|
// The password is generated internally by RegistrationCoordinator.
|
|
// Extract it so we can check that the same password sent to the server
|
|
// to register is used later for other requests.
|
|
authPassword = request.authPassword
|
|
let requestAttributes = Self.attributesFromCreateAccountRequest(request)
|
|
if wasReglockEnabled {
|
|
#expect(Stubs.reglockData.hexadecimalString == requestAttributes.registrationLockToken)
|
|
} else {
|
|
#expect(requestAttributes.registrationLockToken == nil)
|
|
}
|
|
return request.url == expectedRequest.url
|
|
},
|
|
statusCode: 200,
|
|
bodyData: try JSONEncoder().encode(identityResponse)
|
|
))
|
|
|
|
func expectedAuthedAccount() -> AuthedAccount {
|
|
return .explicit(
|
|
aci: identityResponse.aci,
|
|
pni: identityResponse.pni,
|
|
e164: Stubs.e164,
|
|
deviceId: .primary,
|
|
authPassword: authPassword
|
|
)
|
|
}
|
|
|
|
// When registered, we should create pre-keys.
|
|
preKeyManagerMock.rotateOneTimePreKeysMock = { auth in
|
|
#expect(auth == expectedAuthedAccount().chatServiceAuth)
|
|
return .value(())
|
|
}
|
|
|
|
if wasReglockEnabled {
|
|
// If we had reglock before registration, it should be re-enabled.
|
|
let expectedReglockRequest = OWSRequestFactory.enableRegistrationLockV2Request(token: Stubs.reglockToken)
|
|
mockURLSession.addResponse(TSRequestOWSURLSessionMock.Response(
|
|
matcher: { request in
|
|
return request.url == expectedReglockRequest.url
|
|
},
|
|
statusCode: 200,
|
|
bodyData: nil
|
|
))
|
|
}
|
|
|
|
// We haven't done a SVR backup; that should happen now.
|
|
svr.generateAndBackupKeysMock = { pin, authMethod in
|
|
#expect(pin == Stubs.pinCode)
|
|
// We don't have a SVR auth credential, it should use chat server creds.
|
|
#expect(authMethod == .chatServerAuth(expectedAuthedAccount()))
|
|
self.svr.hasMasterKey = true
|
|
return .value(())
|
|
}
|
|
|
|
// Once we sync push tokens, we should restore from storage service.
|
|
storageServiceManagerMock.restoreOrCreateManifestIfNecessaryMock = { auth in
|
|
#expect(auth.authedAccount == expectedAuthedAccount())
|
|
return .value(())
|
|
}
|
|
|
|
// Once we restore from storage service, we should attempt to reclaim
|
|
// our username.
|
|
let mockUsernameLink: Usernames.UsernameLink = .mocked
|
|
localUsernameManagerMock.startingUsernameState = .available(username: "boba.42", usernameLink: mockUsernameLink)
|
|
usernameApiClientMock.confirmReservedUsernameMock = { _, _, chatServiceAuth in
|
|
#expect(chatServiceAuth == .explicit(
|
|
aci: identityResponse.aci,
|
|
deviceId: .primary,
|
|
password: authPassword
|
|
))
|
|
return .value(.success(usernameLinkHandle: mockUsernameLink.handle))
|
|
}
|
|
|
|
// Once we do the username reclamation,
|
|
// we will sync account attributes and then we are finished!
|
|
let expectedAttributesRequest = RegistrationRequestFactory.updatePrimaryDeviceAccountAttributesRequest(
|
|
Stubs.accountAttributes(),
|
|
auth: .implicit() // doesn't matter for url matching
|
|
)
|
|
self.mockURLSession.addResponse(
|
|
matcher: { request in
|
|
return request.url == expectedAttributesRequest.url
|
|
},
|
|
statusCode: 200
|
|
)
|
|
|
|
nextStep = coordinator.submitPINCode(Stubs.pinCode).value
|
|
#expect(nextStep == .done)
|
|
|
|
// Since we set profile info, we should have scheduled a reupload.
|
|
#expect(profileManagerMock.didScheduleReuploadLocalProfile)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testRegRecoveryPwPath_wrongPIN(mode: RegistrationMode) throws {
|
|
let coordinator = coordinatorFactory(mode)
|
|
// Don't care about timing, just start it.
|
|
scheduler.start()
|
|
|
|
// Set profile info so we skip those steps.
|
|
setupDefaultAccountAttributes()
|
|
|
|
let wrongPinCode = "ABCD"
|
|
|
|
// Set a different PIN on disk.
|
|
ows2FAManagerMock.pinCodeMock = { Stubs.pinCode }
|
|
|
|
// Make SVR give us back a reg recovery password.
|
|
svrKeyDeriver.dataGenerator = {
|
|
switch $0 {
|
|
case .registrationRecoveryPassword:
|
|
return Stubs.regRecoveryPwData
|
|
case .registrationLock:
|
|
return Stubs.reglockData
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// NOTE: We expect to skip opening path steps because
|
|
// if we have a SVR master key locally, this _must_ be
|
|
// a previously registered device, and we can skip intros.
|
|
|
|
// We haven't set a phone number so it should ask for that.
|
|
#expect(coordinator.nextStep().value == .phoneNumberEntry(self.stubs.phoneNumberEntryState(mode: mode)))
|
|
|
|
// Give it a phone number, which should show the PIN entry step.
|
|
var nextStep = coordinator.submitE164(Stubs.e164).value
|
|
// Now it should ask for the PIN to confirm the user knows it.
|
|
#expect(nextStep == .pinEntry(Stubs.pinEntryStateForRegRecoveryPath(mode: mode)))
|
|
|
|
// Give it the wrong PIN, it should reject and give us the same step again.
|
|
nextStep = coordinator.submitPINCode(wrongPinCode).value
|
|
#expect(
|
|
nextStep == .pinEntry(Stubs.pinEntryStateForRegRecoveryPath(
|
|
mode: mode,
|
|
error: .wrongPin(wrongPin: wrongPinCode),
|
|
remainingAttempts: 9
|
|
))
|
|
)
|
|
|
|
// Give it the right pin code, which should make it try and register.
|
|
|
|
// It needs an apns token to register.
|
|
pushRegistrationManagerMock.requestPushTokenMock = {
|
|
return .value(.success(Stubs.apnsRegistrationId))
|
|
}
|
|
// Every time we register we also ask for prekeys.
|
|
preKeyManagerMock.createPreKeysMock = {
|
|
return .value(Stubs.prekeyBundles())
|
|
}
|
|
// And we finalize them after.
|
|
preKeyManagerMock.finalizePreKeysMock = { didSucceed in
|
|
#expect(didSucceed)
|
|
return .value(())
|
|
}
|
|
|
|
let expectedRequest = RegistrationRequestFactory.createAccountRequest(
|
|
verificationMethod: .recoveryPassword(Stubs.regRecoveryPw),
|
|
e164: Stubs.e164,
|
|
authPassword: "", // Doesn't matter for request generation.
|
|
accountAttributes: Stubs.accountAttributes(),
|
|
skipDeviceTransfer: true,
|
|
apnRegistrationId: Stubs.apnsRegistrationId,
|
|
prekeyBundles: Stubs.prekeyBundles()
|
|
)
|
|
|
|
let identityResponse = Stubs.accountIdentityResponse()
|
|
var authPassword: String!
|
|
mockURLSession.addResponse(TSRequestOWSURLSessionMock.Response(
|
|
matcher: { request in
|
|
authPassword = request.authPassword
|
|
return request.url == expectedRequest.url
|
|
},
|
|
statusCode: 200,
|
|
bodyData: try JSONEncoder().encode(identityResponse)
|
|
))
|
|
|
|
func expectedAuthedAccount() -> AuthedAccount {
|
|
return .explicit(
|
|
aci: identityResponse.aci,
|
|
pni: identityResponse.pni,
|
|
e164: Stubs.e164,
|
|
deviceId: .primary,
|
|
authPassword: authPassword
|
|
)
|
|
}
|
|
|
|
// When registered, we should create pre-keys.
|
|
preKeyManagerMock.rotateOneTimePreKeysMock = { auth in
|
|
#expect(auth == expectedAuthedAccount().chatServiceAuth)
|
|
return .value(())
|
|
}
|
|
|
|
// We haven't done a SVR backup; that should happen now.
|
|
svr.generateAndBackupKeysMock = { pin, authMethod in
|
|
#expect(pin == Stubs.pinCode)
|
|
// We don't have a SVR auth credential, it should use chat server creds.
|
|
#expect(authMethod == .chatServerAuth(expectedAuthedAccount()))
|
|
self.svr.hasMasterKey = true
|
|
return .value(())
|
|
}
|
|
|
|
// Once we sync push tokens, we should restore from storage service.
|
|
storageServiceManagerMock.restoreOrCreateManifestIfNecessaryMock = { auth in
|
|
#expect(auth.authedAccount == expectedAuthedAccount())
|
|
return .value(())
|
|
}
|
|
|
|
// Once we restore from storage service, we should attempt to reclaim
|
|
// our username. For this test, let's have a corrupted username (and
|
|
// skip reclamation). This should have no impact on the rest of
|
|
// registration.
|
|
localUsernameManagerMock.startingUsernameState = .usernameAndLinkCorrupted
|
|
|
|
// Once we do the storage service restore,
|
|
// we will sync account attributes and then we are finished!
|
|
let expectedAttributesRequest = RegistrationRequestFactory.updatePrimaryDeviceAccountAttributesRequest(
|
|
Stubs.accountAttributes(),
|
|
auth: .implicit() // // doesn't matter for url matching
|
|
)
|
|
self.mockURLSession.addResponse(
|
|
matcher: { request in
|
|
return request.url == expectedAttributesRequest.url
|
|
},
|
|
statusCode: 200
|
|
)
|
|
|
|
nextStep = coordinator.submitPINCode(Stubs.pinCode).value
|
|
#expect(nextStep == .done)
|
|
|
|
// Since we set profile info, we should have scheduled a reupload.
|
|
#expect(profileManagerMock.didScheduleReuploadLocalProfile)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testRegRecoveryPwPath_wrongPassword(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
|
|
// Set profile info so we skip those steps.
|
|
setupDefaultAccountAttributes()
|
|
|
|
// Set a PIN on disk.
|
|
ows2FAManagerMock.pinCodeMock = { Stubs.pinCode }
|
|
|
|
// Make SVR give us back a reg recovery password.
|
|
svrKeyDeriver.dataGenerator = {
|
|
switch $0 {
|
|
case .registrationRecoveryPassword:
|
|
|
|
return Stubs.regRecoveryPwData
|
|
case .registrationLock:
|
|
return Stubs.reglockData
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
svr.hasMasterKey = true
|
|
|
|
// Run the scheduler for a bit; we don't care about timing these bits.
|
|
scheduler.start()
|
|
|
|
// NOTE: We expect to skip opening path steps because
|
|
// if we have a SVR master key locally, this _must_ be
|
|
// a previously registered device, and we can skip intros.
|
|
|
|
// We haven't set a phone number so it should ask for that.
|
|
#expect(coordinator.nextStep().value == .phoneNumberEntry(self.stubs.phoneNumberEntryState(mode: mode)))
|
|
|
|
// Give it a phone number, which should show the PIN entry step.
|
|
var nextStep = coordinator.submitE164(Stubs.e164)
|
|
// Now it should ask for the PIN to confirm the user knows it.
|
|
#expect(nextStep.value == .pinEntry(Stubs.pinEntryStateForRegRecoveryPath(mode: mode)))
|
|
|
|
// Now we want to control timing so we can verify things happened in the right order.
|
|
scheduler.stop()
|
|
scheduler.adjustTime(to: 0)
|
|
|
|
// Give it the pin code, which should make it try and register.
|
|
nextStep = coordinator.submitPINCode(Stubs.pinCode)
|
|
|
|
// Before registering at t=0, it should ask for push tokens to give the registration.
|
|
// It will also ask again later at t=3 when account creation fails and it needs
|
|
// to create a new session.
|
|
pushRegistrationManagerMock.requestPushTokenMock = {
|
|
switch self.scheduler.currentTime {
|
|
case 0:
|
|
return self.scheduler.guarantee(resolvingWith: .success(Stubs.apnsRegistrationId), atTime: 1)
|
|
case 3:
|
|
return .value(.success(Stubs.apnsRegistrationId))
|
|
default:
|
|
Issue.record("Got unexpected push tokens request")
|
|
return .value(.timeout)
|
|
}
|
|
}
|
|
// Every time we register we also ask for prekeys.
|
|
preKeyManagerMock.createPreKeysMock = {
|
|
switch self.scheduler.currentTime {
|
|
case 1, 3:
|
|
return .value(Stubs.prekeyBundles())
|
|
default:
|
|
Issue.record("Got unexpected push tokens request")
|
|
return .init(error: PreKeyError())
|
|
}
|
|
}
|
|
// And we finalize them after.
|
|
preKeyManagerMock.finalizePreKeysMock = { didSucceed in
|
|
switch self.scheduler.currentTime {
|
|
case 3:
|
|
#expect(didSucceed.negated)
|
|
return .value(())
|
|
case 4:
|
|
#expect(didSucceed)
|
|
return .value(())
|
|
default:
|
|
Issue.record("Got unexpected push tokens request")
|
|
return .init(error: PreKeyError())
|
|
}
|
|
}
|
|
|
|
let expectedRecoveryPwRequest = RegistrationRequestFactory.createAccountRequest(
|
|
verificationMethod: .recoveryPassword(Stubs.regRecoveryPw),
|
|
e164: Stubs.e164,
|
|
authPassword: "", // Doesn't matter for request generation.
|
|
accountAttributes: Stubs.accountAttributes(),
|
|
skipDeviceTransfer: true,
|
|
apnRegistrationId: Stubs.apnsRegistrationId,
|
|
prekeyBundles: Stubs.prekeyBundles()
|
|
)
|
|
|
|
// Fail the request at t=3; the reg recovery pw is invalid.
|
|
let failResponse = TSRequestOWSURLSessionMock.Response(
|
|
urlSuffix: expectedRecoveryPwRequest.url!.absoluteString,
|
|
statusCode: RegistrationServiceResponses.AccountCreationResponseCodes.unauthorized.rawValue
|
|
)
|
|
mockURLSession.addResponse(failResponse, atTime: 3, on: scheduler)
|
|
|
|
// Once the first request fails, at t=3, it should try an start a session.
|
|
scheduler.run(atTime: 2) {
|
|
// Resolve with a session at time 4.
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(self.stubs.session(hasSentVerificationCode: false)),
|
|
atTime: 4
|
|
)
|
|
}
|
|
|
|
// Before requesting a session at t=3, it should ask for push tokens to give the session.
|
|
// This was set up above.
|
|
|
|
// Then when it gets back the session at t=4, it should immediately ask for
|
|
// a verification code to be sent.
|
|
scheduler.run(atTime: 4) {
|
|
// We'll ask for a push challenge, though we don't need to resolve it in this test.
|
|
self.pushRegistrationManagerMock.receivePreAuthChallengeTokenMock = {
|
|
return Guarantee<String>.pending().0
|
|
}
|
|
|
|
// Resolve with an updated session at time 5.
|
|
self.sessionManager.requestCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(self.stubs.session(hasSentVerificationCode: true)),
|
|
atTime: 5
|
|
)
|
|
}
|
|
|
|
// Check we have the master key now, to be safe.
|
|
#expect(svr.hasMasterKey)
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 5)
|
|
|
|
// Now we should expect to be at verification code entry since we already set the phone number.
|
|
// No exit allowed since we've already started trying to create the account.
|
|
#expect(nextStep.value == .verificationCodeEntry(
|
|
self.stubs.verificationCodeEntryState(mode: mode, exitConfigOverride: .noExitAllowed)
|
|
))
|
|
// We want to have kept the master key; we failed the reg recovery pw check
|
|
// but that could happen even if the key is valid. Once we finish session based
|
|
// re-registration we want to be able to recover the key.
|
|
#expect(svr.hasMasterKey)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testRegRecoveryPwPath_failedReglock(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
|
|
// Set profile info so we skip those steps.
|
|
setupDefaultAccountAttributes()
|
|
|
|
// Set a PIN on disk.
|
|
ows2FAManagerMock.pinCodeMock = { Stubs.pinCode }
|
|
|
|
// Make SVR give us back a reg recovery password.
|
|
svrKeyDeriver.dataGenerator = {
|
|
switch $0 {
|
|
case .registrationRecoveryPassword:
|
|
return Stubs.regRecoveryPwData
|
|
case .registrationLock:
|
|
return Stubs.reglockData
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
svr.hasMasterKey = true
|
|
|
|
// Run the scheduler for a bit; we don't care about timing these bits.
|
|
scheduler.start()
|
|
|
|
// NOTE: We expect to skip opening path steps because
|
|
// if we have a SVR master key locally, this _must_ be
|
|
// a previously registered device, and we can skip intros.
|
|
|
|
// We haven't set a phone number so it should ask for that.
|
|
#expect(coordinator.nextStep().value == .phoneNumberEntry(self.stubs.phoneNumberEntryState(mode: mode)))
|
|
|
|
// Give it a phone number, which should show the PIN entry step.
|
|
var nextStep = coordinator.submitE164(Stubs.e164)
|
|
// Now it should ask for the PIN to confirm the user knows it.
|
|
#expect(nextStep.value == .pinEntry(Stubs.pinEntryStateForRegRecoveryPath(mode: mode)))
|
|
|
|
// Now we want to control timing so we can verify things happened in the right order.
|
|
scheduler.stop()
|
|
scheduler.adjustTime(to: 0)
|
|
|
|
// Give it the pin code, which should make it try and register.
|
|
nextStep = coordinator.submitPINCode(Stubs.pinCode)
|
|
|
|
// First we try and create an account with reg recovery
|
|
// password; we will fail with reglock error.
|
|
// First we get apns tokens, then prekeys, then register
|
|
// then finalize prekeys (with failure) after.
|
|
let firstPushTokenTime = 0
|
|
let firstPreKeyCreateTime = 1
|
|
let firstRegistrationTime = 2
|
|
let firstPreKeyFinalizeTime = 3
|
|
|
|
// Once we fail, we try again immediately with the reglock
|
|
// token we fetch.
|
|
// Same sequence as the first request.
|
|
let secondPushTokenTime = 4
|
|
let secondPreKeyCreateTime = 5
|
|
let secondRegistrationTime = 6
|
|
let secondPreKeyFinalizeTime = 7
|
|
|
|
// When that fails, we try and create a session.
|
|
// No prekey stuff this time, just apns token and session requests.
|
|
let thirdPushTokenTime = 8
|
|
let sessionStartTime = 9
|
|
let sendVerificationCodeTime = 10
|
|
|
|
pushRegistrationManagerMock.requestPushTokenMock = {
|
|
switch self.scheduler.currentTime {
|
|
case firstPushTokenTime, secondPushTokenTime, thirdPushTokenTime:
|
|
return self.scheduler.guarantee(resolvingWith: .success(Stubs.apnsRegistrationId), atTime: self.scheduler.currentTime + 1)
|
|
default:
|
|
Issue.record("Got unexpected push tokens request")
|
|
return .value(.timeout)
|
|
}
|
|
}
|
|
preKeyManagerMock.createPreKeysMock = {
|
|
switch self.scheduler.currentTime {
|
|
case firstPreKeyCreateTime, secondPreKeyCreateTime:
|
|
return self.scheduler.promise(resolvingWith: Stubs.prekeyBundles(), atTime: self.scheduler.currentTime + 1)
|
|
default:
|
|
Issue.record("Got unexpected prekeys request")
|
|
return .init(error: PreKeyError())
|
|
}
|
|
}
|
|
preKeyManagerMock.finalizePreKeysMock = { didSucceed in
|
|
switch self.scheduler.currentTime {
|
|
case firstPreKeyFinalizeTime, secondPreKeyFinalizeTime:
|
|
#expect(didSucceed.negated)
|
|
return self.scheduler.promise(resolvingWith: (), atTime: self.scheduler.currentTime + 1)
|
|
default:
|
|
Issue.record("Got unexpected prekeys request")
|
|
return .init(error: PreKeyError())
|
|
}
|
|
}
|
|
|
|
let expectedRecoveryPwRequest = RegistrationRequestFactory.createAccountRequest(
|
|
verificationMethod: .recoveryPassword(Stubs.regRecoveryPw),
|
|
e164: Stubs.e164,
|
|
authPassword: "", // Doesn't matter for request generation.
|
|
accountAttributes: Stubs.accountAttributes(),
|
|
skipDeviceTransfer: true,
|
|
apnRegistrationId: Stubs.apnsRegistrationId,
|
|
prekeyBundles: Stubs.prekeyBundles()
|
|
)
|
|
|
|
// Fail the first request; the reglock is invalid.
|
|
let failResponse = TSRequestOWSURLSessionMock.Response(
|
|
urlSuffix: expectedRecoveryPwRequest.url!.absoluteString,
|
|
statusCode: RegistrationServiceResponses.AccountCreationResponseCodes.reglockFailed.rawValue,
|
|
bodyJson: EncodableRegistrationLockFailureResponse(
|
|
timeRemainingMs: 10,
|
|
svr2AuthCredential: Stubs.svr2AuthCredential
|
|
)
|
|
)
|
|
mockURLSession.addResponse(failResponse, atTime: firstRegistrationTime + 1, on: scheduler)
|
|
|
|
// Once the request fails, we should try again with the reglock
|
|
// token, this time.
|
|
mockURLSession.addResponse(failResponse, atTime: secondRegistrationTime + 1, on: scheduler)
|
|
|
|
// Once the second request fails, it should try an start a session.
|
|
scheduler.run(atTime: sessionStartTime - 1) {
|
|
// We'll ask for a push challenge, though we don't need to resolve it in this test.
|
|
self.pushRegistrationManagerMock.receivePreAuthChallengeTokenMock = {
|
|
return Guarantee<String>.pending().0
|
|
}
|
|
|
|
// Resolve with a session.
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(self.stubs.session(hasSentVerificationCode: false)),
|
|
atTime: sessionStartTime + 1
|
|
)
|
|
}
|
|
|
|
// Then when it gets back the session, it should immediately ask for
|
|
// a verification code to be sent.
|
|
scheduler.run(atTime: sendVerificationCodeTime - 1) {
|
|
// Resolve with an updated session.
|
|
self.sessionManager.requestCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(self.stubs.session(hasSentVerificationCode: true)),
|
|
atTime: sendVerificationCodeTime + 1
|
|
)
|
|
}
|
|
|
|
#expect(svr.hasMasterKey)
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == sendVerificationCodeTime + 1)
|
|
|
|
// Now we should expect to be at verification code entry since we already set the phone number.
|
|
// No exit allowed since we've already started trying to create the account.
|
|
#expect(nextStep.value == .verificationCodeEntry(
|
|
self.stubs.verificationCodeEntryState(mode: mode, exitConfigOverride: .noExitAllowed)
|
|
))
|
|
// We want to have wiped our master key; we failed reglock, which means the key itself is
|
|
// wrong.
|
|
#expect(svr.hasMasterKey.negated)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testRegRecoveryPwPath_retryNetworkError(mode: RegistrationMode) throws {
|
|
let coordinator = coordinatorFactory(mode)
|
|
// Set profile info so we skip those steps.
|
|
setupDefaultAccountAttributes()
|
|
|
|
// Set a PIN on disk.
|
|
ows2FAManagerMock.pinCodeMock = { Stubs.pinCode }
|
|
|
|
// Make SVR give us back a reg recovery password.
|
|
svrKeyDeriver.dataGenerator = {
|
|
switch $0 {
|
|
case .registrationRecoveryPassword:
|
|
return Stubs.regRecoveryPwData
|
|
case .registrationLock:
|
|
return Stubs.reglockData
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
svr.hasMasterKey = true
|
|
|
|
// Run the scheduler for a bit; we don't care about timing these bits.
|
|
scheduler.start()
|
|
|
|
// NOTE: We expect to skip opening path steps because
|
|
// if we have a SVR master key locally, this _must_ be
|
|
// a previously registered device, and we can skip intros.
|
|
|
|
// We haven't set a phone number so it should ask for that.
|
|
#expect(coordinator.nextStep().value == .phoneNumberEntry(self.stubs.phoneNumberEntryState(mode: mode)))
|
|
|
|
// Give it a phone number, which should show the PIN entry step.
|
|
var nextStep = coordinator.submitE164(Stubs.e164)
|
|
// Now it should ask for the PIN to confirm the user knows it.
|
|
#expect(nextStep.value == .pinEntry(Stubs.pinEntryStateForRegRecoveryPath(mode: mode)))
|
|
|
|
// Now we want to control timing so we can verify things happened in the right order.
|
|
scheduler.stop()
|
|
scheduler.adjustTime(to: 0)
|
|
|
|
// Give it the pin code, which should make it try and register.
|
|
nextStep = coordinator.submitPINCode(Stubs.pinCode)
|
|
|
|
// Before registering at t=0, it should ask for push tokens to give the registration.
|
|
// When it retries at t=3, it will ask again.
|
|
pushRegistrationManagerMock.requestPushTokenMock = {
|
|
switch self.scheduler.currentTime {
|
|
case 0:
|
|
return self.scheduler.guarantee(resolvingWith: .success(Stubs.apnsRegistrationId), atTime: 1)
|
|
case 3:
|
|
return self.scheduler.guarantee(resolvingWith: .success(Stubs.apnsRegistrationId), atTime: 4)
|
|
default:
|
|
Issue.record("Got unexpected push tokens request")
|
|
return .value(.timeout)
|
|
}
|
|
}
|
|
// Every time we register we also ask for prekeys.
|
|
preKeyManagerMock.createPreKeysMock = {
|
|
switch self.scheduler.currentTime {
|
|
case 1, 4:
|
|
return .value(Stubs.prekeyBundles())
|
|
default:
|
|
Issue.record("Got unexpected push tokens request")
|
|
return .init(error: PreKeyError())
|
|
}
|
|
}
|
|
// And we finalize them after.
|
|
preKeyManagerMock.finalizePreKeysMock = { didSucceed in
|
|
switch self.scheduler.currentTime {
|
|
case 3:
|
|
#expect(didSucceed.negated)
|
|
return .value(())
|
|
case 5:
|
|
#expect(didSucceed)
|
|
return .value(())
|
|
default:
|
|
Issue.record("Got unexpected push tokens request")
|
|
return .init(error: PreKeyError())
|
|
}
|
|
}
|
|
|
|
let expectedRecoveryPwRequest = RegistrationRequestFactory.createAccountRequest(
|
|
verificationMethod: .recoveryPassword(Stubs.regRecoveryPw),
|
|
e164: Stubs.e164,
|
|
authPassword: "", // Doesn't matter for request generation.
|
|
accountAttributes: Stubs.accountAttributes(),
|
|
skipDeviceTransfer: true,
|
|
apnRegistrationId: Stubs.apnsRegistrationId,
|
|
prekeyBundles: Stubs.prekeyBundles()
|
|
)
|
|
|
|
// Fail the request at t=3 with a network error.
|
|
let failResponse = TSRequestOWSURLSessionMock.Response.networkError(url: expectedRecoveryPwRequest.url!)
|
|
mockURLSession.addResponse(failResponse, atTime: 3, on: scheduler)
|
|
|
|
let identityResponse = Stubs.accountIdentityResponse()
|
|
var authPassword: String!
|
|
|
|
// Once the first request fails, at t=3, it should retry.
|
|
scheduler.run(atTime: 2) {
|
|
// Resolve with success at t=5
|
|
let expectedRequest = RegistrationRequestFactory.createAccountRequest(
|
|
verificationMethod: .recoveryPassword(Stubs.regRecoveryPw),
|
|
e164: Stubs.e164,
|
|
authPassword: "", // Doesn't matter for request generation.
|
|
accountAttributes: Stubs.accountAttributes(),
|
|
skipDeviceTransfer: true,
|
|
apnRegistrationId: Stubs.apnsRegistrationId,
|
|
prekeyBundles: Stubs.prekeyBundles()
|
|
)
|
|
|
|
self.mockURLSession.addResponse(
|
|
TSRequestOWSURLSessionMock.Response(
|
|
matcher: { request in
|
|
// The password is generated internally by RegistrationCoordinator.
|
|
// Extract it so we can check that the same password sent to the server
|
|
// to register is used later for other requests.
|
|
authPassword = request.authPassword
|
|
return request.url == expectedRequest.url
|
|
},
|
|
statusCode: 200,
|
|
bodyData: try! JSONEncoder().encode(identityResponse)
|
|
),
|
|
atTime: 5,
|
|
on: self.scheduler
|
|
)
|
|
}
|
|
|
|
func expectedAuthedAccount() -> AuthedAccount {
|
|
return .explicit(
|
|
aci: identityResponse.aci,
|
|
pni: identityResponse.pni,
|
|
e164: Stubs.e164,
|
|
deviceId: .primary,
|
|
authPassword: authPassword
|
|
)
|
|
}
|
|
|
|
// When registered at t=5, it should try and sync pre-keys. Succeed at t=6.
|
|
preKeyManagerMock.rotateOneTimePreKeysMock = { auth in
|
|
#expect(self.scheduler.currentTime == 5)
|
|
#expect(auth == expectedAuthedAccount().chatServiceAuth)
|
|
return self.scheduler.promise(resolvingWith: (), atTime: 6)
|
|
}
|
|
|
|
// We haven't done a SVR backup; that should happen at t=6. Succeed at t=7.
|
|
svr.generateAndBackupKeysMock = { pin, authMethod in
|
|
#expect(self.scheduler.currentTime == 6)
|
|
#expect(pin == Stubs.pinCode)
|
|
// We don't have a SVR auth credential, it should use chat server creds.
|
|
#expect(authMethod == .chatServerAuth(expectedAuthedAccount()))
|
|
self.svr.hasMasterKey = true
|
|
return self.scheduler.promise(resolvingWith: (), atTime: 7)
|
|
}
|
|
|
|
// Once we back up to svr at t=7, we should restore from storage service.
|
|
// Succeed at t=8.
|
|
storageServiceManagerMock.restoreOrCreateManifestIfNecessaryMock = { auth in
|
|
#expect(self.scheduler.currentTime == 7)
|
|
#expect(auth.authedAccount == expectedAuthedAccount())
|
|
return self.scheduler.promise(resolvingWith: (), atTime: 8)
|
|
}
|
|
|
|
// Once we restore from storage service at t=8, we should attempt to
|
|
// reclaim our username. Succeed at t=9.
|
|
let mockUsernameLink: Usernames.UsernameLink = .mocked
|
|
localUsernameManagerMock.startingUsernameState = .available(username: "boba.42", usernameLink: mockUsernameLink)
|
|
usernameApiClientMock.confirmReservedUsernameMock = { _, _, chatServiceAuth in
|
|
#expect(self.scheduler.currentTime == 8)
|
|
#expect(chatServiceAuth == .explicit(
|
|
aci: identityResponse.aci,
|
|
deviceId: .primary,
|
|
password: authPassword
|
|
))
|
|
return self.scheduler.promise(
|
|
resolvingWith: .success(usernameLinkHandle: mockUsernameLink.handle),
|
|
atTime: 9
|
|
)
|
|
}
|
|
|
|
// Once we do the storage service restore at t=9,
|
|
// we will sync account attributes and then we are finished!
|
|
let expectedAttributesRequest = RegistrationRequestFactory.updatePrimaryDeviceAccountAttributesRequest(
|
|
Stubs.accountAttributes(),
|
|
auth: .implicit() // // doesn't matter for url matching
|
|
)
|
|
self.mockURLSession.addResponse(
|
|
TSRequestOWSURLSessionMock.Response(
|
|
matcher: { request in
|
|
return request.url == expectedAttributesRequest.url
|
|
},
|
|
statusCode: 200,
|
|
bodyData: nil
|
|
),
|
|
atTime: 10,
|
|
on: scheduler
|
|
)
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 10)
|
|
|
|
#expect(nextStep.value == .done)
|
|
|
|
// Since we set profile info, we should have scheduled a reupload.
|
|
#expect(profileManagerMock.didScheduleReuploadLocalProfile)
|
|
}
|
|
|
|
// MARK: - SVR Auth Credential Path
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSVRAuthCredentialPath_happyPath(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
|
|
// Run the scheduler for a bit; we don't care about timing these bits.
|
|
scheduler.start()
|
|
|
|
// Don't care about timing, just start it.
|
|
setupDefaultAccountAttributes()
|
|
|
|
// Set profile info so we skip those steps.
|
|
self.setAllProfileInfo()
|
|
|
|
// Put some auth credentials in storage.
|
|
let svr2CredentialCandidates: [SVR2AuthCredential] = [
|
|
Stubs.svr2AuthCredential,
|
|
SVR2AuthCredential(credential: RemoteAttestation.Auth(username: "aaaa", password: "abc")),
|
|
SVR2AuthCredential(credential: RemoteAttestation.Auth(username: "zzzz", password: "xyz")),
|
|
SVR2AuthCredential(credential: RemoteAttestation.Auth(username: "0000", password: "123"))
|
|
]
|
|
svrAuthCredentialStore.svr2Dict = Dictionary(grouping: svr2CredentialCandidates, by: \.credential.username).mapValues { $0.first! }
|
|
|
|
// Get past the opening.
|
|
goThroughOpeningHappyPath(
|
|
coordinator: coordinator,
|
|
mode: mode,
|
|
expectedNextStep: .phoneNumberEntry(self.stubs.phoneNumberEntryState(mode: mode))
|
|
)
|
|
|
|
// Give it a phone number, which should cause it to check the auth credentials.
|
|
// Match the main auth credential.
|
|
let expectedSVR2CheckRequest = RegistrationRequestFactory.svr2AuthCredentialCheckRequest(
|
|
e164: Stubs.e164,
|
|
credentials: svr2CredentialCandidates
|
|
)
|
|
mockURLSession.addResponse(TSRequestOWSURLSessionMock.Response(
|
|
urlSuffix: expectedSVR2CheckRequest.url!.absoluteString,
|
|
statusCode: 200,
|
|
bodyJson: RegistrationServiceResponses.SVR2AuthCheckResponse(matches: [
|
|
"\(Stubs.svr2AuthCredential.credential.username):\(Stubs.svr2AuthCredential.credential.password)": .match,
|
|
"aaaa:abc": .notMatch,
|
|
"zzzz:xyz": .invalid,
|
|
"0000:123": .unknown
|
|
])
|
|
))
|
|
|
|
let nextStep = coordinator.submitE164(Stubs.e164).value
|
|
|
|
// At this point, we should be asking for PIN entry so we can use the credential
|
|
// to recover the SVR master key.
|
|
#expect(nextStep == .pinEntry(Stubs.pinEntryStateForSVRAuthCredentialPath(mode: mode)))
|
|
// We should have wiped the invalid and unknown credentials.
|
|
let remainingCredentials = svrAuthCredentialStore.svr2Dict
|
|
#expect(remainingCredentials[Stubs.svr2AuthCredential.credential.username] != nil)
|
|
#expect(remainingCredentials["aaaa"] != nil)
|
|
#expect(remainingCredentials["zzzz"] == nil)
|
|
#expect(remainingCredentials["0000"] == nil)
|
|
// SVR should be untouched.
|
|
#expect(svrAuthCredentialStore.svr2Dict[Stubs.svr2AuthCredential.credential.username] != nil)
|
|
|
|
scheduler.stop()
|
|
scheduler.adjustTime(to: 0)
|
|
|
|
// Enter the PIN, which should try and recover from SVR.
|
|
// Once we do that, it should follow the Reg Recovery Password Path.
|
|
let nextStepPromise = coordinator.submitPINCode(Stubs.pinCode)
|
|
|
|
// At t=1, resolve the key restoration from SVR and have it start returning the key.
|
|
svr.restoreKeysMock = { pin, authMethod in
|
|
#expect(self.scheduler.currentTime == 0)
|
|
#expect(pin == Stubs.pinCode)
|
|
#expect(authMethod == .svrAuth(Stubs.svr2AuthCredential, backup: nil))
|
|
self.svr.hasMasterKey = true
|
|
return self.scheduler.guarantee(resolvingWith: .success, atTime: 1)
|
|
}
|
|
|
|
// At t=1 it should get the latest credentials from SVR.
|
|
self.svrKeyDeriver.dataGenerator = {
|
|
#expect(self.scheduler.currentTime == 1)
|
|
switch $0 {
|
|
case .registrationRecoveryPassword:
|
|
return Stubs.regRecoveryPwData
|
|
case .registrationLock:
|
|
return Stubs.reglockData
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Before registering at t=1, it should ask for push tokens to give the registration.
|
|
pushRegistrationManagerMock.requestPushTokenMock = {
|
|
#expect(self.scheduler.currentTime == 1)
|
|
return self.scheduler.guarantee(resolvingWith: .success(Stubs.apnsRegistrationId), atTime: 2)
|
|
}
|
|
// Every time we register we also ask for prekeys.
|
|
preKeyManagerMock.createPreKeysMock = {
|
|
switch self.scheduler.currentTime {
|
|
case 2:
|
|
return .value(Stubs.prekeyBundles())
|
|
default:
|
|
Issue.record("Got unexpected push tokens request")
|
|
return .init(error: PreKeyError())
|
|
}
|
|
}
|
|
// And we finalize them after.
|
|
preKeyManagerMock.finalizePreKeysMock = { didSucceed in
|
|
switch self.scheduler.currentTime {
|
|
case 3:
|
|
#expect(didSucceed)
|
|
return .value(())
|
|
default:
|
|
Issue.record("Got unexpected push tokens request")
|
|
return .init(error: PreKeyError())
|
|
}
|
|
}
|
|
|
|
// Now still at t=2 it should make a reg recovery pw request, resolve it at t=3.
|
|
let accountIdentityResponse = Stubs.accountIdentityResponse()
|
|
var authPassword: String!
|
|
let expectedRegRecoveryPwRequest = RegistrationRequestFactory.createAccountRequest(
|
|
verificationMethod: .recoveryPassword(Stubs.regRecoveryPw),
|
|
e164: Stubs.e164,
|
|
authPassword: "", // Doesn't matter for request generation.
|
|
accountAttributes: Stubs.accountAttributes(),
|
|
skipDeviceTransfer: true,
|
|
apnRegistrationId: Stubs.apnsRegistrationId,
|
|
prekeyBundles: Stubs.prekeyBundles()
|
|
)
|
|
self.mockURLSession.addResponse(
|
|
TSRequestOWSURLSessionMock.Response(
|
|
matcher: { request in
|
|
#expect(self.scheduler.currentTime == 2)
|
|
authPassword = request.authPassword
|
|
return request.url == expectedRegRecoveryPwRequest.url
|
|
},
|
|
statusCode: 200,
|
|
bodyJson: accountIdentityResponse
|
|
),
|
|
atTime: 3,
|
|
on: self.scheduler
|
|
)
|
|
|
|
func expectedAuthedAccount() -> AuthedAccount {
|
|
return .explicit(
|
|
aci: accountIdentityResponse.aci,
|
|
pni: accountIdentityResponse.pni,
|
|
e164: Stubs.e164,
|
|
deviceId: .primary,
|
|
authPassword: authPassword
|
|
)
|
|
}
|
|
|
|
// When registered at t=3, it should try and create pre-keys.
|
|
// Resolve at t=4.
|
|
preKeyManagerMock.rotateOneTimePreKeysMock = { auth in
|
|
#expect(self.scheduler.currentTime == 3)
|
|
#expect(auth == expectedAuthedAccount().chatServiceAuth)
|
|
return self.scheduler.promise(resolvingWith: (), atTime: 4)
|
|
}
|
|
|
|
// At t=4 once we create pre-keys, we should back up to svr.
|
|
svr.generateAndBackupKeysMock = { (pin: String, authMethod: SVR.AuthMethod) in
|
|
#expect(self.scheduler.currentTime == 4)
|
|
#expect(pin == Stubs.pinCode)
|
|
#expect(authMethod == .svrAuth(
|
|
Stubs.svr2AuthCredential,
|
|
backup: .chatServerAuth(expectedAuthedAccount())
|
|
))
|
|
return self.scheduler.promise(resolvingWith: (), atTime: 5)
|
|
}
|
|
|
|
// At t=5 once we back up to svr, we should restore from storage service.
|
|
storageServiceManagerMock.restoreOrCreateManifestIfNecessaryMock = { auth in
|
|
#expect(self.scheduler.currentTime == 5)
|
|
#expect(auth.authedAccount == expectedAuthedAccount())
|
|
return self.scheduler.promise(resolvingWith: (), atTime: 6)
|
|
}
|
|
|
|
// Once we restore from storage service at t=6, we should attempt to
|
|
// reclaim our username. Succeed at t=7.
|
|
let mockUsernameLink: Usernames.UsernameLink = .mocked
|
|
localUsernameManagerMock.startingUsernameState = .available(username: "boba.42", usernameLink: mockUsernameLink)
|
|
usernameApiClientMock.confirmReservedUsernameMock = { _, _, chatServiceAuth in
|
|
#expect(self.scheduler.currentTime == 6)
|
|
#expect(chatServiceAuth == .explicit(
|
|
aci: accountIdentityResponse.aci,
|
|
deviceId: .primary,
|
|
password: authPassword
|
|
))
|
|
return self.scheduler.promise(
|
|
resolvingWith: .success(usernameLinkHandle: mockUsernameLink.handle),
|
|
atTime: 7
|
|
)
|
|
}
|
|
|
|
// And at t=6 once we do the storage service restore,
|
|
// we will sync account attributes and then we are finished!
|
|
let expectedAttributesRequest = RegistrationRequestFactory.updatePrimaryDeviceAccountAttributesRequest(
|
|
Stubs.accountAttributes(),
|
|
auth: .implicit() // doesn't matter for url matching
|
|
)
|
|
self.mockURLSession.addResponse(
|
|
matcher: { request in
|
|
#expect(self.scheduler.currentTime == 7)
|
|
return request.url == expectedAttributesRequest.url
|
|
},
|
|
statusCode: 200
|
|
)
|
|
|
|
for i in 0...6 {
|
|
scheduler.run(atTime: i) {
|
|
#expect(nextStepPromise.value == nil)
|
|
}
|
|
}
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 7)
|
|
|
|
#expect(nextStepPromise.value == .done)
|
|
|
|
// Since we set profile info, we should have scheduled a reupload.
|
|
#expect(profileManagerMock.didScheduleReuploadLocalProfile)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSVRAuthCredentialPath_noMatchingCredentials(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
|
|
// Don't care about timing, just start it.
|
|
scheduler.start()
|
|
|
|
// Set profile info so we skip those steps.
|
|
setupDefaultAccountAttributes()
|
|
|
|
// Put some auth credentials in storage.
|
|
let credentialCandidates: [SVR2AuthCredential] = [
|
|
Stubs.svr2AuthCredential,
|
|
SVR2AuthCredential(credential: RemoteAttestation.Auth(username: "aaaa", password: "abc")),
|
|
SVR2AuthCredential(credential: RemoteAttestation.Auth(username: "zzzz", password: "xyz")),
|
|
SVR2AuthCredential(credential: RemoteAttestation.Auth(username: "0000", password: "123"))
|
|
]
|
|
svrAuthCredentialStore.svr2Dict = Dictionary(grouping: credentialCandidates, by: \.credential.username).mapValues { $0.first! }
|
|
|
|
// Get past the opening.
|
|
goThroughOpeningHappyPath(
|
|
coordinator: coordinator,
|
|
mode: mode,
|
|
expectedNextStep: .phoneNumberEntry(self.stubs.phoneNumberEntryState(mode: mode))
|
|
)
|
|
|
|
scheduler.stop()
|
|
scheduler.adjustTime(to: 0)
|
|
|
|
// Give it a phone number, which should cause it to check the auth credentials.
|
|
let nextStep = coordinator.submitE164(Stubs.e164)
|
|
|
|
// Don't give back any matches at t=2, which means we will want to create a session as a fallback.
|
|
let expectedSVRCheckRequest = RegistrationRequestFactory.svr2AuthCredentialCheckRequest(
|
|
e164: Stubs.e164,
|
|
credentials: credentialCandidates
|
|
)
|
|
mockURLSession.addResponse(
|
|
TSRequestOWSURLSessionMock.Response(
|
|
urlSuffix: expectedSVRCheckRequest.url!.absoluteString,
|
|
statusCode: 200,
|
|
bodyJson: RegistrationServiceResponses.SVR2AuthCheckResponse(matches: [
|
|
"\(Stubs.svr2AuthCredential.credential.username):\(Stubs.svr2AuthCredential.credential.password)": .notMatch,
|
|
"aaaa:abc": .notMatch,
|
|
"zzzz:xyz": .invalid,
|
|
"0000:123": .unknown
|
|
])
|
|
),
|
|
atTime: 2,
|
|
on: scheduler
|
|
)
|
|
|
|
// Once the first request fails, at t=2, it should try an start a session.
|
|
scheduler.run(atTime: 1) {
|
|
// We'll ask for a push challenge, though we don't need to resolve it in this test.
|
|
self.pushRegistrationManagerMock.receivePreAuthChallengeTokenMock = {
|
|
return Guarantee<String>.pending().0
|
|
}
|
|
|
|
// Resolve with a session at time 3.
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(self.stubs.session(hasSentVerificationCode: false)),
|
|
atTime: 3
|
|
)
|
|
}
|
|
|
|
// Then when it gets back the session at t=3, it should immediately ask for
|
|
// a verification code to be sent.
|
|
scheduler.run(atTime: 3) {
|
|
// Resolve with an updated session at time 4.
|
|
self.sessionManager.requestCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(self.stubs.session(hasSentVerificationCode: true)),
|
|
atTime: 4
|
|
)
|
|
}
|
|
|
|
pushRegistrationManagerMock.requestPushTokenMock = { .value(.success(Stubs.apnsRegistrationId))}
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 4)
|
|
|
|
// Now we should expect to be at verification code entry since we already set the phone number.
|
|
#expect(nextStep.value == .verificationCodeEntry(self.stubs.verificationCodeEntryState(mode: mode)))
|
|
|
|
// We should have wipted the invalid and unknown credentials.
|
|
let remainingCredentials = svrAuthCredentialStore.svr2Dict
|
|
#expect(remainingCredentials[Stubs.svr2AuthCredential.credential.username] != nil)
|
|
#expect(remainingCredentials["aaaa"] != nil)
|
|
#expect(remainingCredentials["zzzz"] == nil)
|
|
#expect(remainingCredentials["0000"] == nil)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSVRAuthCredentialPath_noMatchingCredentialsThenChangeNumber(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
// Don't care about timing, just start it.
|
|
scheduler.start()
|
|
|
|
// Set profile info so we skip those steps.
|
|
setupDefaultAccountAttributes()
|
|
|
|
// Put some auth credentials in storage.
|
|
let credentialCandidates: [SVR2AuthCredential] = [
|
|
Stubs.svr2AuthCredential
|
|
]
|
|
svrAuthCredentialStore.svr2Dict = Dictionary(grouping: credentialCandidates, by: \.credential.username).mapValues { $0.first! }
|
|
|
|
// Get past the opening.
|
|
goThroughOpeningHappyPath(
|
|
coordinator: coordinator,
|
|
mode: mode,
|
|
expectedNextStep: .phoneNumberEntry(self.stubs.phoneNumberEntryState(mode: mode))
|
|
)
|
|
|
|
scheduler.stop()
|
|
scheduler.adjustTime(to: 0)
|
|
|
|
let originalE164 = E164("+17875550100")!
|
|
let changedE164 = E164("+17875550101")!
|
|
|
|
// Give it a phone number, which should cause it to check the auth credentials.
|
|
var nextStep = coordinator.submitE164(originalE164)
|
|
|
|
// Don't give back any matches at t=2, which means we will want to create a session as a fallback.
|
|
var expectedSVRCheckRequest = RegistrationRequestFactory.svr2AuthCredentialCheckRequest(
|
|
e164: originalE164,
|
|
credentials: credentialCandidates
|
|
)
|
|
mockURLSession.addResponse(
|
|
TSRequestOWSURLSessionMock.Response(
|
|
urlSuffix: expectedSVRCheckRequest.url!.absoluteString,
|
|
statusCode: 200,
|
|
bodyJson: RegistrationServiceResponses.SVR2AuthCheckResponse(matches: [
|
|
"\(Stubs.svr2AuthCredential.credential.username):\(Stubs.svr2AuthCredential.credential.password)": .notMatch
|
|
])
|
|
),
|
|
atTime: 2,
|
|
on: scheduler
|
|
)
|
|
|
|
// Once the first request fails, at t=2, it should try an start a session.
|
|
scheduler.run(atTime: 1) {
|
|
// We'll ask for a push challenge, though we don't need to resolve it in this test.
|
|
self.pushRegistrationManagerMock.receivePreAuthChallengeTokenMock = {
|
|
return Guarantee<String>.pending().0
|
|
}
|
|
|
|
// Resolve with a session at time 3.
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(self.stubs.session(e164: originalE164, hasSentVerificationCode: false)),
|
|
atTime: 3
|
|
)
|
|
}
|
|
|
|
// Then when it gets back the session at t=3, it should immediately ask for
|
|
// a verification code to be sent.
|
|
scheduler.run(atTime: 3) {
|
|
// Resolve with an updated session at time 4.
|
|
self.sessionManager.requestCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(self.stubs.session(hasSentVerificationCode: true)),
|
|
atTime: 4
|
|
)
|
|
}
|
|
|
|
pushRegistrationManagerMock.requestPushTokenMock = { .value(.success(Stubs.apnsRegistrationId))}
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 4)
|
|
|
|
// Now we should expect to be at verification code entry since we already set the phone number.
|
|
#expect(nextStep.value == .verificationCodeEntry(self.stubs.verificationCodeEntryState(mode: mode)))
|
|
|
|
// We should have wiped the invalid and unknown credentials.
|
|
let remainingCredentials = svrAuthCredentialStore.svr2Dict
|
|
#expect(remainingCredentials[Stubs.svr2AuthCredential.credential.username] != nil)
|
|
|
|
// Now change the phone number; this should take us back to phone number entry.
|
|
nextStep = coordinator.requestChangeE164()
|
|
scheduler.runUntilIdle()
|
|
#expect(nextStep.value == .phoneNumberEntry(self.stubs.phoneNumberEntryState(mode: mode)))
|
|
|
|
// Give it a phone number, which should cause it to check the auth credentials again.
|
|
nextStep = coordinator.submitE164(changedE164)
|
|
|
|
// Give a match at t=5, so it registers via SVR auth credential.
|
|
expectedSVRCheckRequest = RegistrationRequestFactory.svr2AuthCredentialCheckRequest(
|
|
e164: changedE164,
|
|
credentials: credentialCandidates
|
|
)
|
|
mockURLSession.addResponse(
|
|
TSRequestOWSURLSessionMock.Response(
|
|
urlSuffix: expectedSVRCheckRequest.url!.absoluteString,
|
|
statusCode: 200,
|
|
bodyJson: RegistrationServiceResponses.SVR2AuthCheckResponse(matches: [
|
|
"\(Stubs.svr2AuthCredential.credential.username):\(Stubs.svr2AuthCredential.credential.password)": .match
|
|
])
|
|
),
|
|
atTime: 5,
|
|
on: scheduler
|
|
)
|
|
|
|
// Now it should ask for PIN entry; we are on the SVR auth credential path.
|
|
scheduler.runUntilIdle()
|
|
#expect(nextStep.value == .pinEntry(Stubs.pinEntryStateForSVRAuthCredentialPath(mode: mode)))
|
|
}
|
|
|
|
// MARK: - Session Path
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_happyPath(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
|
|
createSessionAndRequestFirstCode(coordinator: coordinator, mode: mode)
|
|
|
|
scheduler.tick()
|
|
|
|
var nextStep: Guarantee<RegistrationStep>!
|
|
|
|
// Submit a code at t=5.
|
|
scheduler.run(atTime: 5) {
|
|
nextStep = coordinator.submitVerificationCode(Stubs.pinCode)
|
|
}
|
|
|
|
// At t=7, give back a verified session.
|
|
self.sessionManager.submitCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: true
|
|
)),
|
|
atTime: 7
|
|
)
|
|
|
|
let accountIdentityResponse = Stubs.accountIdentityResponse()
|
|
var authPassword: String!
|
|
|
|
// That means at t=7 it should try and register with the verified
|
|
// session; be ready for that starting at t=6 (but not before).
|
|
|
|
// Before registering at t=7, it should ask for push tokens to give the registration.
|
|
pushRegistrationManagerMock.requestPushTokenMock = {
|
|
#expect(self.scheduler.currentTime == 7)
|
|
return self.scheduler.guarantee(resolvingWith: .success(Stubs.apnsRegistrationId), atTime: 8)
|
|
}
|
|
|
|
// It should also fetch the prekeys for account creation
|
|
preKeyManagerMock.createPreKeysMock = {
|
|
#expect(self.scheduler.currentTime == 8)
|
|
return self.scheduler.promise(resolvingWith: Stubs.prekeyBundles(), atTime: 9)
|
|
}
|
|
|
|
scheduler.run(atTime: 8) {
|
|
let expectedRequest = RegistrationRequestFactory.createAccountRequest(
|
|
verificationMethod: .sessionId(Stubs.sessionId),
|
|
e164: Stubs.e164,
|
|
authPassword: "", // Doesn't matter for request generation.
|
|
accountAttributes: Stubs.accountAttributes(),
|
|
skipDeviceTransfer: true,
|
|
apnRegistrationId: Stubs.apnsRegistrationId,
|
|
prekeyBundles: Stubs.prekeyBundles()
|
|
)
|
|
// Resolve it at t=10
|
|
self.mockURLSession.addResponse(
|
|
TSRequestOWSURLSessionMock.Response(
|
|
matcher: { request in
|
|
authPassword = request.authPassword
|
|
return request.url == expectedRequest.url
|
|
},
|
|
statusCode: 200,
|
|
bodyJson: accountIdentityResponse
|
|
),
|
|
atTime: 10,
|
|
on: self.scheduler
|
|
)
|
|
}
|
|
|
|
func expectedAuthedAccount() -> AuthedAccount {
|
|
return .explicit(
|
|
aci: accountIdentityResponse.aci,
|
|
pni: accountIdentityResponse.pni,
|
|
e164: Stubs.e164,
|
|
deviceId: .primary,
|
|
authPassword: authPassword
|
|
)
|
|
}
|
|
|
|
// Once we are registered at t=10, we should finalize prekeys.
|
|
preKeyManagerMock.finalizePreKeysMock = { didSucceed in
|
|
#expect(self.scheduler.currentTime == 10)
|
|
#expect(didSucceed)
|
|
return self.scheduler.promise(resolvingWith: (), atTime: 11)
|
|
}
|
|
|
|
// Then we should try and create one time pre-keys
|
|
// with the credentials we got in the identity response.
|
|
preKeyManagerMock.rotateOneTimePreKeysMock = { auth in
|
|
#expect(self.scheduler.currentTime == 11)
|
|
#expect(auth == expectedAuthedAccount().chatServiceAuth)
|
|
return self.scheduler.promise(resolvingWith: (), atTime: 12)
|
|
}
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 12)
|
|
|
|
// Now we should ask to create a PIN.
|
|
// No exit allowed since we've already started trying to create the account.
|
|
#expect(nextStep.value == .pinEntry(
|
|
Stubs.pinEntryStateForPostRegCreate(mode: mode, exitConfigOverride: .noExitAllowed)
|
|
))
|
|
|
|
// Confirm the pin first.
|
|
nextStep = coordinator.setPINCodeForConfirmation(.stub())
|
|
scheduler.runUntilIdle()
|
|
// No exit allowed since we've already started trying to create the account.
|
|
#expect(nextStep.value == .pinEntry(
|
|
Stubs.pinEntryStateForPostRegConfirm(mode: mode, exitConfigOverride: .noExitAllowed)
|
|
))
|
|
|
|
scheduler.adjustTime(to: 0)
|
|
|
|
// When we submit the pin, it should backup with SVR.
|
|
nextStep = coordinator.submitPINCode(Stubs.pinCode)
|
|
|
|
// Finish the validation at t=1.
|
|
svr.generateAndBackupKeysMock = { pin, authMethod in
|
|
#expect(self.scheduler.currentTime == 0)
|
|
#expect(pin == Stubs.pinCode)
|
|
#expect(authMethod == .chatServerAuth(expectedAuthedAccount()))
|
|
return self.scheduler.promise(resolvingWith: (), atTime: 1)
|
|
}
|
|
|
|
// At t=1 once we sync push tokens, we should restore from storage service.
|
|
storageServiceManagerMock.restoreOrCreateManifestIfNecessaryMock = { auth in
|
|
#expect(self.scheduler.currentTime == 1)
|
|
#expect(auth.authedAccount == expectedAuthedAccount())
|
|
return self.scheduler.promise(resolvingWith: (), atTime: 2)
|
|
}
|
|
|
|
// Once we restore from storage service, we should attempt to reclaim
|
|
// our username. For this test, let's fail at t=3. This should have
|
|
// no different impact on the rest of registration.
|
|
let mockUsernameLink: Usernames.UsernameLink = .mocked
|
|
localUsernameManagerMock.startingUsernameState = .available(username: "boba.42", usernameLink: mockUsernameLink)
|
|
usernameApiClientMock.confirmReservedUsernameMock = { _, _, chatServiceAuth in
|
|
#expect(self.scheduler.currentTime == 2)
|
|
#expect(chatServiceAuth == .explicit(
|
|
aci: accountIdentityResponse.aci,
|
|
deviceId: .primary,
|
|
password: authPassword
|
|
))
|
|
return self.scheduler.promise(
|
|
rejectedWith: OWSGenericError("Something went wrong :("),
|
|
atTime: 3
|
|
)
|
|
}
|
|
|
|
// When registered, we should create pre-keys.
|
|
preKeyManagerMock.rotateOneTimePreKeysMock = { auth in
|
|
#expect(auth == expectedAuthedAccount())
|
|
return .value(())
|
|
}
|
|
|
|
// And at t=3 once we do the storage service restore,
|
|
// we will sync account attributes and then we are finished!
|
|
let expectedAttributesRequest = RegistrationRequestFactory.updatePrimaryDeviceAccountAttributesRequest(
|
|
Stubs.accountAttributes(),
|
|
auth: .implicit() // doesn't matter for url matching
|
|
)
|
|
self.mockURLSession.addResponse(
|
|
matcher: { request in
|
|
#expect(self.scheduler.currentTime == 3)
|
|
return request.url == expectedAttributesRequest.url
|
|
},
|
|
statusCode: 200
|
|
)
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 3)
|
|
|
|
#expect(nextStep.value == .done)
|
|
|
|
// Since we set profile info, we should have scheduled a reupload.
|
|
#expect(profileManagerMock.didScheduleReuploadLocalProfile)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_invalidE164(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
|
|
switch mode {
|
|
case .registering, .changingNumber:
|
|
break
|
|
case .reRegistering:
|
|
// no changing the number when reregistering
|
|
return
|
|
}
|
|
|
|
setUpSessionPath(coordinator: coordinator, mode: mode)
|
|
|
|
let badE164 = E164("+15555555555")!
|
|
|
|
// Give it a phone number, which should cause it to start a session.
|
|
let nextStep = coordinator.submitE164(badE164)
|
|
|
|
// At t=2, reject for invalid argument (the e164).
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .invalidArgument,
|
|
atTime: 2
|
|
)
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 2)
|
|
|
|
// It should put us on the phone number entry screen again
|
|
// with an error.
|
|
#expect(
|
|
nextStep.value ==
|
|
.phoneNumberEntry(
|
|
self.stubs.phoneNumberEntryState(
|
|
mode: mode,
|
|
previouslyEnteredE164: badE164,
|
|
withValidationErrorFor: .invalidArgument
|
|
)
|
|
)
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_rateLimitSessionCreation(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
setUpSessionPath(coordinator: coordinator, mode: mode)
|
|
|
|
let retryTimeInterval: TimeInterval = 5
|
|
|
|
// Give it a phone number, which should cause it to start a session.
|
|
let nextStep = coordinator.submitE164(Stubs.e164)
|
|
|
|
// At t=2, reject with a rate limit.
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .retryAfter(retryTimeInterval),
|
|
atTime: 2
|
|
)
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 2)
|
|
|
|
// It should put us on the phone number entry screen again
|
|
// with an error.
|
|
#expect(
|
|
nextStep.value ==
|
|
.phoneNumberEntry(
|
|
self.stubs.phoneNumberEntryState(
|
|
mode: mode,
|
|
previouslyEnteredE164: Stubs.e164,
|
|
withValidationErrorFor: .retryAfter(retryTimeInterval)
|
|
)
|
|
)
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_cantSendFirstSMSCode(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
setUpSessionPath(coordinator: coordinator, mode: mode)
|
|
|
|
// Give it a phone number, which should cause it to start a session.
|
|
let nextStep = coordinator.submitE164(Stubs.e164)
|
|
|
|
// At t=2, give back a session, but with SMS code rate limiting already.
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 10,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 2
|
|
)
|
|
|
|
// It should put us on the verification code entry screen with an error.
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 2)
|
|
#expect(
|
|
nextStep.value ==
|
|
.verificationCodeEntry(self.stubs.verificationCodeEntryState(
|
|
mode: mode,
|
|
nextSMS: 10,
|
|
nextVerificationAttempt: nil,
|
|
validationError: .smsResendTimeout
|
|
))
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_landline(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
setUpSessionPath(coordinator: coordinator, mode: mode)
|
|
|
|
// Give it a phone number, which should cause it to start a session.
|
|
var nextStep = coordinator.submitE164(Stubs.e164)
|
|
|
|
// At t=2, give back a session that's ready to go.
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: nil, /* initially calling unavailable */
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 2
|
|
)
|
|
|
|
// Once we get that session at t=2, we should try and send a code.
|
|
// Be ready for that starting at t=1 (but not before).
|
|
scheduler.run(atTime: 1) {
|
|
// Resolve with a transport error at time 3,
|
|
// and no next verification attempt on the session,
|
|
// so it counts as transport failure with no code sent.
|
|
self.sessionManager.requestCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .transportError(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: nil,
|
|
nextCall: 0, /* now sms unavailable but calling is */
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 3
|
|
)
|
|
}
|
|
|
|
// At t=3 we should get back the code entry step,
|
|
// with a validation error for the sms transport.
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 3)
|
|
#expect(nextStep.value == .verificationCodeEntry(self.stubs.verificationCodeEntryState(
|
|
mode: mode,
|
|
nextSMS: nil,
|
|
nextVerificationAttempt: nil,
|
|
validationError: .failedInitialTransport(failedTransport: .sms)
|
|
)))
|
|
|
|
// If we resend via voice, that should put us in a happy path.
|
|
// Resolve with a success at t=4.
|
|
self.sessionManager.didRequestCode = false
|
|
self.sessionManager.requestCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: 0,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 4
|
|
)
|
|
|
|
nextStep = coordinator.requestVoiceCode()
|
|
|
|
// At t=4 we should get back the code entry step.
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 4)
|
|
#expect(sessionManager.didRequestCode)
|
|
#expect(nextStep.value == .verificationCodeEntry(self.stubs.verificationCodeEntryState(mode: mode)))
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_landline_submitCodeWithNoneSentYet(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
setUpSessionPath(coordinator: coordinator, mode: mode)
|
|
|
|
// Give it a phone number, which should cause it to start a session.
|
|
var nextStep = coordinator.submitE164(Stubs.e164)
|
|
|
|
// At t=2, give back a session that's ready to go.
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 2
|
|
)
|
|
|
|
// Once we get that session at t=2, we should try and send a code.
|
|
// Be ready for that starting at t=1 (but not before).
|
|
scheduler.run(atTime: 1) {
|
|
// Resolve with a transport error at time 3,
|
|
// and no next verification attempt on the session,
|
|
// so it counts as transport failure with no code sent.
|
|
self.sessionManager.requestCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .transportError(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 3
|
|
)
|
|
}
|
|
|
|
// At t=3 we should get back the code entry step,
|
|
// with a validation error for the sms transport.
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 3)
|
|
#expect(nextStep.value == .verificationCodeEntry(self.stubs.verificationCodeEntryState(
|
|
mode: mode,
|
|
nextVerificationAttempt: nil,
|
|
validationError: .failedInitialTransport(failedTransport: .sms)
|
|
)))
|
|
|
|
// If we try and submit a code, we should get an error sheet
|
|
// because a code never got sent in the first place.
|
|
// (If the server rejects the submission, which it obviously should).
|
|
self.sessionManager.submitCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .disallowed(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 4
|
|
)
|
|
|
|
nextStep = coordinator.submitVerificationCode(Stubs.verificationCode)
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 4)
|
|
|
|
// The server says no code is available to submit. We know
|
|
// we never sent a code, so show a unique error for that
|
|
// but keep the user on the code entry screen so they can
|
|
// retry sending a code with a transport method of their choice.
|
|
|
|
#expect(
|
|
nextStep.value ==
|
|
.showErrorSheet(.submittingVerificationCodeBeforeAnyCodeSent)
|
|
)
|
|
nextStep = coordinator.nextStep()
|
|
scheduler.runUntilIdle()
|
|
#expect(
|
|
nextStep.value ==
|
|
.verificationCodeEntry(self.stubs.verificationCodeEntryState(
|
|
mode: mode,
|
|
nextVerificationAttempt: nil,
|
|
validationError: .failedInitialTransport(failedTransport: .sms)
|
|
))
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_rateLimitFirstSMSCode(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
setUpSessionPath(coordinator: coordinator, mode: mode)
|
|
|
|
// Give it a phone number, which should cause it to start a session.
|
|
let nextStep = coordinator.submitE164(Stubs.e164)
|
|
|
|
// We'll ask for a push challenge, though we won't resolve it in this test.
|
|
self.pushRegistrationManagerMock.receivePreAuthChallengeTokenMock = {
|
|
return Guarantee<String>.pending().0
|
|
}
|
|
|
|
// At t=2, give back a session that's ready to go.
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 2
|
|
)
|
|
|
|
// Once we get that session at t=2, we should try and send a code.
|
|
// Be ready for that starting at t=1 (but not before).
|
|
scheduler.run(atTime: 1) {
|
|
// Reject with a timeout.
|
|
self.sessionManager.requestCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .retryAfterTimeout(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 10,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 3
|
|
)
|
|
}
|
|
|
|
// It should put us on the phone number entry screen again
|
|
// with an error.
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 3)
|
|
#expect(
|
|
nextStep.value ==
|
|
.phoneNumberEntry(
|
|
self.stubs.phoneNumberEntryState(
|
|
mode: mode,
|
|
previouslyEnteredE164: Stubs.e164,
|
|
withValidationErrorFor: .retryAfter(10)
|
|
)
|
|
)
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_changeE164(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
setUpSessionPath(coordinator: coordinator, mode: mode)
|
|
|
|
let originalE164 = E164("+17875550100")!
|
|
let changedE164 = E164("+17875550101")!
|
|
|
|
// Give it a phone number, which should cause it to start a session.
|
|
var nextStep = coordinator.submitE164(originalE164)
|
|
|
|
// We'll ask for a push challenge, though we won't resolve it in this test.
|
|
self.pushRegistrationManagerMock.receivePreAuthChallengeTokenMock = {
|
|
return Guarantee<String>.pending().0
|
|
}
|
|
|
|
// At t=2, give back a session that's ready to go.
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: originalE164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 2
|
|
)
|
|
|
|
// Once we get that session at t=2, we should try and send a code.
|
|
// Be ready for that starting at t=1 (but not before).
|
|
scheduler.run(atTime: 1) {
|
|
// Give back a session with a sent code.
|
|
self.sessionManager.requestCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: originalE164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: 0,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 3
|
|
)
|
|
}
|
|
|
|
// We should be on the verification code entry screen.
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 3)
|
|
#expect(
|
|
nextStep.value ==
|
|
.verificationCodeEntry(
|
|
self.stubs.verificationCodeEntryState(mode: mode, e164: originalE164)
|
|
)
|
|
)
|
|
|
|
// Ask to change the number; this should put us back on phone number entry.
|
|
nextStep = coordinator.requestChangeE164()
|
|
scheduler.runUntilIdle()
|
|
#expect(
|
|
nextStep.value ==
|
|
.phoneNumberEntry(self.stubs.phoneNumberEntryState(mode: mode))
|
|
)
|
|
|
|
// Give it the new phone number, which should cause it to start a session.
|
|
nextStep = coordinator.submitE164(changedE164)
|
|
|
|
// We'll ask for a push challenge, though we won't resolve it in this test.
|
|
self.pushRegistrationManagerMock.receivePreAuthChallengeTokenMock = {
|
|
return Guarantee<String>.pending().0
|
|
}
|
|
|
|
// At t=5, give back a session that's ready to go.
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: changedE164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 5
|
|
)
|
|
|
|
// Once we get that session at t=5, we should try and send a code.
|
|
// Be ready for that starting at t=4 (but not before).
|
|
scheduler.run(atTime: 4) {
|
|
// Give back a session with a sent code.
|
|
self.sessionManager.requestCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: changedE164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: 0,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 6
|
|
)
|
|
}
|
|
|
|
// We should be on the verification code entry screen.
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 6)
|
|
#expect(
|
|
nextStep.value ==
|
|
.verificationCodeEntry(
|
|
self.stubs.verificationCodeEntryState(mode: mode, e164: changedE164)
|
|
)
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_captchaChallenge(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
setUpSessionPath(coordinator: coordinator, mode: mode)
|
|
|
|
// Give it a phone number, which should cause it to start a session.
|
|
var nextStep = coordinator.submitE164(Stubs.e164)
|
|
|
|
// At t=2, give back a session with a captcha challenge.
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: false,
|
|
requestedInformation: [.captcha],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 2
|
|
)
|
|
|
|
// Once we get that session at t=2, we should get a captcha step back.
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 2)
|
|
#expect(nextStep.value == .captchaChallenge)
|
|
|
|
scheduler.tick()
|
|
|
|
// Submit a captcha challenge at t=4.
|
|
scheduler.run(atTime: 4) {
|
|
nextStep = coordinator.submitCaptcha(Stubs.captchaToken)
|
|
}
|
|
|
|
// At t=6, give back a session without the challenge.
|
|
self.sessionManager.fulfillChallengeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 6
|
|
)
|
|
|
|
// That means at t=6 it should try and send a code;
|
|
// be ready for that starting at t=5 (but not before).
|
|
scheduler.run(atTime: 5) {
|
|
// Resolve with a session at time 7.
|
|
// The session has a sent code, but requires a challenge to send
|
|
// a code again. That should be ignored until we ask to send another code.
|
|
self.sessionManager.requestCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: 0,
|
|
allowedToRequestCode: false,
|
|
requestedInformation: [.captcha],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 7
|
|
)
|
|
}
|
|
|
|
// At t=7, we should get back the code entry step.
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 7)
|
|
#expect(nextStep.value == .verificationCodeEntry(self.stubs.verificationCodeEntryState(mode: mode)))
|
|
|
|
// Now try and resend a code, which should hit us with the captcha challenge immediately.
|
|
scheduler.start()
|
|
#expect(coordinator.requestSMSCode().value == .captchaChallenge)
|
|
scheduler.stop()
|
|
|
|
// Submit a captcha challenge at t=8.
|
|
scheduler.run(atTime: 8) {
|
|
nextStep = coordinator.submitCaptcha(Stubs.captchaToken)
|
|
}
|
|
|
|
// At t=10, give back a session without the challenge.
|
|
self.sessionManager.fulfillChallengeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: 0,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 10
|
|
)
|
|
|
|
// This means at t=10 when we fulfill the challenge, it should
|
|
// immediately try and send the code that couldn't be sent before because
|
|
// of the challenge.
|
|
// Reply to this at t=12.
|
|
self.stubs.date = date.addingTimeInterval(10)
|
|
let secondCodeDate = date
|
|
scheduler.run(atTime: 9) {
|
|
self.sessionManager.requestCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: secondCodeDate,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: 0,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 12
|
|
)
|
|
}
|
|
|
|
// Ensure that at t=11, before we've gotten the request code response,
|
|
// we don't have a result yet.
|
|
scheduler.run(atTime: 11) {
|
|
#expect(nextStep.value == nil)
|
|
}
|
|
|
|
// Once all is done, we should have a new code and be back on the code
|
|
// entry screen.
|
|
// TODO[Registration]: test that the "next SMS code" state is properly set
|
|
// given the new sms code date above.
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 12)
|
|
#expect(nextStep.value == .verificationCodeEntry(self.stubs.verificationCodeEntryState(mode: mode)))
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_pushChallenge(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
setUpSessionPath(coordinator: coordinator, mode: mode)
|
|
|
|
pushRegistrationManagerMock.requestPushTokenMock = {
|
|
#expect(self.scheduler.currentTime == 0)
|
|
return .value(.success(Stubs.apnsRegistrationId))
|
|
}
|
|
|
|
// Give it a phone number, which should cause it to start a session.
|
|
let nextStep = coordinator.submitE164(Stubs.e164)
|
|
|
|
// Prepare to provide the challenge token.
|
|
let (challengeTokenPromise, challengeTokenFuture) = Guarantee<String>.pending()
|
|
pushRegistrationManagerMock.receivePreAuthChallengeTokenMock = {
|
|
#expect(self.scheduler.currentTime == 2)
|
|
return challengeTokenPromise
|
|
}
|
|
|
|
// At t=2, give back a session with a push challenge.
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: false,
|
|
requestedInformation: [.pushChallenge],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 2
|
|
)
|
|
|
|
// At t=3, give the push challenge token. Also prepare to handle its usage, and the
|
|
// resulting request for another SMS code.
|
|
scheduler.run(atTime: 3) {
|
|
challengeTokenFuture.resolve("a pre-auth challenge token")
|
|
|
|
self.sessionManager.fulfillChallengeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: 0,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 4
|
|
)
|
|
|
|
self.sessionManager.requestCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: 0,
|
|
allowedToRequestCode: false,
|
|
requestedInformation: [.pushChallenge],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 6
|
|
)
|
|
|
|
// We should still be waiting.
|
|
#expect(nextStep.value == nil)
|
|
}
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 6)
|
|
|
|
#expect(
|
|
nextStep.value ==
|
|
.verificationCodeEntry(self.stubs.verificationCodeEntryState(mode: mode))
|
|
)
|
|
#expect(
|
|
sessionManager.latestChallengeFulfillment ==
|
|
.pushChallenge("a pre-auth challenge token")
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_pushChallengeTimeoutAfterResolutionThatTakesTooLong(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
let sessionStartsAt = 2
|
|
|
|
setUpSessionPath(coordinator: coordinator, mode: mode)
|
|
|
|
dateProvider = { self.date.addingTimeInterval(TimeInterval(self.scheduler.currentTime)) }
|
|
|
|
pushRegistrationManagerMock.requestPushTokenMock = {
|
|
#expect(self.scheduler.currentTime == 0)
|
|
return .value(.success(Stubs.apnsRegistrationId))
|
|
}
|
|
|
|
// Give it a phone number, which should cause it to start a session.
|
|
let nextStep = coordinator.submitE164(Stubs.e164)
|
|
|
|
// Prepare to provide the challenge token.
|
|
let (challengeTokenPromise, challengeTokenFuture) = Guarantee<String>.pending()
|
|
var receivePreAuthChallengeTokenCount = 0
|
|
pushRegistrationManagerMock.receivePreAuthChallengeTokenMock = {
|
|
switch receivePreAuthChallengeTokenCount {
|
|
case 0, 1:
|
|
#expect(self.scheduler.currentTime == sessionStartsAt)
|
|
case 2:
|
|
let minWaitTime = Int(RegistrationCoordinatorImpl.Constants.pushTokenMinWaitTime / self.scheduler.secondsPerTick)
|
|
#expect(self.scheduler.currentTime == sessionStartsAt + minWaitTime)
|
|
default:
|
|
Issue.record("Calling preAuthChallengeToken too many times")
|
|
}
|
|
receivePreAuthChallengeTokenCount += 1
|
|
return challengeTokenPromise
|
|
}
|
|
|
|
// At t=2, give back a session with a push challenge.
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: false,
|
|
requestedInformation: [.pushChallenge],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: sessionStartsAt
|
|
)
|
|
|
|
// Take too long to resolve with the challenge token.
|
|
let pushChallengeTimeout = Int(RegistrationCoordinatorImpl.Constants.pushTokenTimeout / scheduler.secondsPerTick)
|
|
let receiveChallengeTokenTime = sessionStartsAt + pushChallengeTimeout + 1
|
|
scheduler.run(atTime: receiveChallengeTokenTime) {
|
|
challengeTokenFuture.resolve("challenge token that should be ignored")
|
|
}
|
|
|
|
scheduler.advance(to: sessionStartsAt + pushChallengeTimeout - 1)
|
|
#expect(nextStep.value == nil)
|
|
|
|
scheduler.tick()
|
|
#expect(nextStep.value == .showErrorSheet(.sessionInvalidated))
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == receiveChallengeTokenTime)
|
|
|
|
// One time to set up, one time for the min wait time, one time
|
|
// for the full timeout.
|
|
#expect(receivePreAuthChallengeTokenCount == 3)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_pushChallengeTimeoutAfterNoResolution(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
|
|
let pushChallengeMinTime = Int(RegistrationCoordinatorImpl.Constants.pushTokenMinWaitTime / scheduler.secondsPerTick)
|
|
let pushChallengeTimeout = Int(RegistrationCoordinatorImpl.Constants.pushTokenTimeout / scheduler.secondsPerTick)
|
|
|
|
let sessionStartsAt = 2
|
|
setUpSessionPath(coordinator: coordinator, mode: mode)
|
|
|
|
pushRegistrationManagerMock.requestPushTokenMock = {
|
|
#expect(self.scheduler.currentTime == 0)
|
|
return .value(.success(Stubs.apnsRegistrationId))
|
|
}
|
|
|
|
// Give it a phone number, which should cause it to start a session.
|
|
let nextStep = coordinator.submitE164(Stubs.e164)
|
|
|
|
// We'll never provide a challenge token and will just leave it around forever.
|
|
let (challengeTokenPromise, _) = Guarantee<String>.pending()
|
|
var receivePreAuthChallengeTokenCount = 0
|
|
pushRegistrationManagerMock.receivePreAuthChallengeTokenMock = {
|
|
switch receivePreAuthChallengeTokenCount {
|
|
case 0, 1:
|
|
#expect(self.scheduler.currentTime == sessionStartsAt)
|
|
case 2:
|
|
#expect(self.scheduler.currentTime == sessionStartsAt + pushChallengeMinTime)
|
|
default:
|
|
Issue.record("Calling preAuthChallengeToken too many times")
|
|
}
|
|
receivePreAuthChallengeTokenCount += 1
|
|
return challengeTokenPromise
|
|
}
|
|
|
|
// At t=2, give back a session with a push challenge.
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: false,
|
|
requestedInformation: [.pushChallenge],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 2
|
|
)
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 2 + pushChallengeMinTime + pushChallengeTimeout)
|
|
#expect(nextStep.value == .showErrorSheet(.sessionInvalidated))
|
|
|
|
// One time to set up, one time for the min wait time, one time
|
|
// for the full timeout.
|
|
#expect(receivePreAuthChallengeTokenCount == 3)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_pushChallengeWithoutPushNotificationsAvailable(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
setUpSessionPath(coordinator: coordinator, mode: mode)
|
|
|
|
pushRegistrationManagerMock.requestPushTokenMock = {
|
|
#expect(self.scheduler.currentTime == 0)
|
|
return .value(.pushUnsupported(description: ""))
|
|
}
|
|
|
|
// Give it a phone number, which should cause it to start a session.
|
|
let nextStep = coordinator.submitE164(Stubs.e164)
|
|
|
|
// We'll ask for a push challenge, though we don't need to resolve it in this test.
|
|
self.pushRegistrationManagerMock.receivePreAuthChallengeTokenMock = {
|
|
#expect(self.scheduler.currentTime == 2)
|
|
return Guarantee<String>.pending().0
|
|
}
|
|
|
|
// Require a push challenge, which we won't be able to answer.
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: false,
|
|
requestedInformation: [.pushChallenge],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 2
|
|
)
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 2)
|
|
#expect(
|
|
nextStep.value ==
|
|
.phoneNumberEntry(self.stubs.phoneNumberEntryState(
|
|
mode: mode,
|
|
previouslyEnteredE164: Stubs.e164
|
|
))
|
|
)
|
|
#expect(sessionManager.latestChallengeFulfillment == nil)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_preferPushChallengesIfWeCanAnswerThemImmediately(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
setUpSessionPath(coordinator: coordinator, mode: mode)
|
|
|
|
pushRegistrationManagerMock.requestPushTokenMock = {
|
|
#expect(self.scheduler.currentTime == 0)
|
|
return .value(.success(Stubs.apnsRegistrationId))
|
|
}
|
|
|
|
// Be ready to provide the push challenge token as soon as it's needed.
|
|
pushRegistrationManagerMock.receivePreAuthChallengeTokenMock = {
|
|
#expect(self.scheduler.currentTime == 2)
|
|
return .value("a pre-auth challenge token")
|
|
}
|
|
|
|
// Give it a phone number, which should cause it to start a session.
|
|
let nextStep = coordinator.submitE164(Stubs.e164)
|
|
|
|
// At t=2, give back a session with multiple challenges.
|
|
sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: false,
|
|
requestedInformation: [.captcha, .pushChallenge],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 2
|
|
)
|
|
|
|
// Be ready to handle push challenges as soon as we can.
|
|
scheduler.run(atTime: 2) {
|
|
self.sessionManager.fulfillChallengeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: 0,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 4
|
|
)
|
|
self.sessionManager.requestCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: 0,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 5
|
|
)
|
|
}
|
|
|
|
// We should still be waiting at t=4.
|
|
scheduler.run(atTime: 4) {
|
|
#expect(nextStep.value == nil)
|
|
}
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 5)
|
|
|
|
#expect(
|
|
nextStep.value ==
|
|
.verificationCodeEntry(self.stubs.verificationCodeEntryState(mode: mode))
|
|
)
|
|
#expect(
|
|
sessionManager.latestChallengeFulfillment ==
|
|
.pushChallenge("a pre-auth challenge token")
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_prefersCaptchaChallengesIfWeCannotAnswerPushChallengeQuickly(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
setUpSessionPath(coordinator: coordinator, mode: mode)
|
|
|
|
pushRegistrationManagerMock.requestPushTokenMock = {
|
|
#expect(self.scheduler.currentTime == 0)
|
|
return .value(.success(Stubs.apnsRegistrationId))
|
|
}
|
|
|
|
// Give it a phone number, which should cause it to start a session.
|
|
let nextStep = coordinator.submitE164(Stubs.e164)
|
|
|
|
// Prepare to provide the challenge token.
|
|
let (challengeTokenPromise, challengeTokenFuture) = Guarantee<String>.pending()
|
|
pushRegistrationManagerMock.receivePreAuthChallengeTokenMock = {
|
|
#expect(self.scheduler.currentTime == 2)
|
|
return challengeTokenPromise
|
|
}
|
|
|
|
// At t=2, give back a session with multiple challenges.
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: false,
|
|
requestedInformation: [.pushChallenge, .captcha],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 2
|
|
)
|
|
|
|
// Take too long to resolve with the challenge token.
|
|
let pushChallengeTimeout = Int(RegistrationCoordinatorImpl.Constants.pushTokenTimeout / scheduler.secondsPerTick)
|
|
let receiveChallengeTokenTime = pushChallengeTimeout + 1
|
|
scheduler.run(atTime: receiveChallengeTokenTime - 1) {
|
|
let date = self.stubs.date.addingTimeInterval(TimeInterval(receiveChallengeTokenTime))
|
|
self.stubs.date = date
|
|
}
|
|
scheduler.run(atTime: receiveChallengeTokenTime) {
|
|
challengeTokenFuture.resolve("challenge token that should be ignored")
|
|
}
|
|
|
|
// Once we get that session at t=2, we should wait a short time for the
|
|
// push challenge token.
|
|
let pushChallengeMinTime = Int(RegistrationCoordinatorImpl.Constants.pushTokenMinWaitTime / scheduler.secondsPerTick)
|
|
|
|
// After that, we should get a captcha step back, because we haven't
|
|
// yet received the push challenge token.
|
|
scheduler.advance(to: 2 + pushChallengeMinTime)
|
|
#expect(nextStep.value == .captchaChallenge)
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == receiveChallengeTokenTime)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_pushChallengeFastResolution(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
setUpSessionPath(coordinator: coordinator, mode: mode)
|
|
|
|
pushRegistrationManagerMock.requestPushTokenMock = {
|
|
#expect(self.scheduler.currentTime == 0)
|
|
return .value(.success(Stubs.apnsRegistrationId))
|
|
}
|
|
|
|
// Give it a phone number, which should cause it to start a session.
|
|
let nextStep = coordinator.submitE164(Stubs.e164)
|
|
|
|
// Prepare to provide the challenge token.
|
|
let pushChallengeMinTime = Int(RegistrationCoordinatorImpl.Constants.pushTokenMinWaitTime / scheduler.secondsPerTick)
|
|
let receiveChallengeTokenTime = 2 + pushChallengeMinTime - 1
|
|
|
|
let (challengeTokenPromise, challengeTokenFuture) = Guarantee<String>.pending()
|
|
var receivePreAuthChallengeTokenCount = 0
|
|
pushRegistrationManagerMock.receivePreAuthChallengeTokenMock = {
|
|
switch receivePreAuthChallengeTokenCount {
|
|
case 0, 1:
|
|
#expect(self.scheduler.currentTime == 2)
|
|
default:
|
|
Issue.record("Calling preAuthChallengeToken too many times")
|
|
}
|
|
receivePreAuthChallengeTokenCount += 1
|
|
return challengeTokenPromise
|
|
}
|
|
|
|
// At t=2, give back a session with multiple challenges.
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: false,
|
|
requestedInformation: [.pushChallenge, .captcha],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 2
|
|
)
|
|
|
|
// Don't resolve the captcha token immediately, but quickly enough.
|
|
scheduler.run(atTime: receiveChallengeTokenTime - 1) {
|
|
let date = self.stubs.date.addingTimeInterval(TimeInterval(pushChallengeMinTime - 1))
|
|
self.stubs.date = date
|
|
}
|
|
scheduler.run(atTime: receiveChallengeTokenTime) {
|
|
// Also prep for the token's submission.
|
|
self.sessionManager.fulfillChallengeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.stubs.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: 0,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: receiveChallengeTokenTime + 1
|
|
)
|
|
|
|
self.sessionManager.requestCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.stubs.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: 0,
|
|
allowedToRequestCode: false,
|
|
requestedInformation: [.pushChallenge],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: receiveChallengeTokenTime + 2
|
|
)
|
|
|
|
challengeTokenFuture.resolve("challenge token")
|
|
}
|
|
|
|
// Once we get that session, we should wait a short time for the
|
|
// push challenge token and fulfill it.
|
|
scheduler.advance(to: receiveChallengeTokenTime + 2)
|
|
#expect(nextStep.value == .verificationCodeEntry(self.stubs.verificationCodeEntryState(mode: mode)))
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == receiveChallengeTokenTime + 2)
|
|
|
|
#expect(receivePreAuthChallengeTokenCount == 2)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_ignoresPushChallengesIfWeCannotEverAnswerThem(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
setUpSessionPath(coordinator: coordinator, mode: mode)
|
|
|
|
pushRegistrationManagerMock.requestPushTokenMock = {
|
|
#expect(self.scheduler.currentTime == 0)
|
|
return .value(.pushUnsupported(description: ""))
|
|
}
|
|
|
|
// Give it a phone number, which should cause it to start a session.
|
|
let nextStep = coordinator.submitE164(Stubs.e164)
|
|
|
|
// At t=2, give back a session with multiple challenges.
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: false,
|
|
requestedInformation: [.captcha, .pushChallenge],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 2
|
|
)
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 2)
|
|
#expect(nextStep.value == .captchaChallenge)
|
|
#expect(sessionManager.latestChallengeFulfillment == nil)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_unknownChallenge(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
setUpSessionPath(coordinator: coordinator, mode: mode)
|
|
|
|
// Give it a phone number, which should cause it to start a session.
|
|
var nextStep = coordinator.submitE164(Stubs.e164)
|
|
|
|
// At t=2, give back a session with a captcha challenge and an unknown challenge.
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: false,
|
|
requestedInformation: [.captcha],
|
|
hasUnknownChallengeRequiringAppUpdate: true,
|
|
verified: false
|
|
)),
|
|
atTime: 2
|
|
)
|
|
|
|
// Once we get that session at t=2, we should get a captcha step back.
|
|
// We have an unknown challenge, but we should do known challenges first!
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 2)
|
|
#expect(nextStep.value == .captchaChallenge)
|
|
|
|
scheduler.tick()
|
|
|
|
// Submit a captcha challenge at t=4.
|
|
scheduler.run(atTime: 4) {
|
|
nextStep = coordinator.submitCaptcha(Stubs.captchaToken)
|
|
}
|
|
|
|
// At t=6, give back a session without the captcha but still with the
|
|
// unknown challenge
|
|
self.sessionManager.fulfillChallengeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: false,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: true,
|
|
verified: false
|
|
)),
|
|
atTime: 6
|
|
)
|
|
|
|
// This means at t=6 we should get the app update banner.
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 6)
|
|
#expect(nextStep.value == .appUpdateBanner)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_wrongVerificationCode(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
createSessionAndRequestFirstCode(coordinator: coordinator, mode: mode)
|
|
|
|
// Now try and send the wrong code.
|
|
let badCode = "garbage"
|
|
|
|
// At t=1, give back a rejected argument response, its the wrong code.
|
|
self.sessionManager.submitCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .rejectedArgument(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: 0,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 1
|
|
)
|
|
|
|
let nextStep = coordinator.submitVerificationCode(badCode)
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 1)
|
|
#expect(
|
|
nextStep.value ==
|
|
.verificationCodeEntry(self.stubs.verificationCodeEntryState(
|
|
mode: mode,
|
|
validationError: .invalidVerificationCode(invalidCode: badCode)
|
|
))
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_verificationCodeTimeouts(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
createSessionAndRequestFirstCode(coordinator: coordinator, mode: mode)
|
|
|
|
// At t=1, give back a retry response.
|
|
self.sessionManager.submitCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .retryAfterTimeout(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: 10,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 1
|
|
)
|
|
|
|
var nextStep = coordinator.submitVerificationCode(Stubs.verificationCode)
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 1)
|
|
#expect(
|
|
nextStep.value ==
|
|
.verificationCodeEntry(self.stubs.verificationCodeEntryState(
|
|
mode: mode,
|
|
nextVerificationAttempt: 10,
|
|
validationError: .submitCodeTimeout
|
|
))
|
|
)
|
|
|
|
// Resend an sms code, time that out too at t=2.
|
|
self.sessionManager.requestCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .retryAfterTimeout(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 7,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: 9,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 2
|
|
)
|
|
|
|
nextStep = coordinator.requestSMSCode()
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 2)
|
|
#expect(
|
|
nextStep.value ==
|
|
.verificationCodeEntry(self.stubs.verificationCodeEntryState(
|
|
mode: mode,
|
|
nextSMS: 7,
|
|
nextVerificationAttempt: 9,
|
|
validationError: .smsResendTimeout
|
|
))
|
|
)
|
|
|
|
// Resend an voice code, time that out too at t=4.
|
|
// Make the timeout SO short that it retries at t=4.
|
|
self.sessionManager.didRequestCode = false
|
|
self.sessionManager.requestCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .retryAfterTimeout(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 6,
|
|
nextCall: 0.1,
|
|
nextVerificationAttempt: 8,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 4
|
|
)
|
|
|
|
// Be ready for the retry at t=4
|
|
scheduler.run(atTime: 3) {
|
|
// Ensure we called it the first time.
|
|
#expect(self.sessionManager.didRequestCode)
|
|
self.sessionManager.requestCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .retryAfterTimeout(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 5,
|
|
nextCall: 4,
|
|
nextVerificationAttempt: 8,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 5
|
|
)
|
|
}
|
|
|
|
nextStep = coordinator.requestVoiceCode()
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 5)
|
|
#expect(
|
|
nextStep.value ==
|
|
.verificationCodeEntry(self.stubs.verificationCodeEntryState(
|
|
mode: mode,
|
|
nextSMS: 5,
|
|
nextCall: 4,
|
|
nextVerificationAttempt: 8,
|
|
validationError: .voiceResendTimeout
|
|
))
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_disallowedVerificationCode(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
createSessionAndRequestFirstCode(coordinator: coordinator, mode: mode)
|
|
|
|
// At t=1, give back a disallowed response when submitting a code.
|
|
// Make the session unverified. Together this will be interpreted
|
|
// as meaning no code has been sent (via sms or voice) and one
|
|
// must be requested.
|
|
self.sessionManager.submitCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .disallowed(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 1
|
|
)
|
|
|
|
var nextStep = coordinator.submitVerificationCode(Stubs.verificationCode)
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 1)
|
|
|
|
// The server says no code is available to submit. But we think we tried
|
|
// sending a code with local state. We want to be on the verification
|
|
// code entry screen, with an error so the user retries sending a code.
|
|
|
|
#expect(
|
|
nextStep.value ==
|
|
.showErrorSheet(.verificationCodeSubmissionUnavailable)
|
|
)
|
|
nextStep = coordinator.nextStep()
|
|
scheduler.runUntilIdle()
|
|
#expect(
|
|
nextStep.value ==
|
|
.verificationCodeEntry(self.stubs.verificationCodeEntryState(
|
|
mode: mode,
|
|
nextVerificationAttempt: nil
|
|
))
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_timedOutVerificationCodeWithoutRetries(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
createSessionAndRequestFirstCode(coordinator: coordinator, mode: mode)
|
|
|
|
// At t=1, give back a retry response when submitting a code,
|
|
// but with no ability to resubmit.
|
|
self.sessionManager.submitCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .retryAfterTimeout(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 1
|
|
)
|
|
|
|
var nextStep = coordinator.submitVerificationCode(Stubs.verificationCode)
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 1)
|
|
#expect(
|
|
nextStep.value ==
|
|
.showErrorSheet(.verificationCodeSubmissionUnavailable)
|
|
)
|
|
nextStep = coordinator.nextStep()
|
|
scheduler.runUntilIdle()
|
|
#expect(
|
|
nextStep.value ==
|
|
.verificationCodeEntry(self.stubs.verificationCodeEntryState(
|
|
mode: mode,
|
|
nextVerificationAttempt: nil
|
|
))
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_expiredSession(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
setUpSessionPath(coordinator: coordinator, mode: mode)
|
|
|
|
// Give it a phone number, which should cause it to start a session.
|
|
var nextStep = coordinator.submitE164(Stubs.e164)
|
|
|
|
// At t=2, give back a session thats ready to go.
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 2
|
|
)
|
|
|
|
// Once we get that session at t=2, we should try and send a verification code.
|
|
// Have that ready to go at t=1.
|
|
scheduler.run(atTime: 1) {
|
|
// We'll ask for a push challenge, though we won't resolve it.
|
|
self.pushRegistrationManagerMock.receivePreAuthChallengeTokenMock = {
|
|
return Guarantee<String>.pending().0
|
|
}
|
|
|
|
// Resolve with a session at time 3.
|
|
self.sessionManager.requestCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: 0,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 3
|
|
)
|
|
}
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 3)
|
|
|
|
// Now we should expect to be at verification code entry since we sent the code.
|
|
#expect(nextStep.value == .verificationCodeEntry(self.stubs.verificationCodeEntryState(mode: mode)))
|
|
|
|
scheduler.tick()
|
|
|
|
// Submit a code at t=5.
|
|
scheduler.run(atTime: 5) {
|
|
nextStep = coordinator.submitVerificationCode(Stubs.pinCode)
|
|
}
|
|
|
|
// At t=7, give back an expired session.
|
|
self.sessionManager.submitCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .invalidSession,
|
|
atTime: 7
|
|
)
|
|
|
|
// That means at t=7 it should show an error, and then phone number entry.
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 7)
|
|
#expect(nextStep.value == .showErrorSheet(.sessionInvalidated))
|
|
nextStep = coordinator.nextStep()
|
|
scheduler.runUntilIdle()
|
|
#expect(nextStep.value == .phoneNumberEntry(self.stubs.phoneNumberEntryState(
|
|
mode: mode,
|
|
previouslyEnteredE164: Stubs.e164
|
|
)))
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_skipPINCode(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
createSessionAndRequestFirstCode(coordinator: coordinator, mode: mode)
|
|
|
|
scheduler.tick()
|
|
|
|
var nextStep: Guarantee<RegistrationStep>!
|
|
|
|
// Submit a code at t=5.
|
|
scheduler.run(atTime: 5) {
|
|
nextStep = coordinator.submitVerificationCode(Stubs.pinCode)
|
|
}
|
|
|
|
// At t=7, give back a verified session.
|
|
self.sessionManager.submitCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: true
|
|
)),
|
|
atTime: 7
|
|
)
|
|
|
|
let accountIdentityResponse = Stubs.accountIdentityResponse()
|
|
var authPassword: String!
|
|
|
|
// That means at t=7 it should try and register with the verified
|
|
// session; be ready for that starting at t=6 (but not before).
|
|
|
|
// Before registering at t=7, it should ask for push tokens to give the registration.
|
|
pushRegistrationManagerMock.requestPushTokenMock = {
|
|
#expect(self.scheduler.currentTime == 7)
|
|
return self.scheduler.guarantee(resolvingWith: .success(Stubs.apnsRegistrationId), atTime: 8)
|
|
}
|
|
|
|
// It should also fetch the prekeys for account creation
|
|
preKeyManagerMock.createPreKeysMock = {
|
|
#expect(self.scheduler.currentTime == 8)
|
|
return self.scheduler.promise(resolvingWith: Stubs.prekeyBundles(), atTime: 9)
|
|
}
|
|
|
|
scheduler.run(atTime: 8) {
|
|
let expectedRequest = RegistrationRequestFactory.createAccountRequest(
|
|
verificationMethod: .sessionId(Stubs.sessionId),
|
|
e164: Stubs.e164,
|
|
authPassword: "", // Doesn't matter for request generation.
|
|
accountAttributes: Stubs.accountAttributes(),
|
|
skipDeviceTransfer: true,
|
|
apnRegistrationId: Stubs.apnsRegistrationId,
|
|
prekeyBundles: Stubs.prekeyBundles()
|
|
)
|
|
// Resolve it at t=10
|
|
self.mockURLSession.addResponse(
|
|
TSRequestOWSURLSessionMock.Response(
|
|
matcher: { request in
|
|
authPassword = request.authPassword
|
|
return request.url == expectedRequest.url
|
|
},
|
|
statusCode: 200,
|
|
bodyJson: accountIdentityResponse
|
|
),
|
|
atTime: 10,
|
|
on: self.scheduler
|
|
)
|
|
}
|
|
|
|
func expectedAuthedAccount() -> AuthedAccount {
|
|
return .explicit(
|
|
aci: accountIdentityResponse.aci,
|
|
pni: accountIdentityResponse.pni,
|
|
e164: Stubs.e164,
|
|
deviceId: .primary,
|
|
authPassword: authPassword
|
|
)
|
|
}
|
|
|
|
// Once we are registered at t=10, we should finalize prekeys.
|
|
preKeyManagerMock.finalizePreKeysMock = { didSucceed in
|
|
#expect(self.scheduler.currentTime == 10)
|
|
#expect(didSucceed)
|
|
return self.scheduler.promise(resolvingWith: (), atTime: 11)
|
|
}
|
|
|
|
// Then we should try and create one time pre-keys
|
|
// with the credentials we got in the identity response.
|
|
preKeyManagerMock.rotateOneTimePreKeysMock = { auth in
|
|
#expect(self.scheduler.currentTime == 11)
|
|
#expect(auth == expectedAuthedAccount().chatServiceAuth)
|
|
return self.scheduler.promise(resolvingWith: (), atTime: 12)
|
|
}
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 12)
|
|
|
|
// Now we should ask to create a PIN.
|
|
// No exit allowed since we've already started trying to create the account.
|
|
#expect(nextStep.value == .pinEntry(
|
|
Stubs.pinEntryStateForPostRegCreate(mode: mode, exitConfigOverride: .noExitAllowed)
|
|
))
|
|
|
|
scheduler.adjustTime(to: 0)
|
|
|
|
// Skip the PIN code.
|
|
nextStep = coordinator.skipPINCode()
|
|
|
|
// When we skip the pin, it should skip any SVR backups.
|
|
svr.generateAndBackupKeysMock = { _, _ in
|
|
Issue.record("Shouldn't talk to SVR with skipped PIN!")
|
|
return .value(())
|
|
|
|
}
|
|
storageServiceManagerMock.restoreOrCreateManifestIfNecessaryMock = { _ in
|
|
return .value(())
|
|
}
|
|
|
|
// When registered, we should create pre-keys.
|
|
preKeyManagerMock.rotateOneTimePreKeysMock = { auth in
|
|
#expect(auth == expectedAuthedAccount())
|
|
return .value(())
|
|
}
|
|
|
|
// And at t=0 once we skip the storage service restore,
|
|
// we will sync account attributes and then we are finished!
|
|
let expectedAttributesRequest = RegistrationRequestFactory.updatePrimaryDeviceAccountAttributesRequest(
|
|
Stubs.accountAttributes(),
|
|
auth: .implicit() // doesn't matter for url matching
|
|
)
|
|
self.mockURLSession.addResponse(
|
|
matcher: { request in
|
|
#expect(self.scheduler.currentTime == 0)
|
|
return request.url == expectedAttributesRequest.url
|
|
},
|
|
statusCode: 200
|
|
)
|
|
|
|
// At this point we should have no master key.
|
|
#expect(svr.hasMasterKey == false)
|
|
|
|
var didSetLocalMasterKey = false
|
|
svr.useDeviceLocalMasterKeyMock = { _ in
|
|
#expect(self.svr.hasMasterKey == false)
|
|
didSetLocalMasterKey = true
|
|
}
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 0)
|
|
|
|
#expect(nextStep.value == .done)
|
|
|
|
#expect(didSetLocalMasterKey)
|
|
|
|
// Since we set profile info, we should have scheduled a reupload.
|
|
#expect(profileManagerMock.didScheduleReuploadLocalProfile)
|
|
}
|
|
|
|
@MainActor
|
|
@Test(arguments: Self.testCases)
|
|
func testSessionPath_skipPINRestore_createNewPIN(mode: RegistrationMode) {
|
|
let coordinator = coordinatorFactory(mode)
|
|
switch mode {
|
|
case .registering:
|
|
break
|
|
case .reRegistering, .changingNumber:
|
|
// Test only applies to registering scenarios.
|
|
return
|
|
}
|
|
|
|
createSessionAndRequestFirstCode(coordinator: coordinator, mode: mode)
|
|
|
|
scheduler.tick()
|
|
|
|
var nextStep: Guarantee<RegistrationStep>!
|
|
|
|
// Submit a code at t=5.
|
|
scheduler.run(atTime: 5) {
|
|
nextStep = coordinator.submitVerificationCode(Stubs.pinCode)
|
|
}
|
|
|
|
// At t=7, give back a verified session.
|
|
self.sessionManager.submitCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: true
|
|
)),
|
|
atTime: 7
|
|
)
|
|
|
|
// Previously used SVR so we first ask to restore.
|
|
let accountIdentityResponse = Stubs.accountIdentityResponse(hasPreviouslyUsedSVR: true)
|
|
var authPassword: String!
|
|
|
|
// That means at t=7 it should try and register with the verified
|
|
// session; be ready for that starting at t=6 (but not before).
|
|
|
|
// Before registering at t=7, it should ask for push tokens to give the registration.
|
|
pushRegistrationManagerMock.requestPushTokenMock = {
|
|
#expect(self.scheduler.currentTime == 7)
|
|
return self.scheduler.guarantee(resolvingWith: .success(Stubs.apnsRegistrationId), atTime: 8)
|
|
}
|
|
|
|
// It should also fetch the prekeys for account creation
|
|
preKeyManagerMock.createPreKeysMock = {
|
|
#expect(self.scheduler.currentTime == 8)
|
|
return self.scheduler.promise(resolvingWith: Stubs.prekeyBundles(), atTime: 9)
|
|
}
|
|
|
|
scheduler.run(atTime: 8) {
|
|
let expectedRequest = RegistrationRequestFactory.createAccountRequest(
|
|
verificationMethod: .sessionId(Stubs.sessionId),
|
|
e164: Stubs.e164,
|
|
authPassword: "", // Doesn't matter for request generation.
|
|
accountAttributes: Stubs.accountAttributes(),
|
|
skipDeviceTransfer: true,
|
|
apnRegistrationId: Stubs.apnsRegistrationId,
|
|
prekeyBundles: Stubs.prekeyBundles()
|
|
)
|
|
// Resolve it at t=10
|
|
self.mockURLSession.addResponse(
|
|
TSRequestOWSURLSessionMock.Response(
|
|
matcher: { request in
|
|
authPassword = request.authPassword
|
|
return request.url == expectedRequest.url
|
|
},
|
|
statusCode: 200,
|
|
bodyJson: accountIdentityResponse
|
|
),
|
|
atTime: 10,
|
|
on: self.scheduler
|
|
)
|
|
}
|
|
|
|
func expectedAuthedAccount() -> AuthedAccount {
|
|
return .explicit(
|
|
aci: accountIdentityResponse.aci,
|
|
pni: accountIdentityResponse.pni,
|
|
e164: Stubs.e164,
|
|
deviceId: .primary,
|
|
authPassword: authPassword
|
|
)
|
|
}
|
|
|
|
// Once we are registered at t=10, we should finalize prekeys.
|
|
preKeyManagerMock.finalizePreKeysMock = { didSucceed in
|
|
#expect(self.scheduler.currentTime == 10)
|
|
#expect(didSucceed)
|
|
return self.scheduler.promise(resolvingWith: (), atTime: 11)
|
|
}
|
|
|
|
// Then we should try and create one time pre-keys
|
|
// with the credentials we got in the identity response.
|
|
preKeyManagerMock.rotateOneTimePreKeysMock = { auth in
|
|
#expect(self.scheduler.currentTime == 11)
|
|
#expect(auth == expectedAuthedAccount().chatServiceAuth)
|
|
return self.scheduler.promise(resolvingWith: (), atTime: 12)
|
|
}
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 12)
|
|
|
|
// Now we should ask to restore the PIN.
|
|
#expect(nextStep.value == .pinEntry(
|
|
Stubs.pinEntryStateForPostRegRestore(mode: mode)
|
|
))
|
|
|
|
scheduler.adjustTime(to: 0)
|
|
|
|
// Skip the PIN code and create a new one instead.
|
|
nextStep = coordinator.skipAndCreateNewPINCode()
|
|
|
|
scheduler.runUntilIdle()
|
|
|
|
// When we skip, we should be asked to _create_ the PIN.
|
|
#expect(nextStep.value == .pinEntry(
|
|
Stubs.pinEntryStateForPostRegCreate(mode: mode, exitConfigOverride: .noExitAllowed)
|
|
))
|
|
|
|
// Skip this PIN code, too.
|
|
nextStep = coordinator.skipPINCode()
|
|
|
|
// When we skip the pin, it should skip any SVR backups.
|
|
svr.generateAndBackupKeysMock = { _, _ in
|
|
Issue.record("Shouldn't talk to SVR with skipped PIN!")
|
|
return .value(())
|
|
|
|
}
|
|
storageServiceManagerMock.restoreOrCreateManifestIfNecessaryMock = { _ in
|
|
return .value(())
|
|
}
|
|
|
|
// When registered, we should create pre-keys.
|
|
preKeyManagerMock.rotateOneTimePreKeysMock = { auth in
|
|
#expect(auth == expectedAuthedAccount())
|
|
return .value(())
|
|
}
|
|
|
|
// And at t=0 once we skip the storage service restore,
|
|
// we will sync account attributes and then we are finished!
|
|
let expectedAttributesRequest = RegistrationRequestFactory.updatePrimaryDeviceAccountAttributesRequest(
|
|
Stubs.accountAttributes(),
|
|
auth: .implicit() // doesn't matter for url matching
|
|
)
|
|
self.mockURLSession.addResponse(
|
|
matcher: { request in
|
|
#expect(self.scheduler.currentTime == 0)
|
|
return request.url == expectedAttributesRequest.url
|
|
},
|
|
statusCode: 200
|
|
)
|
|
|
|
// At this point we should have no master key.
|
|
#expect(svr.hasMasterKey.negated)
|
|
|
|
var didSetLocalMasterKey = false
|
|
svr.useDeviceLocalMasterKeyMock = { _ in
|
|
#expect(self.svr.hasMasterKey == false)
|
|
didSetLocalMasterKey = true
|
|
}
|
|
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 0)
|
|
|
|
#expect(nextStep.value == .done)
|
|
|
|
#expect(didSetLocalMasterKey)
|
|
|
|
// Since we set profile info, we should have scheduled a reupload.
|
|
#expect(profileManagerMock.didScheduleReuploadLocalProfile)
|
|
}
|
|
|
|
// MARK: - Profile Setup Path
|
|
|
|
// TODO[Registration]: test the profile setup steps.
|
|
|
|
// MARK: - Persisted State backwards compatibility
|
|
|
|
typealias ReglockState = RegistrationCoordinatorImpl.PersistedState.SessionState.ReglockState
|
|
|
|
@MainActor
|
|
@Test
|
|
func testPersistedState_SVRCredentialCompat() throws {
|
|
let reglockExpirationDate = Date(timeIntervalSince1970: 10000)
|
|
let decoder = JSONDecoder()
|
|
|
|
// Serialized ReglockState.none
|
|
let reglockStateNoneData = "7b226e6f6e65223a7b7d7d"
|
|
#expect(
|
|
try decoder.decode(ReglockState.self, from: Data.data(fromHex: reglockStateNoneData)!) ==
|
|
ReglockState.none
|
|
)
|
|
|
|
// Serialized ReglockState.reglocked(
|
|
// credential: KBSAuthCredential(credential: RemoteAttestation.Auth(username: "abcd", password: "xyz"),
|
|
// expirationDate: reglockExpirationDate
|
|
// )
|
|
let reglockStateReglockedData = "7b227265676c6f636b6564223a7b2265787069726174696f6e44617465223a2d3937383239373230302c2263726564656e7469616c223a7b2263726564656e7469616c223a7b22757365726e616d65223a2261626364222c2270617373776f7264223a2278797a227d7d7d7d"
|
|
#expect(
|
|
try decoder.decode(ReglockState.self, from: Data.data(fromHex: reglockStateReglockedData)!) ==
|
|
ReglockState.reglocked(credential: .testOnly(svr2: nil), expirationDate: reglockExpirationDate)
|
|
)
|
|
|
|
// Serialized ReglockState.reglocked(
|
|
// credential: ReglockState.SVRAuthCredential(
|
|
// kbs: KBSAuthCredential(credential: RemoteAttestation.Auth(username: "abcd", password: "xyz"),
|
|
// svr2: SVR2AuthCredential(credential: RemoteAttestation.Auth(username: "xxx", password: "yyy"))
|
|
// ),
|
|
// expirationDate: reglockExpirationDate
|
|
// )
|
|
let reglockStateReglockedSVR2Data = "7b227265676c6f636b6564223a7b2265787069726174696f6e44617465223a2d3937383239373230302c2263726564656e7469616c223a7b226b6273223a7b2263726564656e7469616c223a7b22757365726e616d65223a2261626364222c2270617373776f7264223a2278797a227d7d2c2273767232223a7b2263726564656e7469616c223a7b22757365726e616d65223a22787878222c2270617373776f7264223a22797979227d7d7d7d7d"
|
|
#expect(
|
|
try decoder.decode(ReglockState.self, from: Data.data(fromHex: reglockStateReglockedSVR2Data)!) ==
|
|
ReglockState.reglocked(credential: .init(svr2: Stubs.svr2AuthCredential), expirationDate: reglockExpirationDate)
|
|
)
|
|
|
|
// Serialized ReglockState.waitingTimeout(expirationDate: reglockExpirationDate)
|
|
let reglockStateWaitingTimeoutData = "7b2277616974696e6754696d656f7574223a7b2265787069726174696f6e44617465223a2d3937383239373230307d7d"
|
|
#expect(
|
|
try decoder.decode(ReglockState.self, from: Data.data(fromHex: reglockStateWaitingTimeoutData)!) ==
|
|
ReglockState.waitingTimeout(expirationDate: reglockExpirationDate)
|
|
)
|
|
}
|
|
|
|
// MARK: Happy Path Setups
|
|
|
|
private func preservingSchedulerState(_ block: () -> Void) {
|
|
let startTime = scheduler.currentTime
|
|
let wasRunning = scheduler.isRunning
|
|
scheduler.stop()
|
|
scheduler.adjustTime(to: 0)
|
|
block()
|
|
scheduler.adjustTime(to: startTime)
|
|
if wasRunning {
|
|
scheduler.start()
|
|
}
|
|
}
|
|
|
|
private func goThroughOpeningHappyPath(
|
|
coordinator: any RegistrationCoordinator,
|
|
mode: RegistrationMode,
|
|
expectedNextStep: RegistrationStep
|
|
) {
|
|
preservingSchedulerState {
|
|
contactsStore.doesNeedContactsAuthorization = true
|
|
pushRegistrationManagerMock.doesNeedNotificationAuthorization = true
|
|
|
|
var nextStep: Guarantee<RegistrationStep>!
|
|
switch mode {
|
|
case .registering:
|
|
// Gotta get the splash out of the way.
|
|
nextStep = coordinator.nextStep()
|
|
scheduler.runUntilIdle()
|
|
#expect(nextStep.value == .registrationSplash)
|
|
case .reRegistering, .changingNumber:
|
|
break
|
|
}
|
|
|
|
// Now we should show the permissions.
|
|
nextStep = coordinator.continueFromSplash()
|
|
scheduler.runUntilIdle()
|
|
#expect(nextStep.value == .permissions)
|
|
|
|
// Once the state is updated we can proceed.
|
|
nextStep = coordinator.requestPermissions()
|
|
scheduler.runUntilIdle()
|
|
#expect(nextStep.value == expectedNextStep)
|
|
}
|
|
}
|
|
|
|
private func setUpSessionPath(coordinator: any RegistrationCoordinator, mode: RegistrationMode) {
|
|
// Set profile info so we skip those steps.
|
|
self.setupDefaultAccountAttributes()
|
|
|
|
pushRegistrationManagerMock.requestPushTokenMock = { .value(.success(Stubs.apnsRegistrationId)) }
|
|
|
|
pushRegistrationManagerMock.receivePreAuthChallengeTokenMock = { .pending().0 }
|
|
|
|
// No other setup; no auth credentials, SVR keys, etc in storage
|
|
// so that we immediately go to the session flow.
|
|
|
|
// Get past the opening.
|
|
goThroughOpeningHappyPath(
|
|
coordinator: coordinator,
|
|
mode: mode,
|
|
expectedNextStep: .phoneNumberEntry(self.stubs.phoneNumberEntryState(mode: mode))
|
|
)
|
|
}
|
|
|
|
private func createSessionAndRequestFirstCode(coordinator: any RegistrationCoordinator, mode: RegistrationMode) {
|
|
setUpSessionPath(coordinator: coordinator, mode: mode)
|
|
|
|
preservingSchedulerState {
|
|
// Give it a phone number, which should cause it to start a session.
|
|
let nextStep = coordinator.submitE164(Stubs.e164)
|
|
|
|
// We'll ask for a push challenge, though we won't resolve it.
|
|
self.pushRegistrationManagerMock.receivePreAuthChallengeTokenMock = {
|
|
return Guarantee<String>.pending().0
|
|
}
|
|
|
|
// At t=2, give back a session that's ready to go.
|
|
self.sessionManager.beginSessionResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: nil,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 2
|
|
)
|
|
|
|
// Once we get that session at t=2, we should try and send a code.
|
|
// Be ready for that starting at t=1 (but not before).
|
|
scheduler.run(atTime: 1) {
|
|
// Resolve with a session thats ready for code submission at time 3.
|
|
self.sessionManager.requestCodeResponse = self.scheduler.guarantee(
|
|
resolvingWith: .success(RegistrationSession(
|
|
id: Stubs.sessionId,
|
|
e164: Stubs.e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: 0,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)),
|
|
atTime: 3
|
|
)
|
|
}
|
|
|
|
// At t=3 we should get back the code entry step.
|
|
scheduler.runUntilIdle()
|
|
#expect(scheduler.currentTime == 3)
|
|
#expect(nextStep.value == .verificationCodeEntry(self.stubs.verificationCodeEntryState(mode: mode)))
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func setupDefaultAccountAttributes() {
|
|
ows2FAManagerMock.pinCodeMock = { nil }
|
|
ows2FAManagerMock.isReglockEnabledMock = { false }
|
|
|
|
tsAccountManagerMock.isManualMessageFetchEnabledMock = { false }
|
|
|
|
setAllProfileInfo()
|
|
}
|
|
|
|
private func setAllProfileInfo() {
|
|
phoneNumberDiscoverabilityManagerMock.phoneNumberDiscoverabilityMock = { .everybody }
|
|
profileManagerMock.hasProfileNameMock = { true }
|
|
}
|
|
|
|
private static func attributesFromCreateAccountRequest(
|
|
_ request: TSRequest
|
|
) -> AccountAttributes {
|
|
let accountAttributesData = try! JSONSerialization.data(
|
|
withJSONObject: request.parameters["accountAttributes"]!,
|
|
options: .fragmentsAllowed
|
|
)
|
|
return try! JSONDecoder().decode(
|
|
AccountAttributes.self,
|
|
from: accountAttributesData
|
|
)
|
|
}
|
|
|
|
// MARK: - Stubs
|
|
|
|
private struct Stubs {
|
|
|
|
static let e164 = E164("+17875550100")!
|
|
static let aci = Aci.randomForTesting()
|
|
static let pinCode = "1234"
|
|
|
|
static let regRecoveryPwData = Data(repeating: 8, count: 8)
|
|
static var regRecoveryPw: String { regRecoveryPwData.base64EncodedString() }
|
|
|
|
static let reglockData = Data(repeating: 7, count: 8)
|
|
static var reglockToken: String { reglockData.hexadecimalString }
|
|
|
|
static let svr2AuthCredential = SVR2AuthCredential(credential: RemoteAttestation.Auth(username: "xxx", password: "yyy"))
|
|
|
|
static let captchaToken = "captchaToken"
|
|
static let apnsToken = "apnsToken"
|
|
static let apnsRegistrationId = RegistrationRequestFactory.ApnRegistrationId(apnsToken: Stubs.apnsToken)
|
|
|
|
static let authUsername = "username_jdhfsalkjfhd"
|
|
static let authPassword = "password_dskafjasldkfjasf"
|
|
|
|
static let sessionId = UUID().uuidString
|
|
static let verificationCode = "8888"
|
|
|
|
var date: Date = Date()
|
|
|
|
static func accountAttributes() -> AccountAttributes {
|
|
return AccountAttributes(
|
|
isManualMessageFetchEnabled: false,
|
|
registrationId: 0,
|
|
pniRegistrationId: 0,
|
|
unidentifiedAccessKey: "",
|
|
unrestrictedUnidentifiedAccess: false,
|
|
twofaMode: .none,
|
|
registrationRecoveryPassword: nil,
|
|
encryptedDeviceName: nil,
|
|
discoverableByPhoneNumber: .nobody,
|
|
hasSVRBackups: true
|
|
)
|
|
}
|
|
|
|
static func accountIdentityResponse(
|
|
hasPreviouslyUsedSVR: Bool = false
|
|
) -> RegistrationServiceResponses.AccountIdentityResponse {
|
|
return RegistrationServiceResponses.AccountIdentityResponse(
|
|
aci: Stubs.aci,
|
|
pni: Pni.randomForTesting(),
|
|
e164: Stubs.e164,
|
|
username: nil,
|
|
hasPreviouslyUsedSVR: hasPreviouslyUsedSVR
|
|
)
|
|
}
|
|
|
|
func session(
|
|
e164: E164 = Stubs.e164,
|
|
hasSentVerificationCode: Bool
|
|
) -> RegistrationSession {
|
|
return RegistrationSession(
|
|
id: UUID().uuidString,
|
|
e164: e164,
|
|
receivedDate: self.date,
|
|
nextSMS: 0,
|
|
nextCall: 0,
|
|
nextVerificationAttempt: hasSentVerificationCode ? 0 : nil,
|
|
allowedToRequestCode: true,
|
|
requestedInformation: [],
|
|
hasUnknownChallengeRequiringAppUpdate: false,
|
|
verified: false
|
|
)
|
|
}
|
|
|
|
static func prekeyBundles() -> RegistrationPreKeyUploadBundles {
|
|
return RegistrationPreKeyUploadBundles(
|
|
aci: preKeyBundle(identity: .aci),
|
|
pni: preKeyBundle(identity: .pni)
|
|
)
|
|
}
|
|
|
|
static func preKeyBundle(identity: OWSIdentity) -> RegistrationPreKeyUploadBundle {
|
|
let identityKeyPair = ECKeyPair.generateKeyPair()
|
|
return RegistrationPreKeyUploadBundle(
|
|
identity: identity,
|
|
identityKeyPair: identityKeyPair,
|
|
signedPreKey: SSKSignedPreKeyStore.generateSignedPreKey(signedBy: identityKeyPair),
|
|
lastResortPreKey: {
|
|
let keyPair = KEMKeyPair.generate()
|
|
let signature = Data(identityKeyPair.keyPair.privateKey.generateSignature(message: Data(keyPair.publicKey.serialize())))
|
|
|
|
let record = SignalServiceKit.KyberPreKeyRecord(
|
|
0,
|
|
keyPair: keyPair,
|
|
signature: signature,
|
|
generatedAt: Date(),
|
|
isLastResort: true
|
|
)
|
|
return record
|
|
}()
|
|
)
|
|
}
|
|
|
|
// MARK: Step States
|
|
|
|
static func pinEntryStateForRegRecoveryPath(
|
|
mode: RegistrationMode,
|
|
error: RegistrationPinValidationError? = nil,
|
|
remainingAttempts: UInt? = nil
|
|
) -> RegistrationPinState {
|
|
return RegistrationPinState(
|
|
operation: .enteringExistingPin(
|
|
skippability: .canSkip,
|
|
remainingAttempts: remainingAttempts
|
|
),
|
|
error: error,
|
|
contactSupportMode: .v2WithUnknownReglockState,
|
|
exitConfiguration: mode.pinExitConfig
|
|
)
|
|
}
|
|
|
|
static func pinEntryStateForSVRAuthCredentialPath(
|
|
mode: RegistrationMode,
|
|
error: RegistrationPinValidationError? = nil
|
|
) -> RegistrationPinState {
|
|
return RegistrationPinState(
|
|
operation: .enteringExistingPin(skippability: .canSkip, remainingAttempts: nil),
|
|
error: error,
|
|
contactSupportMode: .v2WithUnknownReglockState,
|
|
exitConfiguration: mode.pinExitConfig
|
|
)
|
|
}
|
|
|
|
func phoneNumberEntryState(
|
|
mode: RegistrationMode,
|
|
previouslyEnteredE164: E164? = nil,
|
|
withValidationErrorFor response: Registration.BeginSessionResponse? = nil
|
|
) -> RegistrationPhoneNumberViewState {
|
|
let response = response ?? .success(self.session(hasSentVerificationCode: false))
|
|
let validationError: RegistrationPhoneNumberViewState.ValidationError?
|
|
switch response {
|
|
case .success:
|
|
validationError = nil
|
|
case .invalidArgument:
|
|
validationError = .invalidE164(.init(invalidE164: previouslyEnteredE164 ?? Stubs.e164))
|
|
case .retryAfter(let timeInterval):
|
|
validationError = .rateLimited(.init(
|
|
expiration: self.date.addingTimeInterval(timeInterval),
|
|
e164: previouslyEnteredE164 ?? Stubs.e164
|
|
))
|
|
case .networkFailure, .genericError:
|
|
Issue.record("Should not be generating phone number state for error responses.")
|
|
validationError = nil
|
|
}
|
|
|
|
switch mode {
|
|
case .registering:
|
|
return .registration(.initialRegistration(.init(
|
|
previouslyEnteredE164: previouslyEnteredE164,
|
|
validationError: validationError,
|
|
canExitRegistration: true
|
|
)))
|
|
case .reRegistering(let params):
|
|
return .registration(.reregistration(.init(
|
|
e164: params.e164,
|
|
validationError: validationError,
|
|
canExitRegistration: true
|
|
)))
|
|
case .changingNumber(let changeNumberParams):
|
|
switch validationError {
|
|
case .none:
|
|
if let newE164 = previouslyEnteredE164 {
|
|
return .changingNumber(.confirmation(.init(
|
|
oldE164: changeNumberParams.oldE164,
|
|
newE164: newE164,
|
|
rateLimitedError: nil
|
|
)))
|
|
} else {
|
|
return .changingNumber(.initialEntry(.init(
|
|
oldE164: changeNumberParams.oldE164,
|
|
newE164: nil,
|
|
hasConfirmed: false,
|
|
invalidE164Error: nil
|
|
)))
|
|
}
|
|
case .rateLimited(let error):
|
|
return .changingNumber(.confirmation(.init(
|
|
oldE164: changeNumberParams.oldE164,
|
|
newE164: previouslyEnteredE164!,
|
|
rateLimitedError: error
|
|
)))
|
|
case .invalidInput:
|
|
owsFail("Can't happen.")
|
|
case .invalidE164(let error):
|
|
return .changingNumber(.initialEntry(.init(
|
|
oldE164: changeNumberParams.oldE164,
|
|
newE164: previouslyEnteredE164,
|
|
hasConfirmed: previouslyEnteredE164 != nil,
|
|
invalidE164Error: error
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
|
|
func verificationCodeEntryState(
|
|
mode: RegistrationMode,
|
|
e164: E164 = Stubs.e164,
|
|
nextSMS: TimeInterval? = 0,
|
|
nextCall: TimeInterval? = 0,
|
|
showHelpText: Bool = false,
|
|
nextVerificationAttempt: TimeInterval? = 0,
|
|
validationError: RegistrationVerificationValidationError? = nil,
|
|
exitConfigOverride: RegistrationVerificationState.ExitConfiguration? = nil
|
|
) -> RegistrationVerificationState {
|
|
|
|
let canChangeE164: Bool
|
|
switch mode {
|
|
case .reRegistering:
|
|
canChangeE164 = false
|
|
case .registering, .changingNumber:
|
|
canChangeE164 = true
|
|
}
|
|
|
|
return RegistrationVerificationState(
|
|
e164: e164,
|
|
nextSMSDate: nextSMS.map { date.addingTimeInterval($0) },
|
|
nextCallDate: nextCall.map { date.addingTimeInterval($0) },
|
|
nextVerificationAttemptDate: nextVerificationAttempt.map { date.addingTimeInterval($0) },
|
|
canChangeE164: canChangeE164,
|
|
showHelpText: showHelpText,
|
|
validationError: validationError,
|
|
exitConfiguration: exitConfigOverride ?? mode.verificationExitConfig
|
|
)
|
|
}
|
|
|
|
static func pinEntryStateForSessionPathReglock(
|
|
mode: RegistrationMode,
|
|
error: RegistrationPinValidationError? = nil
|
|
) -> RegistrationPinState {
|
|
return RegistrationPinState(
|
|
operation: .enteringExistingPin(skippability: .unskippable, remainingAttempts: nil),
|
|
error: error,
|
|
contactSupportMode: .v2WithReglock,
|
|
exitConfiguration: mode.pinExitConfig
|
|
)
|
|
}
|
|
|
|
static func pinEntryStateForPostRegRestore(
|
|
mode: RegistrationMode,
|
|
exitConfigOverride: RegistrationPinState.ExitConfiguration? = nil,
|
|
error: RegistrationPinValidationError? = nil
|
|
) -> RegistrationPinState {
|
|
return RegistrationPinState(
|
|
operation: .enteringExistingPin(
|
|
skippability: .canSkipAndCreateNew,
|
|
remainingAttempts: nil
|
|
),
|
|
error: error,
|
|
contactSupportMode: .v2NoReglock,
|
|
exitConfiguration: exitConfigOverride ?? mode.pinExitConfig
|
|
)
|
|
}
|
|
|
|
static func pinEntryStateForPostRegCreate(
|
|
mode: RegistrationMode,
|
|
exitConfigOverride: RegistrationPinState.ExitConfiguration? = nil
|
|
) -> RegistrationPinState {
|
|
return RegistrationPinState(
|
|
operation: .creatingNewPin,
|
|
error: nil,
|
|
contactSupportMode: .v2NoReglock,
|
|
exitConfiguration: exitConfigOverride ?? mode.pinExitConfig
|
|
)
|
|
}
|
|
|
|
static func pinEntryStateForPostRegConfirm(
|
|
mode: RegistrationMode,
|
|
error: RegistrationPinValidationError? = nil,
|
|
exitConfigOverride: RegistrationPinState.ExitConfiguration? = nil
|
|
) -> RegistrationPinState {
|
|
return RegistrationPinState(
|
|
operation: .confirmingNewPin(.stub()),
|
|
error: error,
|
|
contactSupportMode: .v2NoReglock,
|
|
exitConfiguration: exitConfigOverride ?? mode.pinExitConfig
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension RegistrationMode {
|
|
|
|
var testDescription: String {
|
|
switch self {
|
|
case .registering:
|
|
return "registering"
|
|
case .reRegistering:
|
|
return "re-registering"
|
|
case .changingNumber:
|
|
return "changing number"
|
|
}
|
|
}
|
|
|
|
var pinExitConfig: RegistrationPinState.ExitConfiguration {
|
|
switch self {
|
|
case .registering:
|
|
return .noExitAllowed
|
|
case .reRegistering:
|
|
return .exitReRegistration
|
|
case .changingNumber:
|
|
// TODO[Registration]: test change number properly
|
|
return .exitChangeNumber
|
|
}
|
|
}
|
|
|
|
var verificationExitConfig: RegistrationVerificationState.ExitConfiguration {
|
|
switch self {
|
|
case .registering:
|
|
return .noExitAllowed
|
|
case .reRegistering:
|
|
return .exitReRegistration
|
|
case .changingNumber:
|
|
// TODO[Registration]: test change number properly
|
|
return .exitChangeNumber
|
|
}
|
|
}
|
|
}
|
|
|
|
private class PreKeyError: Error {
|
|
init() {}
|
|
}
|
|
|
|
struct EncodableRegistrationLockFailureResponse: Codable {
|
|
typealias ResponseType = RegistrationServiceResponses.RegistrationLockFailureResponse
|
|
typealias CodingKeys = ResponseType.CodingKeys
|
|
|
|
var response: ResponseType
|
|
|
|
init(from decoder: any Decoder) throws {
|
|
response = try ResponseType(from: decoder)
|
|
}
|
|
|
|
init(timeRemainingMs: Int, svr2AuthCredential: SVR2AuthCredential) {
|
|
self.response = ResponseType(timeRemainingMs: timeRemainingMs, svr2AuthCredential: svr2AuthCredential)
|
|
}
|
|
|
|
func encode(to encoder: any Encoder) throws {
|
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
try container.encode(response.timeRemainingMs, forKey: .timeRemainingMs)
|
|
try container.encodeIfPresent(response.svr2AuthCredential.credential, forKey: .svr2AuthCredential)
|
|
}
|
|
}
|
|
|
|
private extension Usernames.UsernameLink {
|
|
static var mocked: Usernames.UsernameLink {
|
|
return Usernames.UsernameLink(
|
|
handle: UUID(),
|
|
entropy: Data(repeating: 8, count: 32)
|
|
)!
|
|
}
|
|
}
|