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