@protontech/drive-sdk 0.12.1 → 0.13.1

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 (154) hide show
  1. package/dist/crypto/driveCrypto.d.ts +1 -0
  2. package/dist/crypto/driveCrypto.js +1 -0
  3. package/dist/crypto/driveCrypto.js.map +1 -1
  4. package/dist/interface/events.d.ts +1 -1
  5. package/dist/interface/featureFlags.d.ts +2 -1
  6. package/dist/interface/featureFlags.js +1 -0
  7. package/dist/interface/featureFlags.js.map +1 -1
  8. package/dist/interface/httpClient.d.ts +1 -0
  9. package/dist/interface/nodes.d.ts +7 -0
  10. package/dist/interface/nodes.js.map +1 -1
  11. package/dist/interface/telemetry.d.ts +4 -0
  12. package/dist/interface/telemetry.js.map +1 -1
  13. package/dist/internal/apiService/apiService.d.ts +1 -0
  14. package/dist/internal/apiService/apiService.js +16 -7
  15. package/dist/internal/apiService/apiService.js.map +1 -1
  16. package/dist/internal/apiService/apiService.test.js +24 -0
  17. package/dist/internal/apiService/apiService.test.js.map +1 -1
  18. package/dist/internal/apiService/driveTypes.d.ts +162 -101
  19. package/dist/internal/download/telemetry.js +4 -0
  20. package/dist/internal/download/telemetry.js.map +1 -1
  21. package/dist/internal/download/telemetry.test.js +5 -0
  22. package/dist/internal/download/telemetry.test.js.map +1 -1
  23. package/dist/internal/events/index.js +2 -2
  24. package/dist/internal/events/index.js.map +1 -1
  25. package/dist/internal/events/interface.d.ts +1 -1
  26. package/dist/internal/nodes/apiService.d.ts +4 -0
  27. package/dist/internal/nodes/apiService.js +4 -0
  28. package/dist/internal/nodes/apiService.js.map +1 -1
  29. package/dist/internal/nodes/apiService.test.js +8 -0
  30. package/dist/internal/nodes/apiService.test.js.map +1 -1
  31. package/dist/internal/nodes/cryptoService.js +3 -0
  32. package/dist/internal/nodes/cryptoService.js.map +1 -1
  33. package/dist/internal/nodes/extendedAttributes.d.ts +5 -5
  34. package/dist/internal/nodes/extendedAttributes.js +5 -14
  35. package/dist/internal/nodes/extendedAttributes.js.map +1 -1
  36. package/dist/internal/nodes/extendedAttributes.test.js +16 -22
  37. package/dist/internal/nodes/extendedAttributes.test.js.map +1 -1
  38. package/dist/internal/nodes/interface.d.ts +5 -0
  39. package/dist/internal/nodes/nodesManagement.d.ts +3 -3
  40. package/dist/internal/nodes/nodesManagement.js +7 -5
  41. package/dist/internal/nodes/nodesManagement.js.map +1 -1
  42. package/dist/internal/photos/albumsManager.js +1 -0
  43. package/dist/internal/photos/albumsManager.js.map +1 -1
  44. package/dist/internal/photos/nodes.d.ts +1 -1
  45. package/dist/internal/photos/nodes.js +2 -2
  46. package/dist/internal/photos/nodes.js.map +1 -1
  47. package/dist/internal/photos/upload.d.ts +5 -5
  48. package/dist/internal/photos/upload.js +8 -2
  49. package/dist/internal/photos/upload.js.map +1 -1
  50. package/dist/internal/sharing/apiService.js +1 -1
  51. package/dist/internal/sharing/apiService.js.map +1 -1
  52. package/dist/internal/sharingPublic/nodes.d.ts +1 -0
  53. package/dist/internal/upload/apiService.d.ts +45 -1
  54. package/dist/internal/upload/apiService.js +69 -1
  55. package/dist/internal/upload/apiService.js.map +1 -1
  56. package/dist/internal/upload/blockVerifier.d.ts +4 -1
  57. package/dist/internal/upload/blockVerifier.js +5 -0
  58. package/dist/internal/upload/blockVerifier.js.map +1 -1
  59. package/dist/internal/upload/cryptoService.d.ts +2 -2
  60. package/dist/internal/upload/cryptoService.js +1 -3
  61. package/dist/internal/upload/cryptoService.js.map +1 -1
  62. package/dist/internal/upload/fileUploader.d.ts +4 -3
  63. package/dist/internal/upload/fileUploader.js +17 -7
  64. package/dist/internal/upload/fileUploader.js.map +1 -1
  65. package/dist/internal/upload/index.d.ts +3 -3
  66. package/dist/internal/upload/index.js +17 -1
  67. package/dist/internal/upload/index.js.map +1 -1
  68. package/dist/internal/upload/index.test.d.ts +1 -0
  69. package/dist/internal/upload/index.test.js +71 -0
  70. package/dist/internal/upload/index.test.js.map +1 -0
  71. package/dist/internal/upload/interface.d.ts +2 -0
  72. package/dist/internal/upload/manager.d.ts +41 -2
  73. package/dist/internal/upload/manager.js +126 -44
  74. package/dist/internal/upload/manager.js.map +1 -1
  75. package/dist/internal/upload/manager.test.js +268 -1
  76. package/dist/internal/upload/manager.test.js.map +1 -1
  77. package/dist/internal/upload/smallFileUploader.d.ts +83 -0
  78. package/dist/internal/upload/smallFileUploader.js +197 -0
  79. package/dist/internal/upload/smallFileUploader.js.map +1 -0
  80. package/dist/internal/upload/smallFileUploader.test.d.ts +1 -0
  81. package/dist/internal/upload/smallFileUploader.test.js +358 -0
  82. package/dist/internal/upload/smallFileUploader.test.js.map +1 -0
  83. package/dist/internal/upload/streamReader.d.ts +4 -0
  84. package/dist/internal/upload/streamReader.js +37 -0
  85. package/dist/internal/upload/streamReader.js.map +1 -0
  86. package/dist/internal/upload/streamReader.test.d.ts +1 -0
  87. package/dist/internal/upload/streamReader.test.js +90 -0
  88. package/dist/internal/upload/streamReader.test.js.map +1 -0
  89. package/dist/internal/upload/streamUploader.d.ts +6 -0
  90. package/dist/internal/upload/streamUploader.js +3 -3
  91. package/dist/internal/upload/streamUploader.js.map +1 -1
  92. package/dist/internal/upload/telemetry.d.ts +3 -2
  93. package/dist/internal/upload/telemetry.js +5 -0
  94. package/dist/internal/upload/telemetry.js.map +1 -1
  95. package/dist/internal/upload/telemetry.test.js +6 -0
  96. package/dist/internal/upload/telemetry.test.js.map +1 -1
  97. package/dist/protonDrivePhotosClient.d.ts +1 -1
  98. package/dist/protonDrivePublicLinkClient.js +3 -1
  99. package/dist/protonDrivePublicLinkClient.js.map +1 -1
  100. package/dist/telemetry.d.ts +1 -0
  101. package/dist/telemetry.js +21 -0
  102. package/dist/telemetry.js.map +1 -1
  103. package/dist/telemetry.test.d.ts +1 -0
  104. package/dist/telemetry.test.js +37 -0
  105. package/dist/telemetry.test.js.map +1 -0
  106. package/dist/transformers.d.ts +1 -1
  107. package/dist/transformers.js +1 -0
  108. package/dist/transformers.js.map +1 -1
  109. package/package.json +1 -1
  110. package/src/crypto/driveCrypto.ts +2 -0
  111. package/src/interface/events.ts +1 -1
  112. package/src/interface/featureFlags.ts +1 -0
  113. package/src/interface/httpClient.ts +1 -0
  114. package/src/interface/nodes.ts +7 -0
  115. package/src/interface/telemetry.ts +4 -0
  116. package/src/internal/apiService/apiService.test.ts +30 -0
  117. package/src/internal/apiService/apiService.ts +23 -7
  118. package/src/internal/apiService/driveTypes.ts +162 -101
  119. package/src/internal/download/telemetry.test.ts +5 -0
  120. package/src/internal/download/telemetry.ts +5 -1
  121. package/src/internal/events/index.ts +2 -2
  122. package/src/internal/events/interface.ts +1 -1
  123. package/src/internal/nodes/apiService.test.ts +9 -0
  124. package/src/internal/nodes/apiService.ts +4 -0
  125. package/src/internal/nodes/cryptoService.ts +11 -1
  126. package/src/internal/nodes/extendedAttributes.test.ts +25 -25
  127. package/src/internal/nodes/extendedAttributes.ts +10 -19
  128. package/src/internal/nodes/interface.ts +5 -0
  129. package/src/internal/nodes/nodesManagement.ts +8 -6
  130. package/src/internal/photos/albumsManager.ts +1 -0
  131. package/src/internal/photos/nodes.ts +2 -2
  132. package/src/internal/photos/upload.ts +23 -10
  133. package/src/internal/sharing/apiService.ts +5 -5
  134. package/src/internal/upload/apiService.ts +167 -2
  135. package/src/internal/upload/blockVerifier.ts +12 -0
  136. package/src/internal/upload/cryptoService.ts +10 -10
  137. package/src/internal/upload/fileUploader.ts +20 -7
  138. package/src/internal/upload/index.test.ts +99 -0
  139. package/src/internal/upload/index.ts +45 -4
  140. package/src/internal/upload/interface.ts +2 -0
  141. package/src/internal/upload/manager.test.ts +368 -2
  142. package/src/internal/upload/manager.ts +229 -78
  143. package/src/internal/upload/smallFileUploader.test.ts +491 -0
  144. package/src/internal/upload/smallFileUploader.ts +353 -0
  145. package/src/internal/upload/streamReader.test.ts +109 -0
  146. package/src/internal/upload/streamReader.ts +38 -0
  147. package/src/internal/upload/streamUploader.ts +1 -1
  148. package/src/internal/upload/telemetry.test.ts +6 -0
  149. package/src/internal/upload/telemetry.ts +8 -2
  150. package/src/protonDrivePhotosClient.ts +1 -1
  151. package/src/protonDrivePublicLinkClient.ts +2 -0
  152. package/src/telemetry.test.ts +40 -0
  153. package/src/telemetry.ts +22 -0
  154. package/src/transformers.ts +2 -0
@@ -1,14 +1,18 @@
1
- import { FeatureFlagProvider, ProtonDriveTelemetry, UploadMetadata } from '../../interface';
1
+ import { FeatureFlagProvider, FeatureFlags, ProtonDriveTelemetry, UploadMetadata } from '../../interface';
2
+ import type { FileUploader } from '../../interface';
2
3
  import { DriveAPIService } from '../apiService';
3
4
  import { DriveCrypto } from '../../crypto';
4
5
  import { UploadAPIService } from './apiService';
5
6
  import { UploadCryptoService } from './cryptoService';
6
- import { FileUploader, FileRevisionUploader } from './fileUploader';
7
+ import { FileUploader as FileUploaderClass, FileRevisionUploader } from './fileUploader';
7
8
  import { NodesService, SharesService } from './interface';
8
9
  import { UploadManager } from './manager';
9
10
  import { UploadQueue } from './queue';
11
+ import { SmallFileRevisionUploader, SmallFileUploader } from './smallFileUploader';
10
12
  import { UploadTelemetry } from './telemetry';
11
13
 
14
+ const SMALL_FILE_SIZE_LIMIT = 128 * 1024; // 128 KiB
15
+
12
16
  /**
13
17
  * Provides facade for the upload module.
14
18
  *
@@ -24,6 +28,7 @@ export function initUploadModule(
24
28
  nodesService: NodesService,
25
29
  featureFlagProvider: FeatureFlagProvider,
26
30
  clientUid?: string,
31
+ allowSmallFileUpload: boolean = true,
27
32
  ) {
28
33
  const api = new UploadAPIService(apiService, clientUid);
29
34
  const cryptoService = new UploadCryptoService(telemetry, driveCrypto, nodesService, featureFlagProvider);
@@ -33,6 +38,15 @@ export function initUploadModule(
33
38
 
34
39
  const queue = new UploadQueue();
35
40
 
41
+ async function useSmallFileUpload(metadata: UploadMetadata): Promise<boolean> {
42
+ const isEnabled =
43
+ allowSmallFileUpload && (await featureFlagProvider.isEnabled(FeatureFlags.DriveSmallFileUpload));
44
+ if (!isEnabled) {
45
+ return false;
46
+ }
47
+ return metadata.expectedSize < SMALL_FILE_SIZE_LIMIT;
48
+ }
49
+
36
50
  /**
37
51
  * Returns a FileUploader instance that can be used to upload a file to
38
52
  * a parent folder.
@@ -52,7 +66,21 @@ export function initUploadModule(
52
66
  queue.releaseCapacity(metadata.expectedSize);
53
67
  };
54
68
 
55
- return new FileUploader(
69
+ if (await useSmallFileUpload(metadata)) {
70
+ return new SmallFileUploader(
71
+ uploadTelemetry,
72
+ api,
73
+ cryptoService,
74
+ manager,
75
+ metadata,
76
+ onFinish,
77
+ signal,
78
+ parentFolderUid,
79
+ name,
80
+ );
81
+ }
82
+
83
+ return new FileUploaderClass(
56
84
  uploadTelemetry,
57
85
  api,
58
86
  cryptoService,
@@ -76,13 +104,26 @@ export function initUploadModule(
76
104
  nodeUid: string,
77
105
  metadata: UploadMetadata,
78
106
  signal?: AbortSignal,
79
- ): Promise<FileRevisionUploader> {
107
+ ): Promise<FileUploader> {
80
108
  await queue.waitForCapacity(metadata.expectedSize, signal);
81
109
 
82
110
  const onFinish = () => {
83
111
  queue.releaseCapacity(metadata.expectedSize);
84
112
  };
85
113
 
114
+ if (await useSmallFileUpload(metadata)) {
115
+ return new SmallFileRevisionUploader(
116
+ uploadTelemetry,
117
+ api,
118
+ cryptoService,
119
+ manager,
120
+ metadata,
121
+ onFinish,
122
+ signal,
123
+ nodeUid,
124
+ );
125
+ }
126
+
86
127
  return new FileRevisionUploader(
87
128
  uploadTelemetry,
88
129
  api,
@@ -40,6 +40,7 @@ export type NodeCrypto = {
40
40
  };
41
41
  contentKey: {
42
42
  encrypted: {
43
+ contentKeyPacket: Uint8Array<ArrayBuffer>;
43
44
  base64ContentKeyPacket: string;
44
45
  armoredContentKeyPacketSignature: string;
45
46
  };
@@ -100,6 +101,7 @@ export interface NodesService {
100
101
  getNodeKeys(nodeUid: string): Promise<{
101
102
  key: PrivateKey;
102
103
  passphraseSessionKey: SessionKey;
104
+ contentKeyPacket?: Uint8Array<ArrayBuffer>;
103
105
  contentKeyPacketSessionKey?: SessionKey;
104
106
  hashKey?: Uint8Array<ArrayBuffer>;
105
107
  }>;
@@ -1,5 +1,5 @@
1
1
  import { ValidationError } from '../../errors';
2
- import { ProtonDriveTelemetry, UploadMetadata } from '../../interface';
2
+ import { ProtonDriveTelemetry, ThumbnailType, UploadMetadata } from '../../interface';
3
3
  import { getMockTelemetry } from '../../tests/telemetry';
4
4
  import { ErrorCode } from '../apiService';
5
5
  import { UploadAPIService } from './apiService';
@@ -27,6 +27,14 @@ describe('UploadManager', () => {
27
27
  }),
28
28
  deleteDraft: jest.fn(),
29
29
  commitDraftRevision: jest.fn(),
30
+ uploadSmallFile: jest.fn().mockResolvedValue({
31
+ nodeUid: 'uploaded:nodeUid',
32
+ nodeRevisionUid: 'uploaded:nodeRevisionUid',
33
+ }),
34
+ uploadSmallRevision: jest.fn().mockResolvedValue({
35
+ nodeUid: 'revised:nodeUid',
36
+ nodeRevisionUid: 'revised:nodeRevisionUid',
37
+ }),
30
38
  };
31
39
  // @ts-expect-error No need to implement all methods for mocking
32
40
  cryptoService = {
@@ -59,6 +67,12 @@ describe('UploadManager', () => {
59
67
  signatureEmail: 'signatureEmail',
60
68
  armoredExtendedAttributes: 'newNode:armoredExtendedAttributes',
61
69
  }),
70
+ getSigningKeysForExistingNode: jest.fn().mockResolvedValue({
71
+ email: 'signatureEmail',
72
+ addressId: 'addressId',
73
+ nameAndPassphraseSigningKey: {} as any,
74
+ contentSigningKey: {} as any,
75
+ }),
62
76
  };
63
77
  nodesService = {
64
78
  getNode: jest.fn(async (nodeUid: string) => ({
@@ -119,7 +133,7 @@ describe('UploadManager', () => {
119
133
  armoredEncryptedName: 'newNode:encryptedName',
120
134
  hash: 'newNode:hash',
121
135
  mediaType: 'myMimeType',
122
- intendedUploadSize: 123456,
136
+ intendedUploadSize: 100_000,
123
137
  armoredNodeKey: 'newNode:armoredKey',
124
138
  armoredNodePassphrase: 'newNode:armoredPassphrase',
125
139
  armoredNodePassphraseSignature: 'newNode:armoredPassphraseSignature',
@@ -263,6 +277,358 @@ describe('UploadManager', () => {
263
277
  });
264
278
  });
265
279
 
280
+ describe('generateNewFileCrypto', () => {
281
+ it('should throw when parent is not a folder (no hashKey)', async () => {
282
+ nodesService.getNodeKeys = jest.fn().mockResolvedValue({ hashKey: undefined });
283
+
284
+ const result = manager.generateNewFileCrypto('parentUid', 'fileName');
285
+
286
+ await expect(result).rejects.toThrow('Creating files in non-folders is not allowed');
287
+ expect(nodesService.getNodeKeys).toHaveBeenCalledWith('parentUid');
288
+ expect(cryptoService.generateFileCrypto).not.toHaveBeenCalled();
289
+ });
290
+
291
+ it('should return generated crypto with parentHashKey when parent is folder', async () => {
292
+ const result = await manager.generateNewFileCrypto('parentUid', 'fileName');
293
+
294
+ expect(nodesService.getNodeKeys).toHaveBeenCalledWith('parentUid');
295
+ expect(cryptoService.generateFileCrypto).toHaveBeenCalledWith(
296
+ 'parentUid',
297
+ { key: 'parentNode:nodekey', hashKey: 'parentNode:hashKey' },
298
+ 'fileName',
299
+ );
300
+ expect(result).toMatchObject({
301
+ parentHashKey: 'parentNode:hashKey',
302
+ encryptedNode: { encryptedName: 'newNode:encryptedName', hash: 'newNode:hash' },
303
+ nodeKeys: expect.anything(),
304
+ contentKey: expect.anything(),
305
+ signingKeys: { email: 'signatureEmail' },
306
+ });
307
+ });
308
+ });
309
+
310
+ describe('getExistingFileNodeCrypto', () => {
311
+ it('should throw when node has no active revision', async () => {
312
+ nodesService.getNode = jest.fn().mockResolvedValue({
313
+ uid: 'fileNodeUid',
314
+ parentUid: 'parentUid',
315
+ activeRevision: { ok: false, error: new Error('No revision') },
316
+ });
317
+
318
+ const result = manager.getExistingFileNodeCrypto('fileNodeUid');
319
+
320
+ await expect(result).rejects.toThrow('Creating revisions in non-files is not allowed');
321
+ });
322
+
323
+ it('should throw when nodeKeys has no contentKeyPacketSessionKey', async () => {
324
+ nodesService.getNode = jest.fn().mockResolvedValue({
325
+ uid: 'fileNodeUid',
326
+ parentUid: 'parentUid',
327
+ activeRevision: { ok: true, value: { uid: 'revisionUid' } },
328
+ });
329
+ nodesService.getNodeKeys = jest.fn().mockResolvedValue({
330
+ key: 'nodeKey',
331
+ contentKeyPacket: new Uint8Array([1, 2, 3]),
332
+ hashKey: 'hashKey',
333
+ });
334
+
335
+ const result = manager.getExistingFileNodeCrypto('fileNodeUid');
336
+
337
+ await expect(result).rejects.toThrow('Creating revisions in non-files is not allowed');
338
+ });
339
+
340
+ it('should throw when nodeKeys has no contentKeyPacket', async () => {
341
+ nodesService.getNode = jest.fn().mockResolvedValue({
342
+ uid: 'fileNodeUid',
343
+ parentUid: 'parentUid',
344
+ activeRevision: { ok: true, value: { uid: 'revisionUid' } },
345
+ });
346
+ nodesService.getNodeKeys = jest.fn().mockResolvedValue({
347
+ key: 'nodeKey',
348
+ contentKeyPacketSessionKey: 'sessionKey',
349
+ hashKey: 'hashKey',
350
+ });
351
+
352
+ const result = manager.getExistingFileNodeCrypto('fileNodeUid');
353
+
354
+ await expect(result).rejects.toThrow('Content key packet is required for small revision upload');
355
+ });
356
+
357
+ it('should return key, contentKeyPacket, contentKeyPacketSessionKey and signingKeys', async () => {
358
+ const contentKeyPacket = new Uint8Array([1, 2, 3]);
359
+ nodesService.getNode = jest.fn().mockResolvedValue({
360
+ uid: 'fileNodeUid',
361
+ parentUid: 'parentUid',
362
+ activeRevision: { ok: true, value: { uid: 'revisionUid' } },
363
+ });
364
+ nodesService.getNodeKeys = jest.fn().mockResolvedValue({
365
+ key: 'nodeKey',
366
+ contentKeyPacket,
367
+ contentKeyPacketSessionKey: 'sessionKey',
368
+ hashKey: 'hashKey',
369
+ });
370
+
371
+ const result = await manager.getExistingFileNodeCrypto('fileNodeUid');
372
+
373
+ expect(cryptoService.getSigningKeysForExistingNode).toHaveBeenCalledWith({
374
+ nodeUid: 'fileNodeUid',
375
+ parentNodeUid: 'parentUid',
376
+ });
377
+ expect(result).toEqual({
378
+ key: 'nodeKey',
379
+ contentKeyPacket,
380
+ contentKeyPacketSessionKey: 'sessionKey',
381
+ signingKeys: {
382
+ email: 'signatureEmail',
383
+ addressId: 'addressId',
384
+ nameAndPassphraseSigningKey: {},
385
+ contentSigningKey: {},
386
+ },
387
+ });
388
+ });
389
+ });
390
+
391
+ describe('uploadFile', () => {
392
+ const nodeCrypto = {
393
+ encryptedNode: { encryptedName: 'encName', hash: 'hash' },
394
+ nodeKeys: {
395
+ encrypted: {
396
+ armoredKey: 'armoredKey',
397
+ armoredPassphrase: 'armoredPassphrase',
398
+ armoredPassphraseSignature: 'armoredPassphraseSignature',
399
+ },
400
+ },
401
+ contentKey: {
402
+ encrypted: {
403
+ base64ContentKeyPacket: 'base64ContentKeyPacket',
404
+ armoredContentKeyPacketSignature: 'armoredContentKeyPacketSignature',
405
+ },
406
+ },
407
+ signingKeys: { email: 'signatureEmail' },
408
+ } as any;
409
+ const metadata = { mediaType: 'application/octet-stream', expectedSize: 100 } as UploadMetadata;
410
+ const commitPayload = {
411
+ armoredManifestSignature: 'manifestSignature',
412
+ armoredExtendedAttributes: 'extAttr',
413
+ };
414
+ const encryptedBlock = {
415
+ encryptedData: new Uint8Array([1, 2, 3]),
416
+ armoredSignature: 'blockSig',
417
+ verificationToken: new Uint8Array([4, 5, 6]),
418
+ };
419
+ const encryptedThumbnails = [{ type: ThumbnailType.Type1, encryptedData: new Uint8Array([7, 8, 9]) }];
420
+
421
+ it('should call uploadSmallFile and notifyChildCreated on success', async () => {
422
+ const result = await manager.uploadFile(
423
+ 'parentUid',
424
+ nodeCrypto,
425
+ metadata,
426
+ commitPayload,
427
+ encryptedBlock,
428
+ encryptedThumbnails,
429
+ );
430
+
431
+ expect(result).toEqual({
432
+ nodeUid: 'uploaded:nodeUid',
433
+ nodeRevisionUid: 'uploaded:nodeRevisionUid',
434
+ });
435
+ expect(apiService.uploadSmallFile).toHaveBeenCalledWith(
436
+ 'parentUid',
437
+ {
438
+ armoredEncryptedName: 'encName',
439
+ hash: 'hash',
440
+ mediaType: 'application/octet-stream',
441
+ armoredNodeKey: 'armoredKey',
442
+ armoredNodePassphrase: 'armoredPassphrase',
443
+ armoredNodePassphraseSignature: 'armoredPassphraseSignature',
444
+ base64ContentKeyPacket: 'base64ContentKeyPacket',
445
+ armoredContentKeyPacketSignature: 'armoredContentKeyPacketSignature',
446
+ armoredExtendedAttributes: 'extAttr',
447
+ signatureEmail: 'signatureEmail',
448
+ },
449
+ {
450
+ armoredManifestSignature: 'manifestSignature',
451
+ block: encryptedBlock,
452
+ thumbnails: encryptedThumbnails,
453
+ },
454
+ undefined,
455
+ );
456
+ expect(nodesService.notifyChildCreated).toHaveBeenCalledWith('parentUid');
457
+ });
458
+
459
+ it('should delete existing draft and retry on ALREADY_EXISTS when own draft', async () => {
460
+ let firstCall = true;
461
+ apiService.uploadSmallFile = jest.fn().mockImplementation(() => {
462
+ if (firstCall) {
463
+ firstCall = false;
464
+ throw new ValidationError('Already exists', ErrorCode.ALREADY_EXISTS, {
465
+ ConflictLinkID: 'existingLinkId',
466
+ ConflictDraftRevisionID: 'existingDraftRevisionId',
467
+ ConflictDraftClientUID: clientUid,
468
+ });
469
+ }
470
+ return {
471
+ nodeUid: 'uploaded:nodeUid',
472
+ nodeRevisionUid: 'uploaded:nodeRevisionUid',
473
+ };
474
+ });
475
+
476
+ const result = await manager.uploadFile(
477
+ 'volumeId~parentUid',
478
+ nodeCrypto,
479
+ { ...metadata, overrideExistingDraftByOtherClient: false },
480
+ commitPayload,
481
+ encryptedBlock,
482
+ encryptedThumbnails,
483
+ );
484
+
485
+ expect(result).toEqual({
486
+ nodeUid: 'uploaded:nodeUid',
487
+ nodeRevisionUid: 'uploaded:nodeRevisionUid',
488
+ });
489
+ expect(apiService.deleteDraft).toHaveBeenCalledWith('volumeId~existingLinkId');
490
+ expect(apiService.uploadSmallFile).toHaveBeenCalledTimes(2);
491
+ });
492
+
493
+ it('should call uploadSmallFile with block undefined for zero-byte file', async () => {
494
+ const result = await manager.uploadFile(
495
+ 'parentUid',
496
+ nodeCrypto,
497
+ { ...metadata, expectedSize: 0 },
498
+ commitPayload,
499
+ undefined,
500
+ [],
501
+ );
502
+
503
+ expect(result).toEqual({
504
+ nodeUid: 'uploaded:nodeUid',
505
+ nodeRevisionUid: 'uploaded:nodeRevisionUid',
506
+ });
507
+ expect(apiService.uploadSmallFile).toHaveBeenCalledWith(
508
+ 'parentUid',
509
+ expect.objectContaining({
510
+ armoredEncryptedName: 'encName',
511
+ hash: 'hash',
512
+ mediaType: 'application/octet-stream',
513
+ armoredExtendedAttributes: 'extAttr',
514
+ signatureEmail: 'signatureEmail',
515
+ }),
516
+ {
517
+ armoredManifestSignature: 'manifestSignature',
518
+ block: undefined,
519
+ thumbnails: [],
520
+ },
521
+ undefined,
522
+ );
523
+ expect(nodesService.notifyChildCreated).toHaveBeenCalledWith('parentUid');
524
+ });
525
+ });
526
+
527
+ describe('uploadSmallRevision', () => {
528
+ const nodeCrypto = { signingKeys: { email: 'signatureEmail' } } as any;
529
+ const commitPayload = {
530
+ armoredManifestSignature: 'manifestSig',
531
+ armoredExtendedAttributes: 'extAttr',
532
+ };
533
+ const encryptedBlock = {
534
+ encryptedData: new Uint8Array([1, 2, 3]),
535
+ armoredSignature: 'blockSig',
536
+ verificationToken: new Uint8Array([4, 5, 6]),
537
+ };
538
+ const encryptedThumbnails = [{ type: ThumbnailType.Type1, encryptedData: new Uint8Array([7, 8, 9]) }];
539
+
540
+ it('should throw when file has no revision', async () => {
541
+ nodesService.getNode = jest.fn().mockResolvedValue({
542
+ uid: 'fileNodeUid',
543
+ parentUid: 'parentUid',
544
+ activeRevision: { ok: false, error: new Error('No revision') },
545
+ });
546
+
547
+ const result = manager.uploadSmallRevision(
548
+ 'fileNodeUid',
549
+ nodeCrypto,
550
+ commitPayload,
551
+ encryptedBlock,
552
+ encryptedThumbnails,
553
+ );
554
+
555
+ await expect(result).rejects.toThrow('File has no revision');
556
+ expect(apiService.uploadSmallRevision).not.toHaveBeenCalled();
557
+ });
558
+
559
+ it('should call uploadSmallRevision and notifyNodeChanged on success', async () => {
560
+ nodesService.getNode = jest.fn().mockResolvedValue({
561
+ uid: 'fileNodeUid',
562
+ parentUid: 'parentUid',
563
+ activeRevision: { ok: true, value: { uid: 'currentRevisionUid' } },
564
+ });
565
+
566
+ const result = await manager.uploadSmallRevision(
567
+ 'fileNodeUid',
568
+ nodeCrypto,
569
+ commitPayload,
570
+ encryptedBlock,
571
+ encryptedThumbnails,
572
+ );
573
+
574
+ expect(result).toEqual({
575
+ nodeUid: 'revised:nodeUid',
576
+ nodeRevisionUid: 'revised:nodeRevisionUid',
577
+ });
578
+ expect(apiService.uploadSmallRevision).toHaveBeenCalledWith(
579
+ 'fileNodeUid',
580
+ 'currentRevisionUid',
581
+ {
582
+ signatureEmail: 'signatureEmail',
583
+ armoredExtendedAttributes: 'extAttr',
584
+ },
585
+ {
586
+ armoredManifestSignature: 'manifestSig',
587
+ block: encryptedBlock,
588
+ thumbnails: encryptedThumbnails,
589
+ },
590
+ undefined,
591
+ );
592
+ expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('fileNodeUid');
593
+ });
594
+
595
+ it('should call uploadSmallRevision with block undefined for zero-byte revision', async () => {
596
+ nodesService.getNode = jest.fn().mockResolvedValue({
597
+ uid: 'fileNodeUid',
598
+ parentUid: 'parentUid',
599
+ activeRevision: { ok: true, value: { uid: 'currentRevisionUid' } },
600
+ });
601
+
602
+ const result = await manager.uploadSmallRevision(
603
+ 'fileNodeUid',
604
+ nodeCrypto,
605
+ commitPayload,
606
+ undefined,
607
+ [],
608
+ );
609
+
610
+ expect(result).toEqual({
611
+ nodeUid: 'revised:nodeUid',
612
+ nodeRevisionUid: 'revised:nodeRevisionUid',
613
+ });
614
+ expect(apiService.uploadSmallRevision).toHaveBeenCalledWith(
615
+ 'fileNodeUid',
616
+ 'currentRevisionUid',
617
+ {
618
+ signatureEmail: 'signatureEmail',
619
+ armoredExtendedAttributes: 'extAttr',
620
+ },
621
+ {
622
+ armoredManifestSignature: 'manifestSig',
623
+ block: undefined,
624
+ thumbnails: [],
625
+ },
626
+ undefined,
627
+ );
628
+ expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('fileNodeUid');
629
+ });
630
+ });
631
+
266
632
  describe('commit draft', () => {
267
633
  const nodeRevisionDraft = {
268
634
  nodeUid: 'newNode:nodeUid',