1 import { act, renderHook } from '@testing-library/react-hooks';
3 import { EVENT_TYPES } from '@proton/shared/lib/drive/constants';
5 import type { DriveEvents } from '../_events';
6 import type { DecryptedLink, EncryptedLink, LinkShareUrl } from './interface';
7 import type { Link, LinksState } from './useLinksState';
11 setCachedThumbnailUrl,
14 useLinksStateProvider,
15 } from './useLinksState';
17 jest.mock('../_events/useDriveEventManager', () => {
18 const useDriveEventManager = () => {
22 unregister: () => false,
31 function generateTestLink({
36 rootShareId = 'defaultShareId',
39 parentId: string | undefined;
48 parentLinkId: parentId,
58 parentLinkId: parentId,
68 function getLockedIds(state: LinksState): string[] {
69 return Object.values(state.shareId.links)
70 .filter(({ decrypted }) => decrypted?.isLocked)
71 .map(({ encrypted }) => encrypted.linkId);
74 function generateEvents(events: any[]): DriveEvents {
77 events: events.map(([eventType, encryptedLink]) => ({ eventType, encryptedLink })),
82 describe('useLinksState', () => {
83 let state: LinksState;
89 linkId0: generateTestLink({ id: 'linkId0', parentId: undefined }),
90 linkId1: generateTestLink({ id: 'linkId1', parentId: 'linkId0' }),
91 linkId2: generateTestLink({ id: 'linkId2', parentId: 'linkId1' }),
92 linkId3: generateTestLink({ id: 'linkId3', parentId: 'linkId1' }),
93 linkId4: generateTestLink({ id: 'linkId4', parentId: 'linkId0' }),
94 linkId5: generateTestLink({
98 rootShareId: 'shareId0',
100 linkId6: generateTestLink({
104 rootShareId: 'shareId2',
106 linkId7: generateTestLink({ id: 'linkId7', parentId: 'linkId0', decrypted: true }),
107 linkId8: generateTestLink({ id: 'linkId8', parentId: 'linkId7', decrypted: true }),
108 linkId9: generateTestLink({ id: 'linkId9', parentId: 'linkId7', decrypted: true }),
111 linkId0: ['linkId1', 'linkId4', 'linkId7'],
112 linkId1: ['linkId2', 'linkId3'],
113 linkId4: ['linkId5', 'linkId6'],
114 linkId7: ['linkId8', 'linkId9'],
120 it('deletes links', () => {
121 const result = deleteLinks(state, 'shareId', ['linkId1', 'linkId2', 'linkId6', 'linkId8', 'linkId9']);
122 // Removed links from links.
123 expect(Object.keys(result.shareId.links)).toMatchObject(['linkId0', 'linkId4', 'linkId5', 'linkId7']);
124 // Removed parent from tree.
125 expect(Object.keys(result.shareId.tree)).toMatchObject(['linkId0', 'linkId4', 'linkId7']);
126 // Removed children from tree.
127 expect(result.shareId.tree.linkId4).toMatchObject(['linkId5']);
130 it('adds new encrypted link', () => {
131 const result = addOrUpdate(state, 'shareId', [
136 parentLinkId: 'linkId1',
141 expect(result.shareId.links.newLink).toMatchObject({
142 encrypted: { linkId: 'newLink' },
144 // Added to tree as child to its parent.
145 expect(result.shareId.tree.linkId1).toMatchObject(['linkId2', 'linkId3', 'newLink']);
148 it('adds link to new share', () => {
149 const result = addOrUpdate(state, 'shareId2', [
154 parentLinkId: 'linkId1',
158 // Added new link to links.
159 expect(result.shareId2.links.newLink).toMatchObject({
160 encrypted: { linkId: 'newLink' },
162 // Added parent to tree.
163 expect(Object.keys(result.shareId2.tree)).toMatchObject(['linkId1']);
164 expect(result.shareId2.tree.linkId1).toMatchObject(['newLink']);
167 it('updates encrypted link', () => {
168 const result = addOrUpdate(state, 'shareId', [
173 parentLinkId: 'linkId0',
177 expect(result.shareId.links.linkId7).toMatchObject({
178 decrypted: { linkId: 'linkId7', name: 'linkId7', isStale: true },
179 encrypted: { linkId: 'linkId7' },
183 it('updates encrypted link without need to re-decrypt', () => {
184 const result = addOrUpdate(state, 'shareId', [
189 parentLinkId: 'linkId0',
193 expect(result.shareId.links.linkId7).toMatchObject({
194 decrypted: { linkId: 'linkId7', name: 'linkId7', isStale: false },
195 encrypted: { linkId: 'linkId7' },
199 it('updates encrypted link with different parent', () => {
200 const result = addOrUpdate(state, 'shareId', [
205 parentLinkId: 'linkId6',
209 // Updated link in links.
210 expect(result.shareId.links.linkId7).toMatchObject({
211 decrypted: { linkId: 'linkId7', isStale: true }, // Changing parent requires to re-decrypt again.
212 encrypted: { linkId: 'linkId7' },
214 // Updated original parent tree.
215 expect(result.shareId.tree.linkId0).toMatchObject(['linkId1', 'linkId4']);
216 // Updated new parent tree.
217 expect(result.shareId.tree.linkId6).toMatchObject(['linkId7']);
220 it('updates decrypted link', () => {
221 const result = addOrUpdate(state, 'shareId', [
226 parentLinkId: 'linkId0',
231 parentLinkId: 'linkId0',
235 expect(result.shareId.links.linkId7).toMatchObject({
236 decrypted: { linkId: 'linkId7', name: 'new name' },
237 encrypted: { linkId: 'linkId7' },
241 it('updates trashed link', () => {
242 const result1 = addOrUpdate(state, 'shareId', [
247 parentLinkId: 'linkId0',
255 parentLinkId: 'linkId7',
260 expect(result1.shareId.links.linkId7).toMatchObject({
261 decrypted: { linkId: 'linkId7', name: 'linkId7', isStale: false },
262 encrypted: { linkId: 'linkId7' },
264 // Trashed link is removed from the parent.
265 expect(result1.shareId.tree.linkId0).toMatchObject(['linkId1', 'linkId4']);
266 // Trashed parent trashes automatically also children.
267 expect(result1.shareId.links.linkId7.encrypted.trashed).toBe(12345678);
268 expect(result1.shareId.links.linkId7.encrypted.trashedByParent).toBeFalsy();
269 expect(result1.shareId.links.linkId8.encrypted.trashed).toBe(123456789);
270 expect(result1.shareId.links.linkId8.encrypted.trashedByParent).toBeFalsy();
271 expect(result1.shareId.links.linkId9.encrypted.trashed).toBe(12345678);
272 expect(result1.shareId.links.linkId9.encrypted.trashedByParent).toBeTruthy();
274 // Restoring from trash re-adds link back to its parent.
275 const result2 = addOrUpdate(result1, 'shareId', [
280 parentLinkId: 'linkId0',
285 expect(result2.shareId.tree.linkId0).toMatchObject(['linkId1', 'linkId4', 'linkId7']);
286 // Restoring from trash removes also trashed flag to its children which were trashed with it.
287 expect(result1.shareId.links.linkId7.encrypted.trashed).toBe(null);
288 expect(result1.shareId.links.linkId7.encrypted.trashedByParent).toBeFalsy();
289 expect(result1.shareId.links.linkId8.encrypted.trashed).toBe(123456789);
290 expect(result1.shareId.links.linkId8.encrypted.trashedByParent).toBeFalsy();
291 expect(result1.shareId.links.linkId9.encrypted.trashed).toBe(null);
292 expect(result1.shareId.links.linkId9.encrypted.trashedByParent).toBeFalsy();
295 it('updates trashed folder and adds files to it', () => {
296 const result1 = addOrUpdate(state, 'shareId', [
301 parentLinkId: 'linkId0',
309 parentLinkId: 'linkId7',
313 // Children of trashed parent is added to tree structure.
314 expect(result1.shareId.tree.linkId7).toMatchObject(['linkId8', 'linkId9', 'linkId7a']);
315 // Trashed parent trashes automatically also children.
316 expect(result1.shareId.links.linkId7.encrypted.trashed).toBe(12345678);
317 expect(result1.shareId.links.linkId7.encrypted.trashedByParent).toBeFalsy();
318 expect(result1.shareId.links.linkId7a.encrypted.trashed).toBe(12345678);
319 expect(result1.shareId.links.linkId7a.encrypted.trashedByParent).toBeTruthy();
321 // Restoring from trash re-adds link back to its parent.
322 const result2 = addOrUpdate(result1, 'shareId', [
327 parentLinkId: 'linkId0',
332 // Trashed parent trashes automatically also children.
333 expect(result2.shareId.links.linkId7.encrypted.trashed).toBe(null);
334 expect(result2.shareId.links.linkId7.encrypted.trashedByParent).toBeFalsy();
335 expect(result2.shareId.links.linkId7a.encrypted.trashed).toBe(null);
336 expect(result2.shareId.links.linkId7a.encrypted.trashedByParent).toBeFalsy();
339 it('updates encrypted link with signature issue', () => {
340 // First, it sets signature issue.
341 const result1 = addOrUpdate(state, 'shareId', [
346 parentLinkId: 'linkId0',
347 signatureIssues: { name: 2 },
348 } as unknown as EncryptedLink,
351 expect(result1.shareId.links.linkId7).toMatchObject({
352 decrypted: { linkId: 'linkId7', signatureIssues: { name: 2 } },
353 encrypted: { linkId: 'linkId7', signatureIssues: { name: 2 } },
355 // Second, it keeps it even if we do another update which doesnt change
356 // how the link is encrypted (keys and encrypted data are the same).
357 const result2 = addOrUpdate(result1, 'shareId', [
362 parentLinkId: 'linkId0',
363 } as unknown as EncryptedLink,
366 expect(result2.shareId.links.linkId7).toMatchObject({
367 decrypted: { linkId: 'linkId7', signatureIssues: { name: 2 } },
368 encrypted: { linkId: 'linkId7', signatureIssues: { name: 2 } },
370 // Third, signature issue is cleared if keys or encrypted data is changed.
371 const result3 = addOrUpdate(result2, 'shareId', [
376 parentLinkId: 'linkId0',
377 nodeKey: 'anotherKey',
378 } as unknown as EncryptedLink,
381 expect(result3.shareId.links.linkId7).toMatchObject({
382 decrypted: { linkId: 'linkId7', signatureIssues: undefined },
383 encrypted: { linkId: 'linkId7', signatureIssues: undefined },
387 it('updates decrypted link with signature issue', () => {
388 const result = addOrUpdate(state, 'shareId', [
393 parentLinkId: 'linkId0',
394 } as unknown as EncryptedLink,
398 parentLinkId: 'linkId0',
399 signatureIssues: { name: 2 },
400 } as unknown as DecryptedLink,
403 expect(result.shareId.links.linkId7).toMatchObject({
404 decrypted: { linkId: 'linkId7', signatureIssues: { name: 2 } },
405 encrypted: { linkId: 'linkId7', signatureIssues: { name: 2 } },
409 it('locks and unlocks links', () => {
410 const result1 = setLock(state, 'shareId', ['linkId7', 'linkId8'], true);
411 expect(getLockedIds(result1)).toMatchObject(['linkId7', 'linkId8']);
412 const result2 = setLock(state, 'shareId', ['linkId8'], false);
413 expect(getLockedIds(result2)).toMatchObject(['linkId7']);
416 it('locks and unlocks trashed links', () => {
417 (state.shareId.links.linkId7.decrypted as DecryptedLink).trashed = 1234;
418 (state.shareId.links.linkId8.decrypted as DecryptedLink).trashed = 5678;
419 const result1 = setLock(state, 'shareId', 'trash', true);
420 expect(getLockedIds(result1)).toMatchObject(['linkId7', 'linkId8']);
421 const result2 = setLock(state, 'shareId', 'trash', false);
422 expect(getLockedIds(result2)).toMatchObject([]);
425 it('preserves lock for newly added trashed link', () => {
426 const result1 = setLock(state, 'shareId', 'trash', true);
427 const result2 = addOrUpdate(result1, 'shareId', [
432 parentLinkId: 'linkId0',
438 parentLinkId: 'linkId0',
446 parentLinkId: 'linkId0',
447 trashed: 12345678900, // Way in future after setLock was called.
452 parentLinkId: 'linkId0',
453 trashed: 12345678900,
457 // linkId101 was deleted after our empty action, so is not locked.
458 expect(getLockedIds(result2)).toMatchObject(['linkId100']);
461 it('sets cached thumbnail', () => {
462 const result = setCachedThumbnailUrl(state, 'shareId', 'linkId7', 'cachedurl');
463 expect(result.shareId.links.linkId7.decrypted).toMatchObject({ cachedThumbnailUrl: 'cachedurl' });
466 it('preserves cached lock flag', () => {
467 const state2 = setLock(state, 'shareId', ['linkId7'], true);
471 parentLinkId: 'linkId0',
473 const result = addOrUpdate(state2, 'shareId', [
475 encrypted: link as EncryptedLink,
476 decrypted: link as DecryptedLink,
479 expect(result.shareId.links.linkId7.decrypted).toMatchObject({ isLocked: true });
482 it('preserves cached thumbnail', () => {
483 const state2 = setCachedThumbnailUrl(state, 'shareId', 'linkId7', 'cachedurl');
487 parentLinkId: 'linkId0',
489 const result = addOrUpdate(state2, 'shareId', [
491 encrypted: link as EncryptedLink,
492 decrypted: link as DecryptedLink,
495 expect(result.shareId.links.linkId7.decrypted).toMatchObject({ cachedThumbnailUrl: 'cachedurl' });
498 it('does not preserve cached thumbnail when revision changed', () => {
499 const state2 = setCachedThumbnailUrl(state, 'shareId', 'linkId7', 'cachedurl');
503 parentLinkId: 'linkId0',
504 activeRevision: { id: 'newId' },
506 const result = addOrUpdate(state2, 'shareId', [
508 encrypted: link as EncryptedLink,
509 decrypted: link as DecryptedLink,
512 expect(result.shareId.links.linkId7.decrypted).toMatchObject({ cachedThumbnailUrl: undefined });
515 it('preserves latest share url num accesses', () => {
516 expect(state.shareId.links.linkId7.decrypted?.shareUrl?.numAccesses).toBe(undefined);
518 // First set the numAccesses and check its set.
519 const linkWithAccesses = {
522 parentLinkId: 'linkId0',
525 numAccesses: 0, // Test with zero to make sure zero is also well handled.
528 const result1 = addOrUpdate(state, 'shareId', [
530 encrypted: linkWithAccesses as EncryptedLink,
531 decrypted: linkWithAccesses as DecryptedLink,
534 expect(result1.shareId.links.linkId7.decrypted?.shareUrl?.numAccesses).toBe(0);
536 // Then set newer link without numAccesses which stil preserves the previous value.
537 const linkWithoutAccesses = {
540 parentLinkId: 'linkId0',
545 const result2 = addOrUpdate(state, 'shareId', [
547 encrypted: linkWithoutAccesses as EncryptedLink,
548 decrypted: linkWithoutAccesses as DecryptedLink,
551 expect(result2.shareId.links.linkId7.decrypted?.shareUrl?.numAccesses).toBe(0);
554 it('sets zero num accesses for fresh new share url', () => {
555 (state.shareId.links.linkId7.decrypted as DecryptedLink).shareUrl = undefined;
556 (state.shareId.links.linkId8.decrypted as DecryptedLink).shareUrl = {
562 parentLinkId: 'linkId0',
570 parentLinkId: 'linkId7',
575 const result = addOrUpdate(state, 'shareId', [
577 encrypted: link7 as EncryptedLink,
578 decrypted: link7 as DecryptedLink,
581 encrypted: link8 as EncryptedLink,
582 decrypted: link8 as DecryptedLink,
585 // Link 7 had no shareUrl before, that means it is freshly created, so set to 0.
586 expect(result.shareId.links.linkId7.decrypted?.shareUrl?.numAccesses).toBe(0);
587 // Whereas link 8 had shareUrl before, so the update is about something else, and we need to keep undefined.
588 expect(result.shareId.links.linkId8.decrypted?.shareUrl?.numAccesses).toBe(undefined);
591 it('processes events', () => {
592 const result = updateByEvents(
597 { linkId: 'newLink', name: 'newLink', parentLinkId: 'linkId0', rootShareId: 'shareId' },
599 [EVENT_TYPES.DELETE, { linkId: 'linkId1' }],
600 [EVENT_TYPES.DELETE, { linkId: 'linkId4' }],
603 { linkId: 'linkId7', name: 'new name', parentLinkId: 'linkId0', rootShareId: 'shareId' },
609 expect(Object.keys(result.shareId.links)).toMatchObject([
616 expect(Object.keys(result.shareId.tree)).toMatchObject(['linkId0', 'linkId7']);
617 expect(result.shareId.links.linkId7).toMatchObject({
618 decrypted: { linkId: 'linkId7', name: 'linkId7', isStale: true },
619 encrypted: { linkId: 'linkId7' },
623 it('skips events from non-present share', () => {
624 const result = updateByEvents(
629 { linkId: 'newLink', name: 'newLink', parentLinkId: 'linkId0', rootShareId: 'shareId2' },
635 expect(Object.keys(result)).toMatchObject(['shareId']);
638 describe('hook', () => {
640 current: ReturnType<typeof useLinksStateProvider>;
644 const { result } = renderHook(() => useLinksStateProvider());
648 state.shareId.links.linkId6.encrypted.shareUrl = {
655 state.shareId.links.linkId7.encrypted.trashed = 12345;
656 state.shareId.links.linkId8.encrypted.trashed = 12345;
657 state.shareId.links.linkId8.encrypted.trashedByParent = true;
658 state.shareId.links.linkId9.encrypted.trashed = 12345;
659 state.shareId.links.linkId9.encrypted.trashedByParent = true;
660 hook.current.setLinks('shareId', Object.values(state.shareId.links));
664 it('returns children of the parent', () => {
665 const links = hook.current.getChildren('shareId', 'linkId1');
666 expect(links.map((link) => link.encrypted.linkId)).toMatchObject(['linkId2', 'linkId3']);
669 it('returns trashed links', () => {
670 const links = hook.current.getTrashed('shareId');
671 expect(links.map((link) => link.encrypted.linkId)).toMatchObject(['linkId7']);
674 it('returns shared links', () => {
675 const links = hook.current.getSharedByLink('shareId');
676 expect(links.map((link) => link.encrypted.linkId)).toMatchObject(['linkId5']);
679 it('returns shared with me links', () => {
680 const links = hook.current.getSharedWithMeByLink('shareId');
681 expect(links.map((link) => link.encrypted.linkId)).toMatchObject(['linkId6']);