@protontech/drive-sdk 0.14.4 → 0.14.5

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 (49) hide show
  1. package/dist/interface/account.d.ts +2 -1
  2. package/dist/internal/nodes/cryptoService.d.ts +3 -2
  3. package/dist/internal/nodes/cryptoService.js +11 -1
  4. package/dist/internal/nodes/cryptoService.js.map +1 -1
  5. package/dist/internal/nodes/cryptoService.test.js +40 -2
  6. package/dist/internal/nodes/cryptoService.test.js.map +1 -1
  7. package/dist/internal/nodes/index.js +1 -1
  8. package/dist/internal/nodes/index.js.map +1 -1
  9. package/dist/internal/nodes/nodesManagement.d.ts +2 -2
  10. package/dist/internal/nodes/nodesManagement.js +1 -1
  11. package/dist/internal/nodes/nodesManagement.js.map +1 -1
  12. package/dist/internal/nodes/nodesManagement.test.js +30 -0
  13. package/dist/internal/nodes/nodesManagement.test.js.map +1 -1
  14. package/dist/internal/photos/index.js +1 -1
  15. package/dist/internal/photos/index.js.map +1 -1
  16. package/dist/internal/sharing/apiService.d.ts +1 -1
  17. package/dist/internal/sharing/apiService.js +2 -2
  18. package/dist/internal/sharing/apiService.js.map +1 -1
  19. package/dist/internal/sharing/cryptoService.d.ts +1 -1
  20. package/dist/internal/sharing/cryptoService.js +2 -2
  21. package/dist/internal/sharing/cryptoService.js.map +1 -1
  22. package/dist/internal/sharing/sharingManagement.d.ts +2 -2
  23. package/dist/internal/sharing/sharingManagement.js +29 -2
  24. package/dist/internal/sharing/sharingManagement.js.map +1 -1
  25. package/dist/internal/sharing/sharingManagement.test.js +48 -0
  26. package/dist/internal/sharing/sharingManagement.test.js.map +1 -1
  27. package/dist/internal/sharingPublic/index.js +1 -1
  28. package/dist/internal/sharingPublic/index.js.map +1 -1
  29. package/dist/protonDriveClient.d.ts +11 -2
  30. package/dist/protonDriveClient.js +12 -0
  31. package/dist/protonDriveClient.js.map +1 -1
  32. package/dist/protonDrivePhotosClient.d.ts +10 -1
  33. package/dist/protonDrivePhotosClient.js +12 -0
  34. package/dist/protonDrivePhotosClient.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/interface/account.ts +2 -1
  37. package/src/internal/nodes/cryptoService.test.ts +52 -2
  38. package/src/internal/nodes/cryptoService.ts +16 -1
  39. package/src/internal/nodes/index.ts +1 -1
  40. package/src/internal/nodes/nodesManagement.test.ts +38 -2
  41. package/src/internal/nodes/nodesManagement.ts +15 -3
  42. package/src/internal/photos/index.ts +1 -1
  43. package/src/internal/sharing/apiService.ts +2 -1
  44. package/src/internal/sharing/cryptoService.ts +2 -1
  45. package/src/internal/sharing/sharingManagement.test.ts +73 -0
  46. package/src/internal/sharing/sharingManagement.ts +47 -3
  47. package/src/internal/sharingPublic/index.ts +7 -1
  48. package/src/protonDriveClient.ts +19 -1
  49. package/src/protonDrivePhotosClient.ts +17 -0
@@ -4,9 +4,9 @@ import { NodesCryptoService } from './cryptoService';
4
4
  import { NodesAccess } from './nodesAccess';
5
5
  import { DecryptedNode } from './interface';
6
6
  import { NodesManagement } from './nodesManagement';
7
- import { NodeResult } from '../../interface';
7
+ import { NodeResult, NodeResultWithError } from '../../interface';
8
8
  import { NodeOutOfSyncError } from './errors';
9
- import { ValidationError } from '../../errors';
9
+ import { NodeWithSameNameExistsValidationError, ValidationError } from '../../errors';
10
10
 
11
11
  describe('NodesManagement', () => {
12
12
  let apiService: NodeAPIService;
@@ -263,6 +263,42 @@ describe('NodesManagement', () => {
263
263
  );
264
264
  });
265
265
 
266
+ it('moveNodes yields NodeWithSameNameExistsValidationError in case of duplicate node name', async () => {
267
+ const encryptedCrypto = {
268
+ encryptedName: 'movedArmoredNodeName',
269
+ hash: 'movedHash',
270
+ armoredNodePassphrase: 'movedArmoredNodePassphrase',
271
+ armoredNodePassphraseSignature: 'movedArmoredNodePassphraseSignature',
272
+ signatureEmail: 'movedSignatureEmail',
273
+ nameSignatureEmail: 'movedNameSignatureEmail',
274
+ };
275
+ cryptoService.encryptNodeWithNewParent = jest.fn().mockResolvedValue(encryptedCrypto);
276
+ const error = new NodeWithSameNameExistsValidationError('Node with same name exists', 2500, 'existingNodeUid');
277
+ apiService.moveNode = jest.fn().mockRejectedValue(error);
278
+
279
+ const results: NodeResultWithError[] = [];
280
+ for await (const result of management.moveNodes(['nodeUid'], 'newParentNodeUid')) {
281
+ results.push(result);
282
+ }
283
+
284
+ expect(results).toHaveLength(1);
285
+ expect(results[0]).toEqual({ uid: 'nodeUid', ok: false, error });
286
+ expect(results[0].ok === false && results[0].error).toBeInstanceOf(NodeWithSameNameExistsValidationError);
287
+ });
288
+
289
+ it('moveNodes yields NodeResultWithError with Error on failure', async () => {
290
+ const error = new Error('move failed');
291
+ cryptoService.encryptNodeWithNewParent = jest.fn().mockRejectedValue(error);
292
+
293
+ const results: NodeResultWithError[] = [];
294
+ for await (const result of management.moveNodes(['nodeUid'], 'newParentNodeUid')) {
295
+ results.push(result);
296
+ }
297
+
298
+ expect(results).toHaveLength(1);
299
+ expect(results[0]).toEqual({ uid: 'nodeUid', ok: false, error });
300
+ });
301
+
266
302
  it('copyNode manages copy and updates cache', async () => {
267
303
  const encryptedCrypto = {
268
304
  encryptedName: 'copiedArmoredNodeName',
@@ -1,6 +1,14 @@
1
1
  import { c } from 'ttag';
2
2
 
3
- import { MemberRole, NodeType, NodeResult, NodeResultWithNewUid, resultOk, InvalidNameError } from '../../interface';
3
+ import {
4
+ MemberRole,
5
+ NodeType,
6
+ NodeResult,
7
+ NodeResultWithNewUid,
8
+ resultOk,
9
+ InvalidNameError,
10
+ NodeResultWithError,
11
+ } from '../../interface';
4
12
  import { AbortError, ValidationError } from '../../errors';
5
13
  import { createErrorFromUnknown, getErrorMessage } from '../errors';
6
14
  import { splitNodeUid } from '../uids';
@@ -107,7 +115,11 @@ export abstract class NodesManagementBase<
107
115
  }
108
116
 
109
117
  // Improvement requested: move nodes in parallel
110
- async *moveNodes(nodeUids: string[], newParentNodeUid: string, signal?: AbortSignal): AsyncGenerator<NodeResult> {
118
+ async *moveNodes(
119
+ nodeUids: string[],
120
+ newParentNodeUid: string,
121
+ signal?: AbortSignal,
122
+ ): AsyncGenerator<NodeResultWithError> {
111
123
  for (const nodeUid of nodeUids) {
112
124
  if (signal?.aborted) {
113
125
  throw new AbortError(c('Error').t`Move operation aborted`);
@@ -122,7 +134,7 @@ export abstract class NodesManagementBase<
122
134
  yield {
123
135
  uid: nodeUid,
124
136
  ok: false,
125
- error: getErrorMessage(error),
137
+ error: error instanceof Error ? error : new Error(getErrorMessage(error), { cause: error }),
126
138
  };
127
139
  }
128
140
  }
@@ -123,7 +123,7 @@ export function initPhotosNodesModule(
123
123
  const cache = new PhotosNodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache);
124
124
  const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache);
125
125
  const cryptoReporter = new NodesCryptoReporter(telemetry, sharesService);
126
- const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, cryptoReporter);
126
+ const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService, cryptoReporter);
127
127
  const nodesAccess = new PhotosNodesAccess(telemetry, api, cache, cryptoCache, cryptoService, sharesService);
128
128
  const nodesEventHandler = new NodesEventsHandler(telemetry.getLogger('nodes-events'), cache);
129
129
  const nodesManagement = new PhotosNodesManagement(api, cryptoCache, cryptoService, nodesAccess);
@@ -433,6 +433,7 @@ export class SharingAPIService {
433
433
  shareId: string,
434
434
  invitation: EncryptedInvitationRequest,
435
435
  emailDetails: { message?: string; nodeName?: string } = {},
436
+ externalInvitationId: string | null = null,
436
437
  ): Promise<EncryptedInvitation> {
437
438
  const response = await this.apiService.post<PostInviteProtonUserRequest, PostInviteProtonUserResponse>(
438
439
  `drive/v2/shares/${shareId}/invitations`,
@@ -443,7 +444,7 @@ export class SharingAPIService {
443
444
  Permissions: memberRoleToPermission(invitation.role),
444
445
  KeyPacket: invitation.base64KeyPacket,
445
446
  KeyPacketSignature: invitation.base64KeyPacketSignature,
446
- ExternalInvitationID: null,
447
+ ExternalInvitationID: externalInvitationId,
447
448
  },
448
449
  EmailDetails: {
449
450
  Message: emailDetails.message,
@@ -182,11 +182,12 @@ export class SharingCryptoService {
182
182
  shareSessionKey: SessionKey,
183
183
  inviterKey: PrivateKey,
184
184
  inviteeEmail: string,
185
+ forceRefreshKeys?: boolean,
185
186
  ): Promise<{
186
187
  base64KeyPacket: string;
187
188
  base64KeyPacketSignature: string;
188
189
  }> {
189
- const inviteePublicKeys = await this.account.getPublicKeys(inviteeEmail);
190
+ const inviteePublicKeys = await this.account.getPublicKeys(inviteeEmail, forceRefreshKeys);
190
191
  const result = await this.driveCrypto.encryptInvitation(shareSessionKey, inviteePublicKeys[0], inviterKey);
191
192
  return result;
192
193
  }
@@ -1138,4 +1138,77 @@ describe('SharingManagement', () => {
1138
1138
  expect(apiService.resendExternalInvitationEmail).not.toHaveBeenCalled();
1139
1139
  });
1140
1140
  });
1141
+
1142
+ describe('convertNonProtonInvitation', () => {
1143
+ const nodeUid = 'volumeId~nodeId';
1144
+ const externalInvitationId = 'inv123';
1145
+ const externalInvitationUid = `${DEFAULT_SHARE_ID}~${externalInvitationId}`;
1146
+ const externalInvitation: NonProtonInvitation = {
1147
+ uid: externalInvitationUid,
1148
+ inviteeEmail: 'external@example.com',
1149
+ addedByEmail: resultOk('inviter@example.com'),
1150
+ role: MemberRole.Viewer,
1151
+ invitationTime: new Date(),
1152
+ state: NonProtonInvitationState.Pending,
1153
+ };
1154
+
1155
+ beforeEach(() => {
1156
+ nodesService.getNode = jest.fn().mockResolvedValue({
1157
+ nodeUid,
1158
+ shareId: DEFAULT_SHARE_ID,
1159
+ directRole: MemberRole.Admin,
1160
+ name: { ok: true, value: 'name' },
1161
+ });
1162
+ apiService.getShareExternalInvitations = jest.fn().mockResolvedValue([externalInvitation]);
1163
+ });
1164
+
1165
+ it('should throw if caller is not admin', async () => {
1166
+ nodesService.getNode = jest.fn().mockResolvedValue({
1167
+ nodeUid,
1168
+ shareId: DEFAULT_SHARE_ID,
1169
+ directRole: MemberRole.Viewer,
1170
+ name: { ok: true, value: 'name' },
1171
+ });
1172
+
1173
+ await expect(
1174
+ sharingManagement.convertNonProtonInvitation(nodeUid, externalInvitationUid),
1175
+ ).rejects.toThrow(ValidationError);
1176
+ });
1177
+
1178
+ it('should throw if no sharing info found', async () => {
1179
+ nodesService.getNode = jest.fn().mockResolvedValue({
1180
+ nodeUid,
1181
+ shareId: undefined,
1182
+ directRole: MemberRole.Admin,
1183
+ name: { ok: true, value: 'name' },
1184
+ });
1185
+
1186
+ await expect(
1187
+ sharingManagement.convertNonProtonInvitation(nodeUid, externalInvitationUid),
1188
+ ).rejects.toThrow(ValidationError);
1189
+ });
1190
+
1191
+ it('should throw if external invitation ID is not found', async () => {
1192
+ await expect(
1193
+ sharingManagement.convertNonProtonInvitation(nodeUid, 'unknownShareId~unknownInvId'),
1194
+ ).rejects.toThrow(ValidationError);
1195
+ });
1196
+
1197
+ it('should invite proton user with force-refreshed keys and the external invitation ID', async () => {
1198
+ await sharingManagement.convertNonProtonInvitation(nodeUid, externalInvitationUid);
1199
+
1200
+ expect(cryptoService.encryptInvitation).toHaveBeenCalledWith(
1201
+ expect.anything(),
1202
+ expect.anything(),
1203
+ externalInvitation.inviteeEmail,
1204
+ true,
1205
+ );
1206
+ expect(apiService.inviteProtonUser).toHaveBeenCalledWith(
1207
+ DEFAULT_SHARE_ID,
1208
+ expect.objectContaining({ inviteeEmail: externalInvitation.inviteeEmail, role: externalInvitation.role }),
1209
+ {},
1210
+ externalInvitationId,
1211
+ );
1212
+ });
1213
+ });
1141
1214
  });
@@ -16,7 +16,7 @@ import {
16
16
  SharePublicLinkSettingsObject,
17
17
  } from '../../interface';
18
18
  import { ErrorCode } from '../apiService';
19
- import { splitNodeUid } from '../uids';
19
+ import { splitNodeUid, splitInvitationUid } from '../uids';
20
20
  import { getErrorMessage } from '../errors';
21
21
  import { SharingAPIService } from './apiService';
22
22
  import { PUBLIC_LINK_GENERATED_PASSWORD_LENGTH, SharingCryptoService } from './cryptoService';
@@ -596,8 +596,52 @@ export class SharingManagement {
596
596
  await this.apiService.deleteExternalInvitation(invitationUid);
597
597
  }
598
598
 
599
- private async convertExternalInvitationsToInternal(): Promise<void> {
600
- // FIXME
599
+ async convertNonProtonInvitation(nodeUid: string, nonProtonInvitationUid: string): Promise<ProtonInvitation> {
600
+ const { invitationId: externalInvitationId } = splitInvitationUid(nonProtonInvitationUid);
601
+
602
+ const node = await this.nodesService.getNode(nodeUid);
603
+ if (node.directRole !== MemberRole.Admin) {
604
+ throw new ValidationError(c('Error').t`Only admins can convert non-Proton invitations`);
605
+ }
606
+
607
+ const [currentSharing, inviter] = await Promise.all([
608
+ this.getInternalSharingInfo(nodeUid),
609
+ this.nodesService.getRootNodeEmailKey(nodeUid),
610
+ ]);
611
+ if (!currentSharing) {
612
+ throw new ValidationError(c('Error').t`The node is not shared anymore`);
613
+ }
614
+
615
+ const externalInvitation = currentSharing.nonProtonInvitations.find(
616
+ (invitation) => invitation.uid === nonProtonInvitationUid,
617
+ );
618
+ if (!externalInvitation) {
619
+ throw new ValidationError(c('Error').t`Invitation not found`);
620
+ }
621
+ this.logger.info(
622
+ `Converting non-Proton invitation for ${externalInvitation.inviteeEmail} to internal for node ${nodeUid}`,
623
+ );
624
+ const invitationCrypto = await this.cryptoService.encryptInvitation(
625
+ currentSharing.share.passphraseSessionKey,
626
+ inviter.addressKey,
627
+ externalInvitation.inviteeEmail,
628
+ true, // Force refresh keys: the invitee just created a Proton account, so we have "absent" keys in cache
629
+ );
630
+ const encryptedInvitation = await this.apiService.inviteProtonUser(
631
+ currentSharing.share.shareId,
632
+ {
633
+ addedByEmail: inviter.email,
634
+ inviteeEmail: externalInvitation.inviteeEmail,
635
+ role: externalInvitation.role,
636
+ ...invitationCrypto,
637
+ },
638
+ {},
639
+ externalInvitationId,
640
+ );
641
+ return {
642
+ ...encryptedInvitation,
643
+ addedByEmail: resultOk(encryptedInvitation.addedByEmail),
644
+ };
601
645
  }
602
646
 
603
647
  private async removeMember(memberUid: string): Promise<void> {
@@ -100,7 +100,13 @@ export function initSharingPublicNodesModule(
100
100
  const cache = new NodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache);
101
101
  const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache);
102
102
  const cryptoReporter = new SharingPublicCryptoReporter(telemetry);
103
- const cryptoService = new SharingPublicNodesCryptoService(telemetry, driveCrypto, account, cryptoReporter);
103
+ const cryptoService = new SharingPublicNodesCryptoService(
104
+ telemetry,
105
+ driveCrypto,
106
+ account,
107
+ sharesService,
108
+ cryptoReporter,
109
+ );
104
110
  const nodesAccess = new SharingPublicNodesAccess(
105
111
  telemetry,
106
112
  api,
@@ -8,11 +8,13 @@ import {
8
8
  MaybeNode,
9
9
  MaybeMissingNode,
10
10
  NodeResult,
11
+ NodeResultWithError,
11
12
  NodeResultWithNewUid,
12
13
  Revision,
13
14
  RevisionOrUid,
14
15
  ShareNodeSettings,
15
16
  UnshareNodeSettings,
17
+ ProtonInvitation,
16
18
  ProtonInvitationOrUid,
17
19
  NonProtonInvitationOrUid,
18
20
  ProtonInvitationWithNode,
@@ -420,7 +422,7 @@ export class ProtonDriveClient {
420
422
  nodeUids: NodeOrUid[],
421
423
  newParentNodeUid: NodeOrUid,
422
424
  signal?: AbortSignal,
423
- ): AsyncGenerator<NodeResult> {
425
+ ): AsyncGenerator<NodeResultWithError> {
424
426
  this.logger.info(`Moving ${nodeUids.length} nodes to ${getUid(newParentNodeUid)}`);
425
427
  yield* this.nodes.management.moveNodes(getUids(nodeUids), getUid(newParentNodeUid), signal);
426
428
  }
@@ -749,6 +751,22 @@ export class ProtonDriveClient {
749
751
  return this.sharing.management.unshareNode(getUid(nodeUid), settings);
750
752
  }
751
753
 
754
+ /**
755
+ * Convert a non-Proton invitation to an internal invitation.
756
+ * This is called automatically in the background when the SDK receives
757
+ * a metadata update event, but can also be triggered manually.
758
+ *
759
+ * @param nodeUid - Node entity or its UID string.
760
+ * @param invitationOrUid - Non-Proton invitation entity or its UID string.
761
+ */
762
+ async convertNonProtonInvitation(
763
+ nodeUid: NodeOrUid,
764
+ invitationOrUid: NonProtonInvitationOrUid,
765
+ ): Promise<ProtonInvitation> {
766
+ this.logger.info(`Converting non-Proton invitation ${getUid(invitationOrUid)} for node ${getUid(nodeUid)}`);
767
+ return this.sharing.management.convertNonProtonInvitation(getUid(nodeUid), getUid(invitationOrUid));
768
+ }
769
+
752
770
  /**
753
771
  * Resend the invitation email to shared node.
754
772
  *
@@ -13,6 +13,7 @@ import {
13
13
  ShareNodeSettings,
14
14
  ShareResult,
15
15
  UnshareNodeSettings,
16
+ ProtonInvitation,
16
17
  ProtonInvitationOrUid,
17
18
  NonProtonInvitationOrUid,
18
19
  ProtonInvitationWithNode,
@@ -408,6 +409,22 @@ export class ProtonDrivePhotosClient {
408
409
  return this.sharing.management.unshareNode(getUid(nodeUid), settings);
409
410
  }
410
411
 
412
+ /**
413
+ * Convert a non-Proton invitation to an internal invitation.
414
+ * This is called automatically in the background when the SDK receives
415
+ * a metadata update event, but can also be triggered manually.
416
+ *
417
+ * @param nodeUid - Node entity or its UID string.
418
+ * @param invitationOrUid - Non-Proton invitation entity or its UID string.
419
+ */
420
+ async convertNonProtonInvitation(
421
+ nodeUid: NodeOrUid,
422
+ invitationOrUid: NonProtonInvitationOrUid,
423
+ ): Promise<ProtonInvitation> {
424
+ this.logger.info(`Converting non-Proton invitation ${getUid(invitationOrUid)} for node ${getUid(nodeUid)}`);
425
+ return this.sharing.management.convertNonProtonInvitation(getUid(nodeUid), getUid(invitationOrUid));
426
+ }
427
+
411
428
  /**
412
429
  * Resend the invitation email to shared node.
413
430
  *