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())
26 refreshHandleFactorySpy.mockImplementation(
29 onRefresh(await refreshMock())
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 () => {
43 api.subscribe(listener);
45 fetchMock.mockClear();
46 refreshMock.mockClear();
49 describe('Factory', () => {
50 test('should create initial API state', () => {
51 expect(api.getState()).toEqual({
57 serverTime: undefined,
58 sessionInactive: false,
64 test('should keep track of pending requests', async () => {
65 const resolvers: ((res: Response) => void)[] = [];
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());
83 expect(api.getState().pendingCount).toEqual(2);
85 resolvers[1](mockAPIResponse());
87 expect(api.getState().pendingCount).toEqual(1);
89 resolvers[2](mockAPIResponse());
91 expect(api.getState().pendingCount).toEqual(0);
94 test('should support request treshold', async () => {
95 const backPressuredApi = createApi({ config, getAuth, threshold: 1 });
96 const resolvers: ((res: Response) => void)[] = [];
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());
114 expect(fetchMock).toHaveBeenCalledTimes(2);
116 resolvers[1](mockAPIResponse());
118 expect(fetchMock).toHaveBeenCalledTimes(3);
120 resolvers[2](mockAPIResponse());
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);
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');
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);
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);
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);
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');
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');
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');
191 describe('Locked session', () => {
192 test('should handle locked session', async () => {
193 fetchMock.mockResolvedValueOnce(
196 Code: PassErrorCode.SESSION_LOCKED,
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);
209 test('all subsequent calls should fail early', async () => {
210 fetchMock.mockResolvedValueOnce(
213 Code: PassErrorCode.SESSION_LOCKED,
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);
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);
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);
247 test('should try to refresh session', async () => {
248 const response = { id: Math.random() };
249 refreshMock.mockResolvedValueOnce({ RefreshToken: 'refresh-001' });
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);
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);
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);
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);