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