1 import { AuthMode } from '@proton/pass/types';
2 import { RETRY_ATTEMPTS_MAX } from '@proton/shared/lib/constants';
3 import { HTTP_ERROR_CODES } from '@proton/shared/lib/errors';
4 import * as time from '@proton/shared/lib/helpers/promise';
6 import { refreshHandlerFactory } from './refresh';
7 import { TEST_SERVER_TIME, mockAPIResponse } from './testing';
9 const { TOO_MANY_REQUESTS } = HTTP_ERROR_CODES;
11 describe('Refresh handlers', () => {
12 jest.useFakeTimers().setSystemTime(TEST_SERVER_TIME);
14 const call = jest.fn();
15 const onRefresh = jest.fn();
16 const getAuth = jest.fn();
17 const wait = jest.spyOn(time, 'wait').mockImplementation(() => Promise.resolve());
19 const getMockResponse = (date: Date = new Date()) =>
20 ({ headers: { get: () => date.toString() } }) as unknown as Response;
24 onRefresh.mockClear();
29 test('should throw InactiveSession error if no auth', async () => {
30 const refresh = refreshHandlerFactory({ call, getAuth, onRefresh });
31 await expect(refresh(getMockResponse(), {})).rejects.toThrow('Inactive session');
34 test('should call refresh in `AuthMode.TOKEN`', async () => {
35 getAuth.mockReturnValue({
37 AccessToken: 'access-000',
39 RefreshToken: 'refresh-000',
42 call.mockResolvedValue(
43 mockAPIResponse({ UID: 'id-000', AccessToken: 'access-001', RefreshToken: 'refresh-001' })
45 const refresh = refreshHandlerFactory({ call, getAuth, onRefresh });
46 await refresh(getMockResponse(TEST_SERVER_TIME), {});
48 expect(call).toHaveBeenCalledTimes(1);
49 const [args] = call.mock.calls[0];
51 expect(args.data).toEqual({
52 GrantType: 'refresh_token',
53 RedirectURI: 'https://protonmail.com',
54 RefreshToken: 'refresh-000',
55 ResponseType: 'token',
58 expect(args.headers['x-pm-uid']).toEqual('id-000');
60 expect(onRefresh).toHaveBeenCalledTimes(1);
61 expect(onRefresh).toHaveBeenCalledWith({
63 AccessToken: 'access-001',
64 RefreshToken: 'refresh-001',
65 RefreshTime: +TEST_SERVER_TIME,
69 test('should call refresh in `AuthMode.COOKIE`', async () => {
70 getAuth.mockReturnValue({ type: AuthMode.COOKIE, UID: 'id-000' });
72 call.mockResolvedValue(mockAPIResponse({ UID: 'id-000' }));
73 const refresh = refreshHandlerFactory({ call, getAuth, onRefresh });
74 await refresh(getMockResponse(TEST_SERVER_TIME), {});
76 expect(call).toHaveBeenCalledTimes(1);
77 const [args] = call.mock.calls[0];
79 expect(args.data).toBeUndefined();
80 expect(args.headers['x-pm-uid']).toEqual('id-000');
82 expect(onRefresh).toHaveBeenCalledTimes(1);
83 expect(onRefresh).toHaveBeenCalledWith({
87 RefreshTime: +TEST_SERVER_TIME,
92 test('should use underlying api call `auth` option if provided', async () => {
93 getAuth.mockReturnValue({ type: AuthMode.COOKIE, UID: 'id-000' });
94 const auth = { type: AuthMode.COOKIE, UID: 'id-001' } as const;
96 call.mockResolvedValue(mockAPIResponse({ UID: 'id-001' }));
97 const refresh = refreshHandlerFactory({ call, getAuth, onRefresh });
98 await refresh(getMockResponse(TEST_SERVER_TIME), { auth });
100 expect(call).toHaveBeenCalledTimes(1);
101 const [args] = call.mock.calls[0];
103 expect(args.data).toBeUndefined();
104 expect(args.headers['x-pm-uid']).toEqual('id-001');
106 expect(onRefresh).toHaveBeenCalledTimes(1);
107 expect(onRefresh).toHaveBeenCalledWith({
111 RefreshTime: +TEST_SERVER_TIME,
116 test('should call refresh only once concurrently', async () => {
117 getAuth.mockReturnValue({
118 type: AuthMode.TOKEN,
119 AccessToken: 'access-000',
121 RefreshToken: 'refresh-000',
124 call.mockResolvedValue(mockAPIResponse({ RefreshToken: 'refresh-001' }));
125 const refresh = refreshHandlerFactory({ call, getAuth, onRefresh });
126 const res = getMockResponse(TEST_SERVER_TIME);
128 await Promise.all([refresh(res, {}), refresh(res, {}), refresh(res, {})]);
129 expect(call).toHaveBeenCalledTimes(1);
130 expect(onRefresh).toHaveBeenCalledTimes(1);
132 await refresh(res, {});
133 expect(call).toHaveBeenCalledTimes(2);
134 expect(onRefresh).toHaveBeenCalledTimes(2);
137 test('should not refresh if last refresh time is greater than request to refresh', async () => {
138 getAuth.mockReturnValue({
139 type: AuthMode.TOKEN,
140 AccessToken: 'access-000',
142 RefreshToken: 'refresh-000',
143 RefreshTime: +TEST_SERVER_TIME + 10,
146 call.mockResolvedValue(mockAPIResponse({ RefreshToken: 'refresh-001' }));
147 const refresh = refreshHandlerFactory({ call, getAuth, onRefresh });
148 await refresh(getMockResponse(TEST_SERVER_TIME), {});
150 expect(call).not.toHaveBeenCalled();
151 expect(onRefresh).not.toHaveBeenCalled();
154 test('should not retry if timeout error', async () => {
155 const timeoutError = new Error();
156 timeoutError.name = 'TimeoutError';
157 getAuth.mockReturnValue({
158 type: AuthMode.TOKEN,
159 AccessToken: 'access-000',
161 RefreshToken: 'refresh-000',
163 call.mockRejectedValueOnce(timeoutError);
165 const refresh = refreshHandlerFactory({ call, getAuth, onRefresh });
166 await expect(refresh(getMockResponse(), {})).rejects.toThrow();
168 expect(call).toHaveBeenCalledTimes(1);
169 expect(onRefresh).not.toHaveBeenCalled();
172 test('should not retry if offline error', async () => {
173 const offlineError = new Error();
174 offlineError.name = 'OfflineError';
175 getAuth.mockReturnValue({
176 type: AuthMode.TOKEN,
177 AccessToken: 'access-000',
179 RefreshToken: 'refresh-000',
181 call.mockRejectedValueOnce(offlineError);
183 const refresh = refreshHandlerFactory({ call, getAuth, onRefresh });
184 await expect(refresh(getMockResponse(), {})).rejects.toThrow();
186 expect(call).toHaveBeenCalledTimes(1);
187 expect(onRefresh).not.toHaveBeenCalled();
190 test('should retry if `retry-after` header present', async () => {
191 getAuth.mockReturnValue({
192 type: AuthMode.TOKEN,
193 AccessToken: 'access-000',
195 RefreshToken: 'refresh-000',
198 call.mockRejectedValueOnce(mockAPIResponse({}, TOO_MANY_REQUESTS, { 'retry-after': '10' }));
199 call.mockResolvedValueOnce(mockAPIResponse({ RefreshToken: 'refresh-001' }));
201 const refresh = refreshHandlerFactory({ call, getAuth, onRefresh });
202 await refresh(getMockResponse(), {});
204 expect(call).toHaveBeenCalledTimes(2);
205 expect(onRefresh).toHaveBeenCalledTimes(1);
206 expect(onRefresh).toHaveBeenCalledWith({ RefreshToken: 'refresh-001', RefreshTime: +TEST_SERVER_TIME });
209 test('should stop retrying after `maxAttempts` is reached', async () => {
210 getAuth.mockReturnValue({
211 type: AuthMode.TOKEN,
212 AccessToken: 'access-000',
214 RefreshToken: 'refresh-000',
217 call.mockRejectedValue(mockAPIResponse({}, TOO_MANY_REQUESTS, { 'retry-after': '10' }));
219 const refresh = refreshHandlerFactory({ call, getAuth, onRefresh });
220 await expect(refresh(getMockResponse(), {})).rejects.toBeTruthy();
221 expect(call).toHaveBeenCalledTimes(RETRY_ATTEMPTS_MAX);
222 expect(onRefresh).not.toHaveBeenCalled();