Merge branch 'IDTEAM-1.26.0' into 'main'
[ProtonMail-WebClient.git] / packages / pass / lib / api / factory.spec.ts
bloba39c62f1754ff6ab141842b69b9113228e31b738
1 import { type ApiAuth, AuthMode, type Maybe } from '@proton/pass/types';
2 import { InactiveSessionError } from '@proton/shared/lib/api/helpers/errors';
3 import { API_CUSTOM_ERROR_CODES } from '@proton/shared/lib/errors';
4 import { ApiError } from '@proton/shared/lib/fetch/ApiError';
5 import type { ProtonConfig } from '@proton/shared/lib/interfaces';
7 import { LockedSessionError, PassErrorCode } from './errors';
8 import { createApi } from './factory';
9 import * as refresh from './refresh';
10 import { TEST_SERVER_TIME, mockAPIResponse } from './testing';
12 const { APP_VERSION_BAD } = API_CUSTOM_ERROR_CODES;
14 const asyncNextTick = () => new Promise(process.nextTick);
16 describe('API factory', () => {
17     const config = { APP_NAME: 'proton-pass', APP_VERSION: '0.0.1-test', API_URL: 'https://test.api' } as ProtonConfig;
18     const refreshMock = jest.fn(() => Promise.resolve({}));
19     jest.spyOn(refresh, 'refreshHandlerFactory');
20     const refreshHandleFactorySpy = refresh.refreshHandlerFactory as unknown as jest.SpyInstance;
22     const fetchMock = jest.fn<Promise<Response>, [url: string, options: any], any>(() =>
23         Promise.resolve(mockAPIResponse())
24     );
26     refreshHandleFactorySpy.mockImplementation(
27         ({ onRefresh }) =>
28             async () =>
29                 onRefresh(await refreshMock())
30     );
32     let auth: Maybe<ApiAuth> = undefined;
33     const getAuth = jest.fn().mockImplementation(() => auth);
34     const listener = jest.fn();
36     (global as any).fetch = fetchMock;
37     const api = createApi({ config, getAuth });
39     beforeEach(async () => {
40         auth = undefined;
41         api.unsubscribe();
42         await api.reset();
43         api.subscribe(listener);
44         listener.mockClear();
45         fetchMock.mockClear();
46         refreshMock.mockClear();
47     });
49     describe('Factory', () => {
50         test('should create initial API state', () => {
51             expect(api.getState()).toEqual({
52                 appVersionBad: false,
53                 online: true,
54                 pendingCount: 0,
55                 queued: [],
56                 refreshing: false,
57                 serverTime: undefined,
58                 sessionInactive: false,
59                 sessionLocked: false,
60                 unreachable: false,
61             });
62         });
64         test('should keep track of pending requests', async () => {
65             const resolvers: ((res: Response) => void)[] = [];
67             fetchMock
68                 .mockImplementationOnce(() => new Promise<Response>((res) => resolvers.push(res)))
69                 .mockImplementationOnce(() => new Promise<Response>((res) => resolvers.push(res)))
70                 .mockImplementationOnce(() => new Promise<Response>((res) => resolvers.push(res)));
72             expect(api.getState().pendingCount).toEqual(0);
74             const call1 = api({}).then(asyncNextTick);
75             const call2 = api({}).then(asyncNextTick);
76             const call3 = api({}).then(asyncNextTick);
78             await asyncNextTick();
80             expect(api.getState().pendingCount).toEqual(3);
81             resolvers[0](mockAPIResponse());
82             await call1;
83             expect(api.getState().pendingCount).toEqual(2);
85             resolvers[1](mockAPIResponse());
86             await call2;
87             expect(api.getState().pendingCount).toEqual(1);
89             resolvers[2](mockAPIResponse());
90             await call3;
91             expect(api.getState().pendingCount).toEqual(0);
92         });
94         test('should support request treshold', async () => {
95             const backPressuredApi = createApi({ config, getAuth, threshold: 1 });
96             const resolvers: ((res: Response) => void)[] = [];
98             fetchMock
99                 .mockImplementationOnce(() => new Promise<Response>((res) => resolvers.push(res)))
100                 .mockImplementationOnce(() => new Promise<Response>((res) => resolvers.push(res)))
101                 .mockImplementationOnce(() => new Promise<Response>((res) => resolvers.push(res)));
103             const call1 = backPressuredApi({}).then(asyncNextTick);
104             const call2 = backPressuredApi({}).then(asyncNextTick);
105             const call3 = backPressuredApi({}).then(asyncNextTick);
107             await asyncNextTick();
109             expect(fetchMock).toHaveBeenCalledTimes(1);
110             expect(backPressuredApi.getState().pendingCount).toEqual(3);
112             resolvers[0](mockAPIResponse());
113             await call1;
114             expect(fetchMock).toHaveBeenCalledTimes(2);
116             resolvers[1](mockAPIResponse());
117             await call2;
118             expect(fetchMock).toHaveBeenCalledTimes(3);
120             resolvers[2](mockAPIResponse());
121             await call3;
122         });
123     });
125     describe('Server time', () => {
126         test('should be set on API call', async () => {
127             expect(api.getState().serverTime).toEqual(undefined);
128             await api({ url: 'some/endpoint' });
129             expect(api.getState().serverTime).toEqual(TEST_SERVER_TIME);
130         });
132         test('should throw if no date header response', async () => {
133             fetchMock.mockResolvedValueOnce(mockAPIResponse({}, 200, {}));
134             await expect(api({})).rejects.toThrow('Could not fetch server time');
135         });
136     });
138     describe('Response', () => {
139         test('should support JSON response', async () => {
140             const json = { id: Math.random() };
141             fetchMock.mockResolvedValueOnce(mockAPIResponse(json, 200));
142             expect(await api({ url: 'endpoint', output: 'json' })).toEqual(json);
143         });
145         test('should support RAW response', async () => {
146             const response = mockAPIResponse({ id: Math.random() }, 200);
147             fetchMock.mockResolvedValueOnce(response);
148             expect(await api({ url: 'endpoint', output: 'raw' })).toEqual(response);
149         });
151         test('should support STREAM response', async () => {
152             const response = mockAPIResponse({ id: Math.random() }, 200);
153             fetchMock.mockResolvedValueOnce(response);
154             expect(await api({ url: 'endpoint', output: 'stream' })).toEqual(response.body);
155         });
156     });
158     describe('Authentication', () => {
159         test('should allow authenticated requests when `AuthMode.TOKEN`', async () => {
160             auth = { type: AuthMode.TOKEN, AccessToken: 'access-000', UID: 'id-000', RefreshToken: 'refresh-000' };
161             await api({ url: 'some/endpoint' });
162             const [url, { headers }] = fetchMock.mock.lastCall!;
164             expect(url.toString()).toEqual('https://test.api/some/endpoint');
165             expect(headers.Authorization).toEqual(`Bearer ${auth.AccessToken}`);
166             expect(headers['x-pm-uid']).toEqual(auth.UID);
167             expect(headers['x-pm-appversion']).toEqual('web-pass@0.0.1-dev');
168         });
170         test('should allow authenticated requests when `AuthMode.COOKIE`', async () => {
171             auth = { type: AuthMode.COOKIE, UID: 'id-000' };
172             await api({ url: 'some/endpoint' });
173             const [url, { headers }] = fetchMock.mock.lastCall!;
175             expect(url.toString()).toEqual('https://test.api/some/endpoint');
176             expect(headers.Authorization).toBeUndefined();
177             expect(headers['x-pm-uid']).toEqual(auth.UID);
178             expect(headers['x-pm-appversion']).toEqual('web-pass@0.0.1-dev');
179         });
181         test('should allow unauthenticated requests', async () => {
182             await api({ url: 'some/public/endpoint' });
183             const [url, { headers }] = fetchMock.mock.lastCall!;
184             expect(url.toString()).toEqual('https://test.api/some/public/endpoint');
185             expect(headers.Authorization).toBeUndefined();
186             expect(headers['x-pm-uid']).toBeUndefined();
187             expect(headers['x-pm-appversion']).toEqual('web-pass@0.0.1-dev');
188         });
189     });
191     describe('Locked session', () => {
192         test('should handle locked session', async () => {
193             fetchMock.mockResolvedValueOnce(
194                 mockAPIResponse(
195                     {
196                         Code: PassErrorCode.SESSION_LOCKED,
197                         Error: 'Locked',
198                     },
199                     422
200                 )
201             );
203             await expect(api({ url: 'some/protected/endpoint' })).rejects.toThrow(LockedSessionError());
204             expect(listener).toHaveBeenCalledTimes(1);
205             expect(listener).toHaveBeenCalledWith({ status: 'locked', type: 'session' });
206             expect(api.getState().sessionLocked).toEqual(true);
207         });
209         test('all subsequent calls should fail early', async () => {
210             fetchMock.mockResolvedValueOnce(
211                 mockAPIResponse(
212                     {
213                         Code: PassErrorCode.SESSION_LOCKED,
214                         Error: 'Locked',
215                     },
216                     422
217                 )
218             );
220             await expect(api({ url: 'some/protected/endpoint' })).rejects.toThrow(LockedSessionError());
221             await expect(api({ url: 'some/other/endpoint' })).rejects.toThrow(LockedSessionError());
222             await expect(api({ url: 'some/other/endpoint' })).rejects.toThrow(LockedSessionError());
223             expect(fetchMock).toHaveBeenCalledTimes(1);
224         });
225     });
227     describe('Inactive session', () => {
228         test('should handle inactive session', async () => {
229             refreshMock.mockRejectedValueOnce(InactiveSessionError());
230             fetchMock.mockResolvedValueOnce(mockAPIResponse({}, 401));
231             await expect(api({ url: 'some/protected/endpoint' })).rejects.toThrow(InactiveSessionError());
232             expect(listener).toHaveBeenCalledTimes(2);
233             expect(listener.mock.calls[0][0]).toEqual({ status: 'inactive', type: 'session', silent: false });
234             expect(listener.mock.calls[1][0]).toEqual({ type: 'error', error: 'Session timed out', silent: false });
235             expect(api.getState().sessionInactive).toEqual(true);
236         });
238         test('all subsequent calls should fail early', async () => {
239             refreshMock.mockRejectedValueOnce(InactiveSessionError());
240             fetchMock.mockResolvedValueOnce(mockAPIResponse({}, 401));
241             await expect(api({ url: 'some/protected/endpoint' })).rejects.toThrow(InactiveSessionError());
242             await expect(api({ url: 'some/other/endpoint' })).rejects.toThrow(InactiveSessionError());
243             await expect(api({ url: 'some/other/endpoint' })).rejects.toThrow(InactiveSessionError());
244             expect(fetchMock).toHaveBeenCalledTimes(1);
245         });
247         test('should try to refresh session', async () => {
248             const response = { id: Math.random() };
249             refreshMock.mockResolvedValueOnce({ RefreshToken: 'refresh-001' });
251             fetchMock
252                 .mockResolvedValueOnce(mockAPIResponse({}, 401))
253                 .mockResolvedValueOnce(mockAPIResponse(response, 200));
255             await expect(api({ url: 'some/protected/endpoint' })).resolves.toEqual(response);
256             expect(listener).toHaveBeenCalledTimes(1);
257             expect(listener).toHaveBeenCalledWith({ type: 'refresh', data: { RefreshToken: 'refresh-001' } });
258             expect(api.getState().sessionInactive).toEqual(false);
259         });
260     });
262     describe('Errors', () => {
263         test('should handle offline errors', async () => {
264             fetchMock.mockRejectedValueOnce({ ok: false });
265             await expect(api({})).rejects.toThrow('No network connection');
266             expect(api.getState().online).toBe(false);
267         });
269         test('should handle unavailable service errors', async () => {
270             fetchMock.mockResolvedValueOnce(mockAPIResponse({}, 503));
271             await expect(api({})).rejects.toThrow(ApiError);
272             expect(api.getState().unreachable).toBe(true);
273         });
275         test('should handle bad client version errors', async () => {
276             fetchMock.mockResolvedValueOnce(mockAPIResponse({ Code: APP_VERSION_BAD, Error: 'Bad verson' }, 500));
277             await expect(api({})).rejects.toThrow('App version outdated');
278             expect(api.getState().appVersionBad).toBe(true);
279         });
280     });