1import nock from 'nock'; 2 3import * as Log from '../../../log'; 4import { promptAsync, selectAsync } from '../../../utils/prompts'; 5import { getExpoApiBaseUrl } from '../../endpoint'; 6import { retryUsernamePasswordAuthWithOTPAsync, UserSecondFactorDeviceMethod } from '../otp'; 7import { loginAsync } from '../user'; 8 9jest.mock('../../../utils/prompts'); 10jest.mock('../user'); 11jest.mock('../../../log'); 12 13const asMock = (fn: any): jest.Mock => fn; 14 15beforeEach(() => { 16 asMock(promptAsync).mockClear(); 17 asMock(promptAsync).mockImplementation(() => { 18 throw new Error('Should not be called'); 19 }); 20 21 asMock(selectAsync).mockClear(); 22 asMock(selectAsync).mockImplementation(() => { 23 throw new Error('Should not be called'); 24 }); 25 26 asMock(loginAsync).mockClear(); 27 asMock(Log.log).mockClear(); 28}); 29 30describe(retryUsernamePasswordAuthWithOTPAsync, () => { 31 it('shows SMS OTP prompt when SMS is primary and code was automatically sent', async () => { 32 asMock(promptAsync) 33 .mockImplementationOnce(() => ({ otp: 'hello' })) 34 .mockImplementation(() => { 35 throw new Error("shouldn't happen"); 36 }); 37 38 await retryUsernamePasswordAuthWithOTPAsync('blah', 'blah', { 39 secondFactorDevices: [ 40 { 41 id: 'p0', 42 is_primary: true, 43 method: UserSecondFactorDeviceMethod.SMS, 44 sms_phone_number: 'testphone', 45 }, 46 ], 47 smsAutomaticallySent: true, 48 }); 49 50 expect(Log.log).toHaveBeenCalledWith( 51 'One-time password was sent to the phone number ending in testphone.' 52 ); 53 54 expect(loginAsync).toHaveBeenCalledTimes(1); 55 }); 56 57 it('shows authenticator OTP prompt when authenticator is primary', async () => { 58 asMock(promptAsync) 59 .mockImplementationOnce(() => ({ otp: 'hello' })) 60 .mockImplementation(() => { 61 throw new Error("shouldn't happen"); 62 }); 63 64 await retryUsernamePasswordAuthWithOTPAsync('blah', 'blah', { 65 secondFactorDevices: [ 66 { 67 id: 'p0', 68 is_primary: true, 69 method: UserSecondFactorDeviceMethod.AUTHENTICATOR, 70 sms_phone_number: null, 71 }, 72 ], 73 smsAutomaticallySent: false, 74 }); 75 76 expect(Log.log).toHaveBeenCalledWith('One-time password from authenticator required.'); 77 expect(loginAsync).toHaveBeenCalledTimes(1); 78 }); 79 80 it('shows menu when user bails on primary', async () => { 81 asMock(promptAsync) 82 .mockImplementationOnce(() => ({ otp: null })) 83 .mockImplementationOnce(() => ({ otp: 'hello' })) // second time it is prompted after selecting backup code 84 .mockImplementation(() => { 85 throw new Error("shouldn't happen"); 86 }); 87 88 asMock(selectAsync) 89 .mockImplementationOnce(() => -1) 90 .mockImplementation(() => { 91 throw new Error("shouldn't happen"); 92 }); 93 94 await retryUsernamePasswordAuthWithOTPAsync('blah', 'blah', { 95 secondFactorDevices: [ 96 { 97 id: 'p0', 98 is_primary: true, 99 method: UserSecondFactorDeviceMethod.AUTHENTICATOR, 100 sms_phone_number: null, 101 }, 102 { 103 id: 'p2', 104 is_primary: false, 105 method: UserSecondFactorDeviceMethod.AUTHENTICATOR, 106 sms_phone_number: null, 107 }, 108 ], 109 smsAutomaticallySent: false, 110 }); 111 112 expect(selectAsync).toHaveBeenCalledTimes(1); 113 expect(loginAsync).toHaveBeenCalledTimes(1); 114 }); 115 116 it('shows a warning when when user bails on primary and does not have any secondary set up', async () => { 117 asMock(promptAsync) 118 .mockImplementationOnce(() => ({ otp: null })) 119 .mockImplementation(() => { 120 throw new Error("shouldn't happen"); 121 }); 122 123 await expect( 124 retryUsernamePasswordAuthWithOTPAsync('blah', 'blah', { 125 secondFactorDevices: [ 126 { 127 id: 'p0', 128 is_primary: true, 129 method: UserSecondFactorDeviceMethod.AUTHENTICATOR, 130 sms_phone_number: null, 131 }, 132 ], 133 smsAutomaticallySent: false, 134 }) 135 ).rejects.toThrowError( 136 'No other second-factor devices set up. Ensure you have set up and certified a backup device.' 137 ); 138 }); 139 140 it('prompts for authenticator OTP when user selects authenticator secondary', async () => { 141 asMock(promptAsync) 142 .mockImplementationOnce(() => ({ otp: null })) 143 .mockImplementationOnce(() => ({ otp: 'hello' })) // second time it is prompted after selecting backup code 144 .mockImplementation(() => { 145 throw new Error("shouldn't happen"); 146 }); 147 148 asMock(selectAsync) 149 .mockImplementationOnce(() => -1) 150 .mockImplementation(() => { 151 throw new Error("shouldn't happen"); 152 }); 153 154 await retryUsernamePasswordAuthWithOTPAsync('blah', 'blah', { 155 secondFactorDevices: [ 156 { 157 id: 'p0', 158 is_primary: true, 159 method: UserSecondFactorDeviceMethod.AUTHENTICATOR, 160 sms_phone_number: null, 161 }, 162 { 163 id: 'p2', 164 is_primary: false, 165 method: UserSecondFactorDeviceMethod.AUTHENTICATOR, 166 sms_phone_number: null, 167 }, 168 ], 169 smsAutomaticallySent: false, 170 }); 171 172 expect(promptAsync).toHaveBeenCalledTimes(2); // first OTP, second OTP 173 }); 174 175 it('requests SMS OTP and prompts for SMS OTP when user selects SMS secondary', async () => { 176 asMock(promptAsync) 177 .mockImplementationOnce(() => ({ otp: null })) 178 .mockImplementationOnce(() => ({ otp: 'hello' })) // second time it is prompted after selecting backup code 179 .mockImplementation(() => { 180 throw new Error("shouldn't happen"); 181 }); 182 183 asMock(selectAsync) 184 .mockImplementationOnce(() => 0) 185 .mockImplementation(() => { 186 throw new Error("shouldn't happen"); 187 }); 188 189 const scope = nock(getExpoApiBaseUrl()) 190 .post('/v2/auth/send-sms-otp', { 191 username: 'blah', 192 password: 'blah', 193 secondFactorDeviceID: 'p2', 194 }) 195 .reply(200, {}); 196 197 await retryUsernamePasswordAuthWithOTPAsync('blah', 'blah', { 198 secondFactorDevices: [ 199 { 200 id: 'p0', 201 is_primary: true, 202 method: UserSecondFactorDeviceMethod.AUTHENTICATOR, 203 sms_phone_number: null, 204 }, 205 { 206 id: 'p2', 207 is_primary: false, 208 method: UserSecondFactorDeviceMethod.SMS, 209 sms_phone_number: 'wat', 210 }, 211 ], 212 smsAutomaticallySent: false, 213 }); 214 215 expect(promptAsync).toHaveBeenCalledTimes(2); // first OTP, second OTP 216 expect(scope.isDone()).toBe(true); 217 }); 218 219 it('exits when user bails on primary and backup', async () => { 220 asMock(promptAsync) 221 .mockImplementationOnce(() => ({ otp: null })) 222 .mockImplementation(() => { 223 throw new Error("shouldn't happen"); 224 }); 225 226 asMock(selectAsync) 227 .mockImplementationOnce(() => -2) 228 .mockImplementation(() => { 229 throw new Error("shouldn't happen"); 230 }); 231 232 await expect( 233 retryUsernamePasswordAuthWithOTPAsync('blah', 'blah', { 234 secondFactorDevices: [ 235 { 236 id: 'p0', 237 is_primary: true, 238 method: UserSecondFactorDeviceMethod.AUTHENTICATOR, 239 sms_phone_number: null, 240 }, 241 { 242 id: 'p2', 243 is_primary: false, 244 method: UserSecondFactorDeviceMethod.AUTHENTICATOR, 245 sms_phone_number: null, 246 }, 247 ], 248 smsAutomaticallySent: false, 249 }) 250 ).rejects.toThrowError('Interactive prompt was cancelled.'); 251 }); 252}); 253