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