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