Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / applications / drive / src / app / store / _links / useLink.test.ts
blob5440ca45c9184c61a7b146365e9265de5eac80df
1 import { act, renderHook } from '@testing-library/react-hooks';
3 import { RESPONSE_CODE } from '@proton/shared/lib/drive/constants';
4 import { decryptSigned } from '@proton/shared/lib/keys/driveKeys';
5 import { decryptPassphrase } from '@proton/shared/lib/keys/drivePassphrase';
7 import { tokenIsValid } from '../../utils/url/token';
8 import type { IntegrityMetrics } from '../_crypto';
9 import { ShareType } from '../_shares';
10 import { useLinkInner } from './useLink';
12 jest.mock('@proton/shared/lib/keys/driveKeys');
14 jest.mock('@proton/shared/lib/keys/drivePassphrase');
16 jest.mock('../../utils/url/token');
17 const mockedTokenIsValid = jest.mocked(tokenIsValid).mockReturnValue(false);
19 const mockRequest = jest.fn();
20 jest.mock('../_api/useDebouncedRequest', () => {
21     const useDebouncedRequest = () => {
22         return mockRequest;
23     };
24     return useDebouncedRequest;
25 });
27 jest.mock('../_utils/useDebouncedFunction', () => {
28     const useDebouncedFunction = () => {
29         return (wrapper: any) => wrapper();
30     };
31     return useDebouncedFunction;
32 });
34 describe('useLink', () => {
35     const mockFetchLink = jest.fn();
36     const mockLinksKeys = {
37         getPassphrase: jest.fn(),
38         setPassphrase: jest.fn(),
39         getPassphraseSessionKey: jest.fn(),
40         setPassphraseSessionKey: jest.fn(),
41         getPrivateKey: jest.fn(),
42         setPrivateKey: jest.fn(),
43         getSessionKey: jest.fn(),
44         setSessionKey: jest.fn(),
45         getHashKey: jest.fn(),
46         setHashKey: jest.fn(),
47     };
48     const mockLinksState = {
49         getLink: jest.fn(),
50         setLinks: jest.fn(),
51         setCachedThumbnail: jest.fn(),
52     };
53     const mockGetVerificationKey = jest.fn();
54     const mockGetSharePrivateKey = jest.fn();
55     const mockGetShare = jest.fn();
56     const mockGetDefaultShareAddressEmail = jest.fn();
57     const mockGetDirectSharingInfo = jest.fn();
58     const mockDecryptPrivateKey = jest.fn();
59     const mockIntegrityMetricsDecryptionError = jest.fn();
60     const mockIntegrityMetricsSignatureVerificationError = jest.fn();
62     const isPaid = false;
63     const abortSignal = new AbortController().signal;
65     let hook: {
66         current: ReturnType<typeof useLinkInner>;
67     };
69     beforeAll(() => {
70         // Time relative function can have issue with test environments
71         // To prevent hanging async function we use Timer Mocks from jest
72         // https://jestjs.io/docs/timer-mocks
73         jest.useFakeTimers();
74     });
76     beforeEach(() => {
77         jest.resetAllMocks();
79         global.URL.createObjectURL = jest.fn(() => 'blob:objecturl');
81         // @ts-ignore
82         decryptSigned.mockImplementation(({ armoredMessage }) =>
83             Promise.resolve({ data: `dec:${armoredMessage}`, verified: 1 })
84         );
85         // @ts-ignore
86         decryptPassphrase.mockImplementation(({ armoredPassphrase }) =>
87             Promise.resolve({
88                 decryptedPassphrase: `decPass:${armoredPassphrase}`,
89                 sessionKey: `sessionKey:${armoredPassphrase}`,
90                 verified: 1,
91             })
92         );
93         mockGetSharePrivateKey.mockImplementation((_, shareId) => `privateKey:${shareId}`);
94         mockDecryptPrivateKey.mockImplementation(({ armoredKey: nodeKey }) => `privateKey:${nodeKey}`);
96         const { result } = renderHook(() =>
97             useLinkInner(
98                 mockFetchLink,
99                 mockLinksKeys,
100                 mockLinksState,
101                 mockGetVerificationKey,
102                 mockGetSharePrivateKey,
103                 mockGetShare,
104                 mockGetDefaultShareAddressEmail,
105                 mockGetDirectSharingInfo,
106                 isPaid,
107                 {
108                     nodeDecryptionError: mockIntegrityMetricsDecryptionError,
109                     signatureVerificationError: mockIntegrityMetricsSignatureVerificationError,
110                 } as unknown as IntegrityMetrics,
111                 mockDecryptPrivateKey
112             )
113         );
114         hook = result;
115     });
117     it('returns decrypted version from the cache', async () => {
118         const item = { name: 'name' };
119         mockLinksState.getLink.mockReturnValue({ decrypted: item });
120         await act(async () => {
121             const link = hook.current.getLink(abortSignal, 'shareId', 'linkId');
122             await expect(link).resolves.toMatchObject(item);
123         });
124         expect(mockLinksState.getLink).toBeCalledWith('shareId', 'linkId');
125         expect(mockFetchLink).not.toBeCalled();
126     });
128     it('decrypts when missing decrypted version in the cache', async () => {
129         mockLinksState.getLink.mockReturnValue({
130             encrypted: { linkId: 'linkId', parentLinkId: undefined, name: 'name' },
131         });
132         await act(async () => {
133             const link = hook.current.getLink(abortSignal, 'shareId', 'linkId');
134             await expect(link).resolves.toMatchObject({
135                 linkId: 'linkId',
136                 name: 'dec:name',
137             });
138         });
139         expect(mockLinksState.getLink).toBeCalledWith('shareId', 'linkId');
140         expect(mockFetchLink).not.toBeCalled();
141     });
143     it('decrypts link with parent link', async () => {
144         const generateLink = (id: string, parentId?: string) => {
145             return {
146                 linkId: `${id}`,
147                 parentLinkId: parentId,
148                 name: `name ${id}`,
149                 nodeKey: `nodeKey ${id}`,
150                 nodePassphrase: `nodePassphrase ${id}`,
151             };
152         };
153         const links: Record<string, ReturnType<typeof generateLink>> = {
154             root: generateLink('root'),
155             parent: generateLink('parent', 'root'),
156             link: generateLink('link', 'parent'),
157         };
158         mockLinksState.getLink.mockImplementation((_, linkId) => ({ encrypted: links[linkId] }));
160         await act(async () => {
161             const link = hook.current.getLink(abortSignal, 'shareId', 'link');
162             await expect(link).resolves.toMatchObject({
163                 linkId: 'link',
164                 name: 'dec:name link',
165             });
166         });
168         expect(mockFetchLink).not.toBeCalled();
169         expect(mockLinksState.getLink.mock.calls.map(([, linkId]) => linkId)).toMatchObject([
170             'link', // Called by getLink.
171             'link', // Called by getEncryptedLink.
172             'parent', // Called by getLinkPrivateKey.
173             'root', // Called by getLinkPrivateKey.
174         ]);
175         // Decrypt passphrases so we can decrypt private keys for the root and the parent.
176         // @ts-ignore
177         expect(decryptPassphrase.mock.calls.map(([{ armoredPassphrase }]) => armoredPassphrase)).toMatchObject([
178             'nodePassphrase root',
179             'nodePassphrase parent',
180         ]);
181         expect(mockDecryptPrivateKey.mock.calls.map(([{ armoredKey: nodeKey }]) => nodeKey)).toMatchObject([
182             'nodeKey root',
183             'nodeKey parent',
184         ]);
185         // With the parent key is decrypted the name of the requested link.
186         expect(
187             // @ts-ignore
188             decryptSigned.mock.calls.map(([{ privateKey, armoredMessage }]) => [privateKey, armoredMessage])
189         ).toMatchObject([['privateKey:nodeKey parent', 'name link']]);
190     });
192     describe('root name', () => {
193         const LINK_NAME = 'LINK_NAME';
195         const tests = [
196             { type: ShareType.standard, name: `dec:${LINK_NAME}` },
198             { type: ShareType.default, name: 'My files' },
199             { type: ShareType.photos, name: 'Photos' },
200         ];
202         tests.forEach(({ type, name }) => {
203             it(`detects type ${type} as "${name}"`, async () => {
204                 const link = {
205                     linkId: `root`,
206                     name: LINK_NAME,
207                     nodeKey: `nodeKey root`,
208                     nodePassphrase: `nodePassphrase root`,
209                 };
210                 mockLinksState.getLink.mockImplementation(() => ({ encrypted: link }));
211                 mockGetShare.mockImplementation((_, shareId) => ({
212                     shareId,
213                     rootLinkId: link.linkId,
214                     type,
215                 }));
217                 await act(async () => {
218                     const link = hook.current.getLink(abortSignal, 'shareId', 'root');
219                     await expect(link).resolves.toMatchObject({
220                         linkId: 'root',
221                         name,
222                     });
223                 });
224             });
225         });
226     });
228     it('fetches link from API and decrypts when missing in the cache', async () => {
229         mockFetchLink.mockReturnValue(Promise.resolve({ linkId: 'linkId', parentLinkId: undefined, name: 'name' }));
230         await act(async () => {
231             const link = hook.current.getLink(abortSignal, 'shareId', 'linkId');
232             await expect(link).resolves.toMatchObject({
233                 linkId: 'linkId',
234                 name: 'dec:name',
235             });
236         });
237         expect(mockLinksState.getLink).toBeCalledWith('shareId', 'linkId');
238         expect(mockFetchLink).toBeCalledTimes(1);
239     });
241     it('skips failing fetch if already attempted before', async () => {
242         const err = { data: { Code: RESPONSE_CODE.NOT_FOUND } };
243         mockFetchLink.mockRejectedValue(err);
244         const link = hook.current.getLink(abortSignal, 'shareId', 'linkId');
245         await expect(link).rejects.toMatchObject(err);
246         const link2 = hook.current.getLink(abortSignal, 'shareId', 'linkId');
247         await expect(link2).rejects.toMatchObject(err);
248         const link3 = hook.current.getLink(abortSignal, 'shareId', 'linkId2');
249         await expect(link3).rejects.toMatchObject(err);
251         expect(mockLinksState.getLink).toBeCalledWith('shareId', 'linkId');
252         expect(mockFetchLink).toBeCalledTimes(2); // linkId once and linkId2
253     });
255     it('skips load of already cached thumbnail', async () => {
256         const downloadCallbackMock = jest.fn();
257         mockLinksState.getLink.mockReturnValue({
258             decrypted: {
259                 name: 'name',
260                 cachedThumbnailUrl: 'url',
261             },
262         });
263         await act(async () => {
264             await hook.current.loadLinkThumbnail(abortSignal, 'shareId', 'linkId', downloadCallbackMock);
265         });
266         expect(mockRequest).not.toBeCalled();
267         expect(downloadCallbackMock).not.toBeCalled();
268         expect(mockLinksState.setCachedThumbnail).not.toBeCalled();
269     });
271     it('loads link thumbnail using cached link thumbnail info', async () => {
272         const downloadCallbackMock = jest.fn().mockReturnValue(
273             Promise.resolve({
274                 contents: Promise.resolve(undefined),
275                 verifiedPromise: Promise.resolve(1),
276             })
277         );
278         mockLinksState.getLink.mockReturnValue({
279             decrypted: {
280                 name: 'name',
281                 hasThumbnail: true,
282                 activeRevision: {
283                     thumbnail: {
284                         bareUrl: 'bareUrl',
285                         token: 'token',
286                     },
287                 },
288             },
289         });
290         await act(async () => {
291             await hook.current.loadLinkThumbnail(abortSignal, 'shareId', 'linkId', downloadCallbackMock);
292         });
293         expect(downloadCallbackMock).toBeCalledWith('bareUrl', 'token');
294         expect(mockLinksState.setCachedThumbnail).toBeCalledWith('shareId', 'linkId', expect.any(String));
295         expect(mockRequest).not.toBeCalled();
296     });
298     it('loads link thumbnail with expired cached link thumbnail info', async () => {
299         mockRequest.mockReturnValue({
300             ThumbnailBareURL: 'bareUrl',
301             ThumbnailToken: 'token2', // Requested new non-expired token.
302         });
303         const downloadCallbackMock = jest.fn().mockImplementation((url: string, token: string) =>
304             token === 'token'
305                 ? Promise.reject('token expired')
306                 : Promise.resolve({
307                       contents: Promise.resolve(undefined),
308                       verifiedPromise: Promise.resolve(1),
309                   })
310         );
311         mockLinksState.getLink.mockReturnValue({
312             decrypted: {
313                 name: 'name',
314                 hasThumbnail: true,
315                 activeRevision: {
316                     thumbnail: {
317                         bareUrl: 'bareUrl',
318                         token: 'token', // Expired token.
319                     },
320                 },
321             },
322         });
323         await act(async () => {
324             await hook.current.loadLinkThumbnail(abortSignal, 'shareId', 'linkId', downloadCallbackMock);
325         });
326         expect(downloadCallbackMock).toBeCalledWith('bareUrl', 'token'); // First attempted with expired token.
327         expect(mockRequest).toBeCalledTimes(1); // Then requested the new token.
328         expect(downloadCallbackMock).toBeCalledWith('bareUrl', 'token2'); // And the new one used for final download.
329         expect(mockLinksState.setCachedThumbnail).toBeCalledWith('shareId', 'linkId', expect.any(String));
330     });
332     it('loads link thumbnail with its url on API', async () => {
333         mockRequest.mockReturnValue({
334             ThumbnailBareURL: 'bareUrl',
335             ThumbnailToken: 'token',
336         });
337         const downloadCallbackMock = jest.fn().mockReturnValue(
338             Promise.resolve({
339                 contents: Promise.resolve(undefined),
340                 verifiedPromise: Promise.resolve(1),
341             })
342         );
343         mockLinksState.getLink.mockReturnValue({
344             decrypted: {
345                 name: 'name',
346                 hasThumbnail: true,
347                 activeRevision: {
348                     id: 'revisionId',
349                 },
350             },
351         });
352         await act(async () => {
353             await hook.current.loadLinkThumbnail(abortSignal, 'shareId', 'linkId', downloadCallbackMock);
354         });
355         expect(mockRequest).toBeCalledTimes(1);
356         expect(downloadCallbackMock).toBeCalledWith('bareUrl', 'token');
357         expect(mockLinksState.setCachedThumbnail).toBeCalledWith('shareId', 'linkId', expect.any(String));
358     });
360     it('decrypts badly signed thumbnail block', async () => {
361         mockLinksState.getLink.mockReturnValue({
362             encrypted: {
363                 linkId: 'link',
364             },
365             decrypted: {
366                 linkId: 'link',
367                 name: 'name',
368                 hasThumbnail: true,
369                 activeRevision: {
370                     id: 'revisionId',
371                 },
372             },
373         });
374         mockGetShare.mockImplementation((_, shareId) =>
375             Promise.resolve({
376                 shareId,
377                 type: ShareType.default,
378             })
379         );
380         mockRequest.mockReturnValue({
381             ThumbnailBareURL: 'bareUrl',
382             ThumbnailToken: 'token',
383         });
384         const downloadCallbackMock = jest.fn().mockReturnValue(
385             Promise.resolve({
386                 contents: Promise.resolve(undefined),
387                 verifiedPromise: Promise.resolve(2),
388             })
389         );
391         await act(async () => {
392             await hook.current.loadLinkThumbnail(abortSignal, 'shareId', 'link', downloadCallbackMock);
393         });
394         expect(mockLinksState.setLinks).toBeCalledWith('shareId', [
395             expect.objectContaining({
396                 encrypted: expect.objectContaining({
397                     linkId: 'link',
398                     signatureIssues: { thumbnail: 2 },
399                 }),
400             }),
401         ]);
402         expect(mockIntegrityMetricsSignatureVerificationError).toHaveBeenCalled();
403     });
405     describe('decrypts link meta data with signature issues', () => {
406         beforeEach(() => {
407             const generateLink = (id: string, parentId?: string) => {
408                 return {
409                     linkId: `${id}`,
410                     parentLinkId: parentId,
411                     name: `name ${id}`,
412                     nodeKey: `nodeKey ${id}`,
413                     nodeHashKey: `nodeHashKey ${id}`,
414                     nodePassphrase: `nodePassphrase ${id}`,
415                 };
416             };
417             const links: Record<string, ReturnType<typeof generateLink>> = {
418                 root: generateLink('root'),
419                 parent: generateLink('parent', 'root'),
420                 link: generateLink('link', 'parent'),
421             };
422             mockLinksState.getLink.mockImplementation((_, linkId) => ({ encrypted: links[linkId] }));
423         });
425         it('decrypts badly signed passphrase', async () => {
426             // @ts-ignore
427             decryptPassphrase.mockReset();
428             // @ts-ignore
429             decryptPassphrase.mockImplementation(({ armoredPassphrase }) =>
430                 Promise.resolve({
431                     decryptedPassphrase: `decPass:${armoredPassphrase}`,
432                     sessionKey: `sessionKey:${armoredPassphrase}`,
433                     verified: 2,
434                 })
435             );
436             mockGetShare.mockImplementation((_, shareId) =>
437                 Promise.resolve({
438                     shareId,
439                     type: ShareType.default,
440                 })
441             );
443             await act(async () => {
444                 await hook.current.getLink(abortSignal, 'shareId', 'link');
445             });
446             ['root', 'parent'].forEach((linkId) => {
447                 expect(mockLinksState.setLinks).toBeCalledWith('shareId', [
448                     expect.objectContaining({
449                         encrypted: expect.objectContaining({
450                             linkId,
451                             signatureIssues: { passphrase: 2 },
452                         }),
453                     }),
454                 ]);
455             });
456             expect(mockIntegrityMetricsSignatureVerificationError).toHaveBeenCalled();
457         });
459         it('should not call integrityMetricsSignature in case this is a bookmark', async () => {
460             mockedTokenIsValid.mockReturnValue(true);
461             // @ts-ignore
462             decryptPassphrase.mockReset();
463             // @ts-ignore
464             decryptPassphrase.mockImplementation(({ armoredPassphrase }) =>
465                 Promise.resolve({
466                     decryptedPassphrase: `decPass:${armoredPassphrase}`,
467                     sessionKey: `sessionKey:${armoredPassphrase}`,
468                     verified: 2,
469                 })
470             );
471             mockGetShare.mockImplementation((_, shareId) =>
472                 Promise.resolve({
473                     shareId,
474                     type: ShareType.default,
475                 })
476             );
478             await act(async () => {
479                 await hook.current.getLink(abortSignal, 'shareId', 'link');
480             });
482             expect(mockIntegrityMetricsSignatureVerificationError).not.toHaveBeenCalled();
483         });
485         it('decrypts badly signed hash', async () => {
486             // @ts-ignore
487             decryptSigned.mockReset();
488             // @ts-ignore
489             decryptSigned.mockImplementation(({ armoredMessage }) =>
490                 Promise.resolve({ data: `dec:${armoredMessage}`, verified: 2 })
491             );
492             mockGetVerificationKey.mockReturnValue([]);
493             mockGetShare.mockImplementation((_, shareId) =>
494                 Promise.resolve({
495                     shareId,
496                     type: ShareType.default,
497                 })
498             );
500             await act(async () => {
501                 await hook.current.getLinkHashKey(abortSignal, 'shareId', 'parent');
502             });
503             expect(mockLinksState.setLinks).toBeCalledWith('shareId', [
504                 expect.objectContaining({
505                     encrypted: expect.objectContaining({
506                         linkId: 'parent',
507                         signatureIssues: { hash: 2 },
508                     }),
509                 }),
510             ]);
511             expect(mockIntegrityMetricsSignatureVerificationError).toHaveBeenCalled();
512         });
514         it('decrypts badly signed name', async () => {
515             // @ts-ignore
516             decryptSigned.mockReset();
517             // @ts-ignore
518             decryptSigned.mockImplementation(({ armoredMessage }) =>
519                 Promise.resolve({ data: `dec:${armoredMessage}`, verified: 2 })
520             );
521             mockGetShare.mockImplementation((_, shareId) =>
522                 Promise.resolve({
523                     shareId,
524                     type: ShareType.default,
525                 })
526             );
528             await act(async () => {
529                 await hook.current.getLink(abortSignal, 'shareId', 'link');
530             });
531             expect(mockLinksState.setLinks).toBeCalledWith('shareId', [
532                 expect.objectContaining({
533                     decrypted: expect.objectContaining({
534                         linkId: 'link',
535                         signatureIssues: { name: 2 },
536                     }),
537                 }),
538             ]);
539             expect(mockIntegrityMetricsSignatureVerificationError).toHaveBeenCalled();
540         });
541     });