@protontech/drive-sdk 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/crypto/driveCrypto.d.ts +1 -1
  2. package/dist/crypto/driveCrypto.js.map +1 -1
  3. package/dist/crypto/interface.d.ts +1 -1
  4. package/dist/crypto/openPGPCrypto.d.ts +1 -1
  5. package/dist/crypto/openPGPCrypto.js +4 -1
  6. package/dist/crypto/openPGPCrypto.js.map +1 -1
  7. package/dist/internal/download/cryptoService.js +2 -2
  8. package/dist/internal/download/cryptoService.js.map +1 -1
  9. package/dist/internal/download/fileDownloader.js +2 -2
  10. package/dist/internal/download/fileDownloader.js.map +1 -1
  11. package/dist/internal/download/fileDownloader.test.js +3 -1
  12. package/dist/internal/download/fileDownloader.test.js.map +1 -1
  13. package/dist/internal/nodes/cache.js +3 -1
  14. package/dist/internal/nodes/cache.js.map +1 -1
  15. package/dist/internal/nodes/cryptoReporter.d.ts +20 -0
  16. package/dist/internal/nodes/cryptoReporter.js +96 -0
  17. package/dist/internal/nodes/cryptoReporter.js.map +1 -0
  18. package/dist/internal/nodes/cryptoService.d.ts +17 -12
  19. package/dist/internal/nodes/cryptoService.js +17 -97
  20. package/dist/internal/nodes/cryptoService.js.map +1 -1
  21. package/dist/internal/nodes/cryptoService.test.js +3 -1
  22. package/dist/internal/nodes/cryptoService.test.js.map +1 -1
  23. package/dist/internal/nodes/index.js +3 -1
  24. package/dist/internal/nodes/index.js.map +1 -1
  25. package/dist/internal/nodes/interface.d.ts +1 -1
  26. package/dist/internal/nodes/nodesAccess.d.ts +2 -2
  27. package/dist/internal/nodes/nodesAccess.js +52 -54
  28. package/dist/internal/nodes/nodesAccess.js.map +1 -1
  29. package/dist/internal/sharing/cache.d.ts +3 -0
  30. package/dist/internal/sharing/cache.js +17 -2
  31. package/dist/internal/sharing/cache.js.map +1 -1
  32. package/dist/internal/sharing/interface.d.ts +1 -1
  33. package/dist/internal/sharing/interface.js +1 -1
  34. package/dist/internal/sharing/sharingAccess.js +6 -0
  35. package/dist/internal/sharing/sharingAccess.js.map +1 -1
  36. package/dist/internal/sharing/sharingAccess.test.js +242 -33
  37. package/dist/internal/sharing/sharingAccess.test.js.map +1 -1
  38. package/dist/internal/sharingPublic/apiService.d.ts +1 -1
  39. package/dist/internal/sharingPublic/apiService.js +9 -2
  40. package/dist/internal/sharingPublic/apiService.js.map +1 -1
  41. package/dist/internal/sharingPublic/cryptoService.d.ts +6 -20
  42. package/dist/internal/sharingPublic/cryptoService.js +40 -103
  43. package/dist/internal/sharingPublic/cryptoService.js.map +1 -1
  44. package/dist/internal/sharingPublic/index.d.ts +2 -2
  45. package/dist/internal/sharingPublic/index.js +2 -2
  46. package/dist/internal/sharingPublic/index.js.map +1 -1
  47. package/dist/internal/sharingPublic/interface.d.ts +1 -43
  48. package/dist/internal/sharingPublic/manager.d.ts +1 -1
  49. package/dist/internal/sharingPublic/manager.js +9 -7
  50. package/dist/internal/sharingPublic/manager.js.map +1 -1
  51. package/dist/internal/upload/streamUploader.js +1 -1
  52. package/dist/internal/upload/streamUploader.js.map +1 -1
  53. package/dist/internal/upload/streamUploader.test.js +3 -1
  54. package/dist/internal/upload/streamUploader.test.js.map +1 -1
  55. package/dist/protonDriveClient.js +1 -0
  56. package/dist/protonDriveClient.js.map +1 -1
  57. package/dist/protonDrivePublicLinkClient.d.ts +13 -4
  58. package/dist/protonDrivePublicLinkClient.js +13 -11
  59. package/dist/protonDrivePublicLinkClient.js.map +1 -1
  60. package/package.json +1 -1
  61. package/src/crypto/driveCrypto.ts +1 -1
  62. package/src/crypto/interface.ts +1 -1
  63. package/src/crypto/openPGPCrypto.ts +5 -2
  64. package/src/internal/download/cryptoService.ts +2 -2
  65. package/src/internal/download/fileDownloader.test.ts +3 -1
  66. package/src/internal/download/fileDownloader.ts +2 -2
  67. package/src/internal/nodes/cache.ts +3 -1
  68. package/src/internal/nodes/cryptoReporter.ts +145 -0
  69. package/src/internal/nodes/cryptoService.test.ts +3 -1
  70. package/src/internal/nodes/cryptoService.ts +44 -137
  71. package/src/internal/nodes/index.ts +3 -1
  72. package/src/internal/nodes/interface.ts +1 -1
  73. package/src/internal/nodes/nodesAccess.ts +59 -61
  74. package/src/internal/sharing/cache.ts +19 -2
  75. package/src/internal/sharing/interface.ts +1 -1
  76. package/src/internal/sharing/sharingAccess.test.ts +282 -34
  77. package/src/internal/sharing/sharingAccess.ts +6 -0
  78. package/src/internal/sharingPublic/apiService.ts +11 -2
  79. package/src/internal/sharingPublic/cryptoService.ts +71 -135
  80. package/src/internal/sharingPublic/index.ts +3 -2
  81. package/src/internal/sharingPublic/interface.ts +8 -53
  82. package/src/internal/sharingPublic/manager.ts +9 -8
  83. package/src/internal/upload/streamUploader.test.ts +3 -1
  84. package/src/internal/upload/streamUploader.ts +1 -1
  85. package/src/protonDriveClient.ts +1 -0
  86. package/src/protonDrivePublicLinkClient.ts +26 -12
@@ -10,6 +10,7 @@ import { NodeAPIService } from './apiService';
10
10
  import { NodesCache } from './cache';
11
11
  import { NodesCryptoCache } from './cryptoCache';
12
12
  import { NodesCryptoService } from './cryptoService';
13
+ import { NodesCryptoReporter } from './cryptoReporter';
13
14
  import { SharesService } from './interface';
14
15
  import { NodesAccess } from './nodesAccess';
15
16
  import { NodesManagement } from './nodesManagement';
@@ -40,7 +41,8 @@ export function initNodesModule(
40
41
  const api = new NodeAPIService(telemetry.getLogger('nodes-api'), apiService);
41
42
  const cache = new NodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache);
42
43
  const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache);
43
- const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService);
44
+ const cryptoReporter = new NodesCryptoReporter(telemetry, sharesService);
45
+ const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, cryptoReporter);
44
46
  const nodesAccess = new NodesAccess(
45
47
  telemetry.getLogger('nodes'),
46
48
  api,
@@ -56,7 +56,7 @@ export interface EncryptedNodeCrypto {
56
56
  nameSignatureEmail?: string;
57
57
  armoredKey: string;
58
58
  armoredNodePassphrase: string;
59
- armoredNodePassphraseSignature: string;
59
+ armoredNodePassphraseSignature?: string;
60
60
  membership?: {
61
61
  inviterEmail: string;
62
62
  base64MemberSharePassphraseKeyPacket: string;
@@ -289,7 +289,7 @@ export class NodesAccess {
289
289
  }
290
290
 
291
291
  const { node: unparsedNode, keys } = await this.cryptoService.decryptNode(encryptedNode, parentKey);
292
- const node = await this.parseNode(unparsedNode);
292
+ const node = await parseNode(this.logger, unparsedNode);
293
293
  try {
294
294
  await this.cache.setNode(node);
295
295
  } catch (error: unknown) {
@@ -305,65 +305,6 @@ export class NodesAccess {
305
305
  return { node, keys };
306
306
  }
307
307
 
308
- private async parseNode(unparsedNode: DecryptedUnparsedNode): Promise<DecryptedNode> {
309
- let nodeName: Result<string, Error | InvalidNameError> = unparsedNode.name;
310
- if (unparsedNode.name.ok) {
311
- try {
312
- validateNodeName(unparsedNode.name.value);
313
- } catch (error: unknown) {
314
- this.logger.warn(`Node name validation failed: ${error instanceof Error ? error.message : error}`);
315
- nodeName = resultError({
316
- name: unparsedNode.name.value,
317
- error: error instanceof Error ? error.message : c('Error').t`Unknown error`,
318
- });
319
- }
320
- }
321
-
322
- if (unparsedNode.type === NodeType.File) {
323
- const extendedAttributes = unparsedNode.activeRevision?.ok
324
- ? parseFileExtendedAttributes(
325
- this.logger,
326
- unparsedNode.activeRevision.value.creationTime,
327
- unparsedNode.activeRevision.value.extendedAttributes,
328
- )
329
- : undefined;
330
-
331
- return {
332
- ...unparsedNode,
333
- isStale: false,
334
- activeRevision: !unparsedNode.activeRevision?.ok
335
- ? unparsedNode.activeRevision
336
- : resultOk({
337
- uid: unparsedNode.activeRevision.value.uid,
338
- state: unparsedNode.activeRevision.value.state,
339
- creationTime: unparsedNode.activeRevision.value.creationTime,
340
- storageSize: unparsedNode.activeRevision.value.storageSize,
341
- contentAuthor: unparsedNode.activeRevision.value.contentAuthor,
342
- thumbnails: unparsedNode.activeRevision.value.thumbnails,
343
- ...extendedAttributes,
344
- }),
345
- folder: undefined,
346
- treeEventScopeId: splitNodeUid(unparsedNode.uid).volumeId,
347
- };
348
- }
349
-
350
- const extendedAttributes = unparsedNode.folder?.extendedAttributes
351
- ? parseFolderExtendedAttributes(this.logger, unparsedNode.folder.extendedAttributes)
352
- : undefined;
353
- return {
354
- ...unparsedNode,
355
- name: nodeName,
356
- isStale: false,
357
- activeRevision: undefined,
358
- folder: extendedAttributes
359
- ? {
360
- ...extendedAttributes,
361
- }
362
- : undefined,
363
- treeEventScopeId: splitNodeUid(unparsedNode.uid).volumeId,
364
- };
365
- }
366
-
367
308
  async getParentKeys(
368
309
  node: Pick<DecryptedNode, 'parentUid' | 'shareId'>,
369
310
  ): Promise<Pick<DecryptedNodeKeys, 'key' | 'hashKey'>> {
@@ -375,7 +316,7 @@ export class NodesAccess {
375
316
  // Change the error message to be more specific.
376
317
  // Original error message is referring to node, while here
377
318
  // it referes to as parent to follow the method context.
378
- throw new DecryptionError(c('Error').t`Parent cannot be decrypted`);
319
+ throw new DecryptionError(c('Error').t`Parent cannot be decrypted`, { cause: error });
379
320
  }
380
321
  throw error;
381
322
  }
@@ -458,3 +399,60 @@ export class NodesAccess {
458
399
  return node.parentUid ? this.getRootNode(node.parentUid) : node;
459
400
  }
460
401
  }
402
+
403
+ export async function parseNode(logger: Logger, unparsedNode: DecryptedUnparsedNode): Promise<DecryptedNode> {
404
+ let nodeName: Result<string, Error | InvalidNameError> = unparsedNode.name;
405
+ if (unparsedNode.name.ok) {
406
+ try {
407
+ validateNodeName(unparsedNode.name.value);
408
+ } catch (error: unknown) {
409
+ logger.warn(`Node name validation failed: ${error instanceof Error ? error.message : error}`);
410
+ nodeName = resultError({
411
+ name: unparsedNode.name.value,
412
+ error: error instanceof Error ? error.message : c('Error').t`Unknown error`,
413
+ });
414
+ }
415
+ }
416
+
417
+ const treeEventScopeId = splitNodeUid(unparsedNode.uid).volumeId;
418
+
419
+ if (unparsedNode.type === NodeType.File) {
420
+ const extendedAttributes = unparsedNode.activeRevision?.ok
421
+ ? parseFileExtendedAttributes(
422
+ logger,
423
+ unparsedNode.activeRevision.value.creationTime,
424
+ unparsedNode.activeRevision.value.extendedAttributes,
425
+ )
426
+ : undefined;
427
+
428
+ return {
429
+ ...unparsedNode,
430
+ isStale: false,
431
+ activeRevision: !unparsedNode.activeRevision?.ok
432
+ ? unparsedNode.activeRevision
433
+ : resultOk({
434
+ uid: unparsedNode.activeRevision.value.uid,
435
+ state: unparsedNode.activeRevision.value.state,
436
+ creationTime: unparsedNode.activeRevision.value.creationTime,
437
+ storageSize: unparsedNode.activeRevision.value.storageSize,
438
+ contentAuthor: unparsedNode.activeRevision.value.contentAuthor,
439
+ thumbnails: unparsedNode.activeRevision.value.thumbnails,
440
+ ...extendedAttributes,
441
+ }),
442
+ folder: undefined,
443
+ treeEventScopeId,
444
+ };
445
+ }
446
+
447
+ const extendedAttributes = unparsedNode.folder?.extendedAttributes
448
+ ? parseFolderExtendedAttributes(logger, unparsedNode.folder.extendedAttributes)
449
+ : undefined;
450
+ return {
451
+ ...unparsedNode,
452
+ name: nodeName,
453
+ isStale: false,
454
+ activeRevision: undefined,
455
+ folder: extendedAttributes,
456
+ treeEventScopeId,
457
+ };
458
+ }
@@ -44,11 +44,28 @@ export class SharingCache {
44
44
  }
45
45
 
46
46
  async getSharedWithMeNodeUids(): Promise<string[]> {
47
- return this.getNodeUids(SharingType.sharedWithMe);
47
+ return this.getNodeUids(SharingType.SharedWithMe);
48
+ }
49
+
50
+ async hasSharedWithMeNodeUidsLoaded(): Promise<boolean> {
51
+ try {
52
+ await this.getNodeUids(SharingType.SharedWithMe);
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ async addSharedWithMeNodeUid(nodeUid: string): Promise<void> {
60
+ return this.addNodeUid(SharingType.SharedWithMe, nodeUid);
61
+ }
62
+
63
+ async removeSharedWithMeNodeUid(nodeUid: string): Promise<void> {
64
+ return this.removeNodeUid(SharingType.SharedWithMe, nodeUid);
48
65
  }
49
66
 
50
67
  async setSharedWithMeNodeUids(nodeUids: string[] | undefined): Promise<void> {
51
- return this.setNodeUids(SharingType.sharedWithMe, nodeUids);
68
+ return this.setNodeUids(SharingType.SharedWithMe, nodeUids);
52
69
  }
53
70
 
54
71
  /**
@@ -5,7 +5,7 @@ import { DecryptedNode } from '../nodes';
5
5
 
6
6
  export enum SharingType {
7
7
  SharedByMe = 'sharedByMe',
8
- sharedWithMe = 'sharedWithMe',
8
+ SharedWithMe = 'sharedWithMe',
9
9
  }
10
10
 
11
11
  /**
@@ -1,4 +1,6 @@
1
- import { NodeType, resultError, resultOk } from '../../interface';
1
+ import { getMockLogger } from '../../tests/logger';
2
+ import { NodeType, resultError, resultOk, MemberRole } from '../../interface';
3
+ import { ValidationError } from '../../errors';
2
4
  import { SharingAPIService } from './apiService';
3
5
  import { SharingCache } from './cache';
4
6
  import { SharingCryptoService } from './cryptoService';
@@ -14,8 +16,12 @@ describe('SharingAccess', () => {
14
16
 
15
17
  let sharingAccess: SharingAccess;
16
18
 
17
- const nodeUids = Array.from({ length: BATCH_LOADING_SIZE + 5 }, (_, i) => `nodeUid${i}`);
18
- const nodes = nodeUids.map((nodeUid) => ({ nodeUid }));
19
+ const nodeUids = Array.from({ length: BATCH_LOADING_SIZE + 5 }, (_, i) => `volumeId~nodeUid${i}`);
20
+ const nodes = nodeUids.map((nodeUid) => ({
21
+ nodeUid,
22
+ shareId: 'shareId',
23
+ name: { ok: true, value: `name${nodeUid.split('~')[1]}` }
24
+ }));
19
25
  const nodeUidsIterator = async function* () {
20
26
  for (const nodeUid of nodeUids) {
21
27
  yield nodeUid;
@@ -33,25 +39,67 @@ describe('SharingAccess', () => {
33
39
  creationTime: new Date('2025-01-01'),
34
40
  node: {
35
41
  type: NodeType.File,
36
- mediaType: 'mediaType',
42
+ mediaType: 'image/jpeg',
37
43
  },
38
44
  };
39
45
  }),
46
+ removeMember: jest.fn(),
47
+ iterateInvitationUids: jest.fn().mockImplementation(async function* () {
48
+ yield 'invitationUid';
49
+ }),
50
+ getInvitation: jest.fn().mockResolvedValue({
51
+ uid: 'invitationUid',
52
+ node: { uid: 'volumeId~nodeUid' },
53
+ inviteeEmail: 'invitee-email',
54
+ role: MemberRole.Viewer,
55
+ }),
56
+ acceptInvitation: jest.fn(),
57
+ rejectInvitation: jest.fn(),
58
+ deleteBookmark: jest.fn(),
40
59
  };
60
+
41
61
  // @ts-expect-error No need to implement all methods for mocking
42
62
  cache = {
43
63
  setSharedByMeNodeUids: jest.fn(),
44
64
  setSharedWithMeNodeUids: jest.fn(),
65
+ getSharedByMeNodeUids: jest.fn(),
66
+ getSharedWithMeNodeUids: jest.fn(),
67
+ hasSharedByMeNodeUidsLoaded: jest.fn().mockResolvedValue(true),
68
+ hasSharedWithMeNodeUidsLoaded: jest.fn().mockResolvedValue(true),
69
+ addSharedByMeNodeUid: jest.fn(),
70
+ removeSharedByMeNodeUid: jest.fn(),
71
+ addSharedWithMeNodeUid: jest.fn(),
72
+ removeSharedWithMeNodeUid: jest.fn(),
45
73
  };
74
+
46
75
  // @ts-expect-error No need to implement all methods for mocking
47
76
  cryptoService = {
48
77
  decryptInvitation: jest.fn(),
49
78
  decryptBookmark: jest.fn(),
79
+ decryptInvitationWithNode: jest.fn().mockResolvedValue({
80
+ uid: 'invitationUid',
81
+ inviteeEmail: 'invitee-email',
82
+ role: MemberRole.Viewer,
83
+ node: {
84
+ uid: 'volumeId~nodeUid',
85
+ name: { ok: true, value: 'SharedFile.txt' },
86
+ type: NodeType.File,
87
+ },
88
+ }),
89
+ acceptInvitation: jest.fn().mockResolvedValue({
90
+ base64SessionKeySignature: 'mockSignature',
91
+ }),
50
92
  };
93
+
51
94
  // @ts-expect-error No need to implement all methods for mocking
52
95
  sharesService = {
53
96
  getMyFilesIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId' }),
97
+ loadEncryptedShare: jest.fn().mockResolvedValue({
98
+ id: 'shareId',
99
+ membership: { memberUid: 'memberUid' },
100
+ }),
54
101
  };
102
+
55
103
  // @ts-expect-error No need to implement all methods for mocking
56
104
  nodesService = {
57
105
  iterateNodes: jest.fn().mockImplementation(async function* (nodeUids) {
@@ -61,13 +109,18 @@ describe('SharingAccess', () => {
61
109
  }
62
110
  }
63
111
  }),
112
+ getNode: jest.fn().mockResolvedValue({
113
+ nodeUid: 'volumeId~nodeUid',
114
+ shareId: 'shareId',
115
+ name: { ok: true, value: 'TestFile.txt' },
116
+ }),
64
117
  };
65
118
 
66
119
  sharingAccess = new SharingAccess(apiService, cache, cryptoService, sharesService, nodesService);
67
120
  });
68
121
 
69
122
  describe('iterateSharedNodes', () => {
70
- it('should iterate from cache', async () => {
123
+ it('should iterate from cache when available', async () => {
71
124
  cache.getSharedByMeNodeUids = jest.fn().mockResolvedValue(nodeUids);
72
125
 
73
126
  const result = await Array.fromAsync(sharingAccess.iterateSharedNodes());
@@ -77,20 +130,32 @@ describe('SharingAccess', () => {
77
130
  expect(cache.setSharedByMeNodeUids).not.toHaveBeenCalled();
78
131
  });
79
132
 
80
- it('should iterate from API', async () => {
81
- cache.getSharedByMeNodeUids = jest.fn().mockRejectedValue(new Error('Not cached'));
133
+ it('should iterate from API when cache is empty', async () => {
134
+ cache.getSharedByMeNodeUids = jest.fn().mockRejectedValue(new Error('Cache miss'));
82
135
 
83
136
  const result = await Array.fromAsync(sharingAccess.iterateSharedNodes());
84
137
 
85
138
  expect(result).toEqual(nodes);
86
139
  expect(apiService.iterateSharedNodeUids).toHaveBeenCalledWith('volumeId', undefined);
87
- expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2); // Mocked is a bit more over one batch
140
+ expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2);
88
141
  expect(cache.setSharedByMeNodeUids).toHaveBeenCalledWith(nodeUids);
89
142
  });
143
+
144
+ it('should ignore missing nodes during iteration', async () => {
145
+ cache.getSharedByMeNodeUids = jest.fn().mockResolvedValue(['volumeId~nodeUid1', 'volumeId~missingNode']);
146
+ nodesService.iterateNodes = jest.fn().mockImplementation(async function* () {
147
+ yield { nodeUid: 'volumeId~nodeUid1', name: { ok: true, value: 'file1.txt' } };
148
+ yield { missingUid: 'volumeId~missingNode' };
149
+ });
150
+
151
+ const result = await Array.fromAsync(sharingAccess.iterateSharedNodes());
152
+
153
+ expect(result).toEqual([{ nodeUid: 'volumeId~nodeUid1', name: { ok: true, value: 'file1.txt' } }]);
154
+ });
90
155
  });
91
156
 
92
157
  describe('iterateSharedNodesWithMe', () => {
93
- it('should iterate from cache', async () => {
158
+ it('should iterate from cache when available', async () => {
94
159
  cache.getSharedWithMeNodeUids = jest.fn().mockResolvedValue(nodeUids);
95
160
 
96
161
  const result = await Array.fromAsync(sharingAccess.iterateSharedNodesWithMe());
@@ -100,24 +165,149 @@ describe('SharingAccess', () => {
100
165
  expect(cache.setSharedWithMeNodeUids).not.toHaveBeenCalled();
101
166
  });
102
167
 
103
- it('should iterate from API', async () => {
104
- cache.getSharedWithMeNodeUids = jest.fn().mockRejectedValue(new Error('Not cached'));
168
+ it('should iterate from API when cache is empty', async () => {
169
+ cache.getSharedWithMeNodeUids = jest.fn().mockRejectedValue(new Error('Cache miss'));
105
170
 
106
171
  const result = await Array.fromAsync(sharingAccess.iterateSharedNodesWithMe());
107
172
 
108
173
  expect(result).toEqual(nodes);
109
174
  expect(apiService.iterateSharedWithMeNodeUids).toHaveBeenCalledWith(undefined);
110
- expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2); // Mocked is a bit more over one batch
175
+ expect(nodesService.iterateNodes).toHaveBeenCalledTimes(2);
111
176
  expect(cache.setSharedWithMeNodeUids).toHaveBeenCalledWith(nodeUids);
112
177
  });
113
178
  });
114
179
 
180
+ describe('removeSharedNodeWithMe', () => {
181
+ const nodeUid = 'volumeId~nodeUid';
182
+
183
+ it('should remove member and update cache', async () => {
184
+ await sharingAccess.removeSharedNodeWithMe(nodeUid);
185
+
186
+ expect(nodesService.getNode).toHaveBeenCalledWith(nodeUid);
187
+ expect(sharesService.loadEncryptedShare).toHaveBeenCalledWith('shareId');
188
+ expect(apiService.removeMember).toHaveBeenCalledWith('memberUid');
189
+ expect(cache.removeSharedWithMeNodeUid).toHaveBeenCalledWith(nodeUid);
190
+ });
191
+
192
+ it('should return early if node is not shared', async () => {
193
+ nodesService.getNode = jest.fn().mockResolvedValue({
194
+ nodeUid,
195
+ shareId: undefined,
196
+ name: { ok: true, value: 'UnsharedFile.txt' }
197
+ });
198
+
199
+ await sharingAccess.removeSharedNodeWithMe(nodeUid);
200
+
201
+ expect(sharesService.loadEncryptedShare).not.toHaveBeenCalled();
202
+ expect(apiService.removeMember).not.toHaveBeenCalled();
203
+ expect(cache.removeSharedWithMeNodeUid).not.toHaveBeenCalled();
204
+ });
205
+
206
+ it('should throw ValidationError if no membership found', async () => {
207
+ sharesService.loadEncryptedShare = jest.fn().mockResolvedValue({
208
+ id: 'shareId',
209
+ membership: undefined,
210
+ });
211
+
212
+ await expect(sharingAccess.removeSharedNodeWithMe(nodeUid)).rejects.toThrow(ValidationError);
213
+ expect(apiService.removeMember).not.toHaveBeenCalled();
214
+ expect(cache.removeSharedWithMeNodeUid).not.toHaveBeenCalled();
215
+ });
216
+ });
217
+
218
+ describe('iterateInvitations', () => {
219
+ it('should iterate and decrypt invitations', async () => {
220
+ const result = await Array.fromAsync(sharingAccess.iterateInvitations());
221
+
222
+ expect(result).toEqual([{
223
+ uid: 'invitationUid',
224
+ inviteeEmail: 'invitee-email',
225
+ role: MemberRole.Viewer,
226
+ node: {
227
+ uid: 'volumeId~nodeUid',
228
+ name: { ok: true, value: 'SharedFile.txt' },
229
+ type: NodeType.File,
230
+ },
231
+ }]);
232
+ expect(apiService.iterateInvitationUids).toHaveBeenCalledWith(undefined);
233
+ expect(apiService.getInvitation).toHaveBeenCalledWith('invitationUid');
234
+ expect(cryptoService.decryptInvitationWithNode).toHaveBeenCalledWith({
235
+ uid: 'invitationUid',
236
+ node: { uid: 'volumeId~nodeUid' },
237
+ inviteeEmail: 'invitee-email',
238
+ role: MemberRole.Viewer,
239
+ });
240
+ });
241
+ });
242
+
243
+ describe('acceptInvitation', () => {
244
+ it('should accept invitation and update cache', async () => {
245
+ const invitationUid = 'invitationUid';
246
+
247
+ await sharingAccess.acceptInvitation(invitationUid);
248
+
249
+ expect(apiService.getInvitation).toHaveBeenCalledWith(invitationUid);
250
+ expect(cryptoService.acceptInvitation).toHaveBeenCalledWith({
251
+ uid: 'invitationUid',
252
+ node: { uid: 'volumeId~nodeUid' },
253
+ inviteeEmail: 'invitee-email',
254
+ role: MemberRole.Viewer,
255
+ });
256
+ expect(apiService.acceptInvitation).toHaveBeenCalledWith(invitationUid, 'mockSignature');
257
+ expect(cache.addSharedWithMeNodeUid).toHaveBeenCalledWith('volumeId~nodeUid');
258
+ });
259
+
260
+ it('should not update cache when not loaded', async () => {
261
+ const invitationUid = 'invitationUid';
262
+ cache.hasSharedWithMeNodeUidsLoaded = jest.fn().mockResolvedValue(false);
263
+
264
+ await sharingAccess.acceptInvitation(invitationUid);
265
+
266
+ expect(apiService.acceptInvitation).toHaveBeenCalledWith(invitationUid, 'mockSignature');
267
+ expect(cache.addSharedWithMeNodeUid).not.toHaveBeenCalled();
268
+ });
269
+ });
270
+
271
+ describe('rejectInvitation', () => {
272
+ it('should reject invitation', async () => {
273
+ const invitationUid = 'invitationUid';
274
+
275
+ await sharingAccess.rejectInvitation(invitationUid);
276
+
277
+ expect(apiService.rejectInvitation).toHaveBeenCalledWith(invitationUid);
278
+ });
279
+ });
280
+
115
281
  describe('iterateBookmarks', () => {
116
- it('should return decrypted bookmark', async () => {
282
+ it('should return successfully decrypted bookmark', async () => {
283
+ cryptoService.decryptBookmark = jest.fn().mockResolvedValue({
284
+ url: resultOk('https://example.com/file.pdf'),
285
+ customPassword: resultOk('password123'),
286
+ nodeName: resultOk('ImportantDocument.pdf'),
287
+ });
288
+
289
+ const result = await Array.fromAsync(sharingAccess.iterateBookmarks());
290
+
291
+ expect(result).toEqual([
292
+ resultOk({
293
+ uid: 'tokenId',
294
+ creationTime: new Date('2025-01-01'),
295
+ url: 'https://example.com/file.pdf',
296
+ customPassword: 'password123',
297
+ node: {
298
+ name: 'ImportantDocument.pdf',
299
+ type: NodeType.File,
300
+ mediaType: 'image/jpeg',
301
+ },
302
+ }),
303
+ ]);
304
+ });
305
+
306
+ it('should return successfully decrypted bookmark with undefined password', async () => {
117
307
  cryptoService.decryptBookmark = jest.fn().mockResolvedValue({
118
- url: resultOk('url'),
119
- customPassword: resultOk('customPassword'),
120
- nodeName: resultOk('nodeName'),
308
+ url: resultOk('https://example.com/file.pdf'),
309
+ customPassword: resultOk(undefined),
310
+ nodeName: resultOk('PublicDocument.pdf'),
121
311
  });
122
312
 
123
313
  const result = await Array.fromAsync(sharingAccess.iterateBookmarks());
@@ -126,22 +316,46 @@ describe('SharingAccess', () => {
126
316
  resultOk({
127
317
  uid: 'tokenId',
128
318
  creationTime: new Date('2025-01-01'),
129
- url: 'url',
130
- customPassword: 'customPassword',
319
+ url: 'https://example.com/file.pdf',
320
+ customPassword: undefined,
321
+ node: {
322
+ name: 'PublicDocument.pdf',
323
+ type: NodeType.File,
324
+ mediaType: 'image/jpeg',
325
+ },
326
+ }),
327
+ ]);
328
+ });
329
+
330
+ it('should return degraded bookmark when URL cannot be decrypted', async () => {
331
+ cryptoService.decryptBookmark = jest.fn().mockResolvedValue({
332
+ url: resultError('URL decryption failed'),
333
+ customPassword: resultOk('password123'),
334
+ nodeName: resultOk('Document.pdf'),
335
+ });
336
+
337
+ const result = await Array.fromAsync(sharingAccess.iterateBookmarks());
338
+
339
+ expect(result).toEqual([
340
+ resultError({
341
+ uid: 'tokenId',
342
+ creationTime: new Date('2025-01-01'),
343
+ url: resultError('URL decryption failed'),
344
+ customPassword: resultOk('password123'),
131
345
  node: {
132
- name: 'nodeName',
346
+ name: resultOk('Document.pdf'),
133
347
  type: NodeType.File,
134
- mediaType: 'mediaType',
348
+ mediaType: 'image/jpeg',
135
349
  },
136
350
  }),
137
351
  ]);
138
352
  });
139
353
 
140
- it('should return degraded bookmark if URL password cannot be decrypted', async () => {
354
+ it('should return degraded bookmark when custom password cannot be decrypted', async () => {
141
355
  cryptoService.decryptBookmark = jest.fn().mockResolvedValue({
142
- url: resultError('url cannot be decrypted'),
143
- customPassword: resultOk('url cannot be decrypted'),
144
- nodeName: resultError('url cannot be decrypted'),
356
+ url: resultOk('https://example.com/file.pdf'),
357
+ customPassword: resultError('Password decryption failed'),
358
+ nodeName: resultOk('Document.pdf'),
145
359
  });
146
360
 
147
361
  const result = await Array.fromAsync(sharingAccess.iterateBookmarks());
@@ -150,22 +364,22 @@ describe('SharingAccess', () => {
150
364
  resultError({
151
365
  uid: 'tokenId',
152
366
  creationTime: new Date('2025-01-01'),
153
- url: resultError('url cannot be decrypted'),
154
- customPassword: resultOk('url cannot be decrypted'),
367
+ url: resultOk('https://example.com/file.pdf'),
368
+ customPassword: resultError('Password decryption failed'),
155
369
  node: {
156
- name: resultError('url cannot be decrypted'),
370
+ name: resultOk('Document.pdf'),
157
371
  type: NodeType.File,
158
- mediaType: 'mediaType',
372
+ mediaType: 'image/jpeg',
159
373
  },
160
374
  }),
161
375
  ]);
162
376
  });
163
377
 
164
- it('should return degraded bookmark if node name cannot be decrypted', async () => {
378
+ it('should return degraded bookmark when node name cannot be decrypted', async () => {
165
379
  cryptoService.decryptBookmark = jest.fn().mockResolvedValue({
166
- url: resultOk('url'),
380
+ url: resultOk('https://example.com/file.pdf'),
167
381
  customPassword: resultOk(undefined),
168
- nodeName: resultError('node name cannot be decrypted'),
382
+ nodeName: resultError('Node name decryption failed'),
169
383
  });
170
384
 
171
385
  const result = await Array.fromAsync(sharingAccess.iterateBookmarks());
@@ -174,15 +388,49 @@ describe('SharingAccess', () => {
174
388
  resultError({
175
389
  uid: 'tokenId',
176
390
  creationTime: new Date('2025-01-01'),
177
- url: resultOk('url'),
391
+ url: resultOk('https://example.com/file.pdf'),
178
392
  customPassword: resultOk(undefined),
179
393
  node: {
180
- name: resultError('node name cannot be decrypted'),
394
+ name: resultError('Node name decryption failed'),
395
+ type: NodeType.File,
396
+ mediaType: 'image/jpeg',
397
+ },
398
+ }),
399
+ ]);
400
+ });
401
+
402
+ it('should return degraded bookmark when all decryption fails', async () => {
403
+ cryptoService.decryptBookmark = jest.fn().mockResolvedValue({
404
+ url: resultError('URL decryption failed'),
405
+ customPassword: resultError('Password decryption failed'),
406
+ nodeName: resultError('Node name decryption failed'),
407
+ });
408
+
409
+ const result = await Array.fromAsync(sharingAccess.iterateBookmarks());
410
+
411
+ expect(result).toEqual([
412
+ resultError({
413
+ uid: 'tokenId',
414
+ creationTime: new Date('2025-01-01'),
415
+ url: resultError('URL decryption failed'),
416
+ customPassword: resultError('Password decryption failed'),
417
+ node: {
418
+ name: resultError('Node name decryption failed'),
181
419
  type: NodeType.File,
182
- mediaType: 'mediaType',
420
+ mediaType: 'image/jpeg',
183
421
  },
184
422
  }),
185
423
  ]);
186
424
  });
187
425
  });
426
+
427
+ describe('deleteBookmark', () => {
428
+ it('should delete bookmark using tokenId', async () => {
429
+ const bookmarkUid = 'tokenId123';
430
+
431
+ await sharingAccess.deleteBookmark(bookmarkUid);
432
+
433
+ expect(apiService.deleteBookmark).toHaveBeenCalledWith(bookmarkUid);
434
+ });
435
+ });
188
436
  });
@@ -124,6 +124,9 @@ export class SharingAccess {
124
124
  }
125
125
 
126
126
  await this.apiService.removeMember(memberUid);
127
+ if (await this.cache.hasSharedWithMeNodeUidsLoaded()) {
128
+ await this.cache.removeSharedWithMeNodeUid(nodeUid);
129
+ }
127
130
  }
128
131
 
129
132
  async *iterateInvitations(signal?: AbortSignal): AsyncGenerator<ProtonInvitationWithNode> {
@@ -138,6 +141,9 @@ export class SharingAccess {
138
141
  const encryptedInvitation = await this.apiService.getInvitation(invitationUid);
139
142
  const { base64SessionKeySignature } = await this.cryptoService.acceptInvitation(encryptedInvitation);
140
143
  await this.apiService.acceptInvitation(invitationUid, base64SessionKeySignature);
144
+ if (await this.cache.hasSharedWithMeNodeUidsLoaded()) {
145
+ await this.cache.addSharedWithMeNodeUid(encryptedInvitation.node.uid);
146
+ }
141
147
  }
142
148
 
143
149
  async rejectInvitation(invitationUid: string): Promise<void> {