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