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 = () => {
24 return useDebouncedRequest;
27 jest.mock('../_utils/useDebouncedFunction', () => {
28 const useDebouncedFunction = () => {
29 return (wrapper: any) => wrapper();
31 return useDebouncedFunction;
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(),
48 const mockLinksState = {
51 setCachedThumbnail: jest.fn(),
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();
63 const abortSignal = new AbortController().signal;
66 current: ReturnType<typeof useLinkInner>;
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
79 global.URL.createObjectURL = jest.fn(() => 'blob:objecturl');
82 decryptSigned.mockImplementation(({ armoredMessage }) =>
83 Promise.resolve({ data: `dec:${armoredMessage}`, verified: 1 })
86 decryptPassphrase.mockImplementation(({ armoredPassphrase }) =>
88 decryptedPassphrase: `decPass:${armoredPassphrase}`,
89 sessionKey: `sessionKey:${armoredPassphrase}`,
93 mockGetSharePrivateKey.mockImplementation((_, shareId) => `privateKey:${shareId}`);
94 mockDecryptPrivateKey.mockImplementation(({ armoredKey: nodeKey }) => `privateKey:${nodeKey}`);
96 const { result } = renderHook(() =>
101 mockGetVerificationKey,
102 mockGetSharePrivateKey,
104 mockGetDefaultShareAddressEmail,
105 mockGetDirectSharingInfo,
108 nodeDecryptionError: mockIntegrityMetricsDecryptionError,
109 signatureVerificationError: mockIntegrityMetricsSignatureVerificationError,
110 } as unknown as IntegrityMetrics,
111 mockDecryptPrivateKey
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);
124 expect(mockLinksState.getLink).toBeCalledWith('shareId', 'linkId');
125 expect(mockFetchLink).not.toBeCalled();
128 it('decrypts when missing decrypted version in the cache', async () => {
129 mockLinksState.getLink.mockReturnValue({
130 encrypted: { linkId: 'linkId', parentLinkId: undefined, name: 'name' },
132 await act(async () => {
133 const link = hook.current.getLink(abortSignal, 'shareId', 'linkId');
134 await expect(link).resolves.toMatchObject({
139 expect(mockLinksState.getLink).toBeCalledWith('shareId', 'linkId');
140 expect(mockFetchLink).not.toBeCalled();
143 it('decrypts link with parent link', async () => {
144 const generateLink = (id: string, parentId?: string) => {
147 parentLinkId: parentId,
149 nodeKey: `nodeKey ${id}`,
150 nodePassphrase: `nodePassphrase ${id}`,
153 const links: Record<string, ReturnType<typeof generateLink>> = {
154 root: generateLink('root'),
155 parent: generateLink('parent', 'root'),
156 link: generateLink('link', 'parent'),
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({
164 name: 'dec:name link',
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.
175 // Decrypt passphrases so we can decrypt private keys for the root and the parent.
177 expect(decryptPassphrase.mock.calls.map(([{ armoredPassphrase }]) => armoredPassphrase)).toMatchObject([
178 'nodePassphrase root',
179 'nodePassphrase parent',
181 expect(mockDecryptPrivateKey.mock.calls.map(([{ armoredKey: nodeKey }]) => nodeKey)).toMatchObject([
185 // With the parent key is decrypted the name of the requested link.
188 decryptSigned.mock.calls.map(([{ privateKey, armoredMessage }]) => [privateKey, armoredMessage])
189 ).toMatchObject([['privateKey:nodeKey parent', 'name link']]);
192 describe('root name', () => {
193 const LINK_NAME = 'LINK_NAME';
196 { type: ShareType.standard, name: `dec:${LINK_NAME}` },
198 { type: ShareType.default, name: 'My files' },
199 { type: ShareType.photos, name: 'Photos' },
202 tests.forEach(({ type, name }) => {
203 it(`detects type ${type} as "${name}"`, async () => {
207 nodeKey: `nodeKey root`,
208 nodePassphrase: `nodePassphrase root`,
210 mockLinksState.getLink.mockImplementation(() => ({ encrypted: link }));
211 mockGetShare.mockImplementation((_, shareId) => ({
213 rootLinkId: link.linkId,
217 await act(async () => {
218 const link = hook.current.getLink(abortSignal, 'shareId', 'root');
219 await expect(link).resolves.toMatchObject({
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({
237 expect(mockLinksState.getLink).toBeCalledWith('shareId', 'linkId');
238 expect(mockFetchLink).toBeCalledTimes(1);
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
255 it('skips load of already cached thumbnail', async () => {
256 const downloadCallbackMock = jest.fn();
257 mockLinksState.getLink.mockReturnValue({
260 cachedThumbnailUrl: 'url',
263 await act(async () => {
264 await hook.current.loadLinkThumbnail(abortSignal, 'shareId', 'linkId', downloadCallbackMock);
266 expect(mockRequest).not.toBeCalled();
267 expect(downloadCallbackMock).not.toBeCalled();
268 expect(mockLinksState.setCachedThumbnail).not.toBeCalled();
271 it('loads link thumbnail using cached link thumbnail info', async () => {
272 const downloadCallbackMock = jest.fn().mockReturnValue(
274 contents: Promise.resolve(undefined),
275 verifiedPromise: Promise.resolve(1),
278 mockLinksState.getLink.mockReturnValue({
290 await act(async () => {
291 await hook.current.loadLinkThumbnail(abortSignal, 'shareId', 'linkId', downloadCallbackMock);
293 expect(downloadCallbackMock).toBeCalledWith('bareUrl', 'token');
294 expect(mockLinksState.setCachedThumbnail).toBeCalledWith('shareId', 'linkId', expect.any(String));
295 expect(mockRequest).not.toBeCalled();
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.
303 const downloadCallbackMock = jest.fn().mockImplementation((url: string, token: string) =>
305 ? Promise.reject('token expired')
307 contents: Promise.resolve(undefined),
308 verifiedPromise: Promise.resolve(1),
311 mockLinksState.getLink.mockReturnValue({
318 token: 'token', // Expired token.
323 await act(async () => {
324 await hook.current.loadLinkThumbnail(abortSignal, 'shareId', 'linkId', downloadCallbackMock);
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));
332 it('loads link thumbnail with its url on API', async () => {
333 mockRequest.mockReturnValue({
334 ThumbnailBareURL: 'bareUrl',
335 ThumbnailToken: 'token',
337 const downloadCallbackMock = jest.fn().mockReturnValue(
339 contents: Promise.resolve(undefined),
340 verifiedPromise: Promise.resolve(1),
343 mockLinksState.getLink.mockReturnValue({
352 await act(async () => {
353 await hook.current.loadLinkThumbnail(abortSignal, 'shareId', 'linkId', downloadCallbackMock);
355 expect(mockRequest).toBeCalledTimes(1);
356 expect(downloadCallbackMock).toBeCalledWith('bareUrl', 'token');
357 expect(mockLinksState.setCachedThumbnail).toBeCalledWith('shareId', 'linkId', expect.any(String));
360 it('decrypts badly signed thumbnail block', async () => {
361 mockLinksState.getLink.mockReturnValue({
374 mockGetShare.mockImplementation((_, shareId) =>
377 type: ShareType.default,
380 mockRequest.mockReturnValue({
381 ThumbnailBareURL: 'bareUrl',
382 ThumbnailToken: 'token',
384 const downloadCallbackMock = jest.fn().mockReturnValue(
386 contents: Promise.resolve(undefined),
387 verifiedPromise: Promise.resolve(2),
391 await act(async () => {
392 await hook.current.loadLinkThumbnail(abortSignal, 'shareId', 'link', downloadCallbackMock);
394 expect(mockLinksState.setLinks).toBeCalledWith('shareId', [
395 expect.objectContaining({
396 encrypted: expect.objectContaining({
398 signatureIssues: { thumbnail: 2 },
402 expect(mockIntegrityMetricsSignatureVerificationError).toHaveBeenCalled();
405 describe('decrypts link meta data with signature issues', () => {
407 const generateLink = (id: string, parentId?: string) => {
410 parentLinkId: parentId,
412 nodeKey: `nodeKey ${id}`,
413 nodeHashKey: `nodeHashKey ${id}`,
414 nodePassphrase: `nodePassphrase ${id}`,
417 const links: Record<string, ReturnType<typeof generateLink>> = {
418 root: generateLink('root'),
419 parent: generateLink('parent', 'root'),
420 link: generateLink('link', 'parent'),
422 mockLinksState.getLink.mockImplementation((_, linkId) => ({ encrypted: links[linkId] }));
425 it('decrypts badly signed passphrase', async () => {
427 decryptPassphrase.mockReset();
429 decryptPassphrase.mockImplementation(({ armoredPassphrase }) =>
431 decryptedPassphrase: `decPass:${armoredPassphrase}`,
432 sessionKey: `sessionKey:${armoredPassphrase}`,
436 mockGetShare.mockImplementation((_, shareId) =>
439 type: ShareType.default,
443 await act(async () => {
444 await hook.current.getLink(abortSignal, 'shareId', 'link');
446 ['root', 'parent'].forEach((linkId) => {
447 expect(mockLinksState.setLinks).toBeCalledWith('shareId', [
448 expect.objectContaining({
449 encrypted: expect.objectContaining({
451 signatureIssues: { passphrase: 2 },
456 expect(mockIntegrityMetricsSignatureVerificationError).toHaveBeenCalled();
459 it('should not call integrityMetricsSignature in case this is a bookmark', async () => {
460 mockedTokenIsValid.mockReturnValue(true);
462 decryptPassphrase.mockReset();
464 decryptPassphrase.mockImplementation(({ armoredPassphrase }) =>
466 decryptedPassphrase: `decPass:${armoredPassphrase}`,
467 sessionKey: `sessionKey:${armoredPassphrase}`,
471 mockGetShare.mockImplementation((_, shareId) =>
474 type: ShareType.default,
478 await act(async () => {
479 await hook.current.getLink(abortSignal, 'shareId', 'link');
482 expect(mockIntegrityMetricsSignatureVerificationError).not.toHaveBeenCalled();
485 it('decrypts badly signed hash', async () => {
487 decryptSigned.mockReset();
489 decryptSigned.mockImplementation(({ armoredMessage }) =>
490 Promise.resolve({ data: `dec:${armoredMessage}`, verified: 2 })
492 mockGetVerificationKey.mockReturnValue([]);
493 mockGetShare.mockImplementation((_, shareId) =>
496 type: ShareType.default,
500 await act(async () => {
501 await hook.current.getLinkHashKey(abortSignal, 'shareId', 'parent');
503 expect(mockLinksState.setLinks).toBeCalledWith('shareId', [
504 expect.objectContaining({
505 encrypted: expect.objectContaining({
507 signatureIssues: { hash: 2 },
511 expect(mockIntegrityMetricsSignatureVerificationError).toHaveBeenCalled();
514 it('decrypts badly signed name', async () => {
516 decryptSigned.mockReset();
518 decryptSigned.mockImplementation(({ armoredMessage }) =>
519 Promise.resolve({ data: `dec:${armoredMessage}`, verified: 2 })
521 mockGetShare.mockImplementation((_, shareId) =>
524 type: ShareType.default,
528 await act(async () => {
529 await hook.current.getLink(abortSignal, 'shareId', 'link');
531 expect(mockLinksState.setLinks).toBeCalledWith('shareId', [
532 expect.objectContaining({
533 decrypted: expect.objectContaining({
535 signatureIssues: { name: 2 },
539 expect(mockIntegrityMetricsSignatureVerificationError).toHaveBeenCalled();