@protontech/drive-sdk 0.9.7 → 0.9.9

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 (59) hide show
  1. package/dist/interface/upload.d.ts +10 -0
  2. package/dist/internal/nodes/mediaTypes.d.ts +2 -0
  3. package/dist/internal/nodes/mediaTypes.js +3 -0
  4. package/dist/internal/nodes/mediaTypes.js.map +1 -1
  5. package/dist/internal/nodes/nodesManagement.js +2 -1
  6. package/dist/internal/nodes/nodesManagement.js.map +1 -1
  7. package/dist/internal/photos/albums.d.ts +15 -1
  8. package/dist/internal/photos/albums.js +91 -1
  9. package/dist/internal/photos/albums.js.map +1 -1
  10. package/dist/internal/photos/albums.test.d.ts +1 -0
  11. package/dist/internal/photos/albums.test.js +217 -0
  12. package/dist/internal/photos/albums.test.js.map +1 -0
  13. package/dist/internal/photos/albumsCrypto.d.ts +35 -0
  14. package/dist/internal/photos/albumsCrypto.js +66 -0
  15. package/dist/internal/photos/albumsCrypto.js.map +1 -0
  16. package/dist/internal/photos/albumsCrypto.test.d.ts +1 -0
  17. package/dist/internal/photos/albumsCrypto.test.js +134 -0
  18. package/dist/internal/photos/albumsCrypto.test.js.map +1 -0
  19. package/dist/internal/photos/apiService.d.ts +22 -0
  20. package/dist/internal/photos/apiService.js +91 -0
  21. package/dist/internal/photos/apiService.js.map +1 -1
  22. package/dist/internal/photos/index.d.ts +4 -4
  23. package/dist/internal/photos/index.js +5 -3
  24. package/dist/internal/photos/index.js.map +1 -1
  25. package/dist/internal/photos/interface.d.ts +21 -0
  26. package/dist/internal/photos/interface.js +14 -0
  27. package/dist/internal/photos/interface.js.map +1 -1
  28. package/dist/internal/photos/timeline.d.ts +2 -5
  29. package/dist/internal/photos/timeline.js.map +1 -1
  30. package/dist/internal/photos/upload.js +3 -2
  31. package/dist/internal/photos/upload.js.map +1 -1
  32. package/dist/internal/upload/streamUploader.d.ts +3 -1
  33. package/dist/internal/upload/streamUploader.js +10 -3
  34. package/dist/internal/upload/streamUploader.js.map +1 -1
  35. package/dist/internal/upload/streamUploader.test.js +10 -0
  36. package/dist/internal/upload/streamUploader.test.js.map +1 -1
  37. package/dist/protonDrivePhotosClient.d.ts +64 -6
  38. package/dist/protonDrivePhotosClient.js +75 -1
  39. package/dist/protonDrivePhotosClient.js.map +1 -1
  40. package/dist/protonDrivePublicLinkClient.d.ts +15 -0
  41. package/dist/protonDrivePublicLinkClient.js +20 -0
  42. package/dist/protonDrivePublicLinkClient.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/interface/upload.ts +10 -0
  45. package/src/internal/nodes/mediaTypes.ts +3 -0
  46. package/src/internal/nodes/nodesManagement.ts +2 -1
  47. package/src/internal/photos/albums.test.ts +268 -0
  48. package/src/internal/photos/albums.ts +136 -0
  49. package/src/internal/photos/albumsCrypto.test.ts +181 -0
  50. package/src/internal/photos/albumsCrypto.ts +100 -0
  51. package/src/internal/photos/apiService.ts +168 -2
  52. package/src/internal/photos/index.ts +7 -5
  53. package/src/internal/photos/interface.ts +24 -0
  54. package/src/internal/photos/timeline.ts +2 -5
  55. package/src/internal/photos/upload.ts +3 -2
  56. package/src/internal/upload/streamUploader.test.ts +38 -0
  57. package/src/internal/upload/streamUploader.ts +10 -3
  58. package/src/protonDrivePhotosClient.ts +104 -9
  59. package/src/protonDrivePublicLinkClient.ts +27 -0
@@ -0,0 +1,268 @@
1
+ import { NodeType, MemberRole } from '../../interface';
2
+ import { ValidationError } from '../../errors';
3
+ import { Albums } from './albums';
4
+ import { AlbumsCryptoService } from './albumsCrypto';
5
+ import { PhotosAPIService } from './apiService';
6
+ import { DecryptedPhotoNode } from './interface';
7
+ import { PhotosNodesAccess } from './nodes';
8
+ import { PhotoSharesManager } from './shares';
9
+
10
+ describe('Albums', () => {
11
+ let apiService: PhotosAPIService;
12
+ let cryptoService: AlbumsCryptoService;
13
+ let photoShares: PhotoSharesManager;
14
+ let nodesService: PhotosNodesAccess;
15
+ let albums: Albums;
16
+
17
+ let nodes: { [uid: string]: DecryptedPhotoNode };
18
+
19
+ beforeEach(() => {
20
+ nodes = {
21
+ rootNodeUid: {
22
+ uid: 'rootNodeUid',
23
+ parentUid: '',
24
+ hash: 'rootHash',
25
+ } as DecryptedPhotoNode,
26
+ albumNodeUid: {
27
+ uid: 'albumNodeUid',
28
+ parentUid: 'rootNodeUid',
29
+ name: { ok: true, value: 'old album name' },
30
+ hash: 'albumHash',
31
+ encryptedName: 'encryptedAlbumName',
32
+ } as DecryptedPhotoNode,
33
+ };
34
+
35
+ // @ts-expect-error No need to implement all methods for mocking
36
+ apiService = {
37
+ createAlbum: jest.fn().mockResolvedValue('volumeId~newAlbumNodeId'),
38
+ updateAlbum: jest.fn(),
39
+ deleteAlbum: jest.fn(),
40
+ removePhotosFromAlbum: jest.fn(),
41
+ };
42
+
43
+ // @ts-expect-error No need to implement all methods for mocking
44
+ cryptoService = {
45
+ createAlbum: jest.fn().mockResolvedValue({
46
+ encryptedCrypto: {
47
+ encryptedName: 'newEncryptedAlbumName',
48
+ hash: 'newAlbumHash',
49
+ armoredKey: 'armoredKey',
50
+ armoredNodePassphrase: 'armoredNodePassphrase',
51
+ armoredNodePassphraseSignature: 'armoredNodePassphraseSignature',
52
+ signatureEmail: 'signature@example.com',
53
+ armoredHashKey: 'armoredHashKey',
54
+ },
55
+ keys: {
56
+ passphrase: 'passphrase',
57
+ key: 'nodeKey',
58
+ passphraseSessionKey: 'passphraseSessionKey',
59
+ hashKey: new Uint8Array([1, 2, 3]),
60
+ },
61
+ }),
62
+ renameAlbum: jest.fn().mockResolvedValue({
63
+ signatureEmail: 'newSignatureEmail',
64
+ armoredNodeName: 'newArmoredAlbumName',
65
+ hash: 'newHash',
66
+ }),
67
+ };
68
+
69
+ // @ts-expect-error No need to implement all methods for mocking
70
+ photoShares = {
71
+ getRootIDs: jest.fn().mockResolvedValue({ volumeId: 'volumeId', rootNodeId: 'rootNodeId' }),
72
+ };
73
+
74
+ // @ts-expect-error No need to implement all methods for mocking
75
+ nodesService = {
76
+ getVolumeRootFolder: jest.fn().mockResolvedValue(nodes.rootNodeUid),
77
+ getNode: jest.fn().mockImplementation((uid: string) => nodes[uid]),
78
+ getNodeKeys: jest.fn().mockImplementation((uid) => ({
79
+ key: `${uid}-key`,
80
+ hashKey: `${uid}-hashKey`,
81
+ passphrase: `${uid}-passphrase`,
82
+ passphraseSessionKey: `${uid}-passphraseSessionKey`,
83
+ })),
84
+ getParentKeys: jest.fn().mockImplementation(({ parentUid }) => ({
85
+ key: `${parentUid}-key`,
86
+ hashKey: `${parentUid}-hashKey`,
87
+ })),
88
+ getNodeSigningKeys: jest.fn().mockResolvedValue({
89
+ type: 'userAddress',
90
+ email: 'user@example.com',
91
+ addressId: 'addressId',
92
+ key: 'addressKey',
93
+ }),
94
+ notifyNodeChanged: jest.fn(),
95
+ notifyNodeDeleted: jest.fn(),
96
+ notifyChildCreated: jest.fn(),
97
+ };
98
+
99
+ albums = new Albums(apiService, cryptoService, photoShares, nodesService);
100
+ });
101
+
102
+ describe('createAlbum', () => {
103
+ it('creates album and returns decrypted node', async () => {
104
+ const newAlbum = await albums.createAlbum('My New Album');
105
+
106
+ expect(newAlbum).toEqual(
107
+ expect.objectContaining({
108
+ uid: 'volumeId~newAlbumNodeId',
109
+ parentUid: 'rootNodeUid',
110
+ type: NodeType.Album,
111
+ mediaType: 'Album',
112
+ name: { ok: true, value: 'My New Album' },
113
+ hash: 'newAlbumHash',
114
+ encryptedName: 'newEncryptedAlbumName',
115
+ keyAuthor: { ok: true, value: 'signature@example.com' },
116
+ nameAuthor: { ok: true, value: 'signature@example.com' },
117
+ }),
118
+ );
119
+
120
+ expect(nodesService.getNodeSigningKeys).toHaveBeenCalledWith({ parentNodeUid: 'rootNodeUid' });
121
+ expect(cryptoService.createAlbum).toHaveBeenCalledWith(
122
+ { key: 'rootNodeUid-key', hashKey: 'rootNodeUid-hashKey' },
123
+ { type: 'userAddress', email: 'user@example.com', addressId: 'addressId', key: 'addressKey' },
124
+ 'My New Album',
125
+ );
126
+ expect(apiService.createAlbum).toHaveBeenCalledWith('rootNodeUid', {
127
+ encryptedName: 'newEncryptedAlbumName',
128
+ hash: 'newAlbumHash',
129
+ armoredKey: 'armoredKey',
130
+ armoredNodePassphrase: 'armoredNodePassphrase',
131
+ armoredNodePassphraseSignature: 'armoredNodePassphraseSignature',
132
+ signatureEmail: 'signature@example.com',
133
+ armoredHashKey: 'armoredHashKey',
134
+ });
135
+ expect(nodesService.notifyChildCreated).toHaveBeenCalledWith('rootNodeUid');
136
+ });
137
+
138
+ it('throws validation error for invalid album name', async () => {
139
+ await expect(albums.createAlbum('invalid/name')).rejects.toThrow(ValidationError);
140
+ });
141
+
142
+ it('throws error when parent hash key is not available', async () => {
143
+ nodesService.getNodeKeys = jest.fn().mockResolvedValue({
144
+ key: 'rootNodeUid-key',
145
+ hashKey: undefined,
146
+ });
147
+
148
+ await expect(albums.createAlbum('My Album')).rejects.toThrow(
149
+ 'Cannot create album: parent folder hash key not available',
150
+ );
151
+ });
152
+ });
153
+
154
+ describe('updateAlbum', () => {
155
+ it('updates album name and notifies cache', async () => {
156
+ const updatedAlbum = await albums.updateAlbum('albumNodeUid', { name: 'new album name' });
157
+
158
+ expect(updatedAlbum).toEqual({
159
+ ...nodes.albumNodeUid,
160
+ name: { ok: true, value: 'new album name' },
161
+ encryptedName: 'newArmoredAlbumName',
162
+ nameAuthor: { ok: true, value: 'newSignatureEmail' },
163
+ hash: 'newHash',
164
+ });
165
+ expect(nodesService.getNodeSigningKeys).toHaveBeenCalledWith({
166
+ nodeUid: 'albumNodeUid',
167
+ parentNodeUid: 'rootNodeUid',
168
+ });
169
+ expect(cryptoService.renameAlbum).toHaveBeenCalledWith(
170
+ { key: 'rootNodeUid-key', hashKey: 'rootNodeUid-hashKey' },
171
+ 'encryptedAlbumName',
172
+ { type: 'userAddress', email: 'user@example.com', addressId: 'addressId', key: 'addressKey' },
173
+ 'new album name',
174
+ );
175
+ expect(apiService.updateAlbum).toHaveBeenCalledWith(
176
+ 'albumNodeUid',
177
+ undefined,
178
+ {
179
+ encryptedName: 'newArmoredAlbumName',
180
+ hash: 'newHash',
181
+ originalHash: 'albumHash',
182
+ nameSignatureEmail: 'newSignatureEmail',
183
+ },
184
+ );
185
+ expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('albumNodeUid');
186
+ });
187
+
188
+ it('updates album cover photo only', async () => {
189
+ const updatedAlbum = await albums.updateAlbum('albumNodeUid', { coverPhotoNodeUid: 'photoNodeUid' });
190
+
191
+ expect(updatedAlbum).toEqual(nodes.albumNodeUid);
192
+ expect(cryptoService.renameAlbum).not.toHaveBeenCalled();
193
+ expect(apiService.updateAlbum).toHaveBeenCalledWith('albumNodeUid', 'photoNodeUid', undefined);
194
+ expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('albumNodeUid');
195
+ });
196
+
197
+ it('updates album name and cover photo together', async () => {
198
+ const updatedAlbum = await albums.updateAlbum('albumNodeUid', {
199
+ name: 'new album name',
200
+ coverPhotoNodeUid: 'photoNodeUid',
201
+ });
202
+
203
+ expect(updatedAlbum).toEqual({
204
+ ...nodes.albumNodeUid,
205
+ name: { ok: true, value: 'new album name' },
206
+ encryptedName: 'newArmoredAlbumName',
207
+ nameAuthor: { ok: true, value: 'newSignatureEmail' },
208
+ hash: 'newHash',
209
+ });
210
+ expect(apiService.updateAlbum).toHaveBeenCalledWith(
211
+ 'albumNodeUid',
212
+ 'photoNodeUid',
213
+ {
214
+ encryptedName: 'newArmoredAlbumName',
215
+ hash: 'newHash',
216
+ originalHash: 'albumHash',
217
+ nameSignatureEmail: 'newSignatureEmail',
218
+ },
219
+ );
220
+ });
221
+
222
+ it('throws validation error for invalid album name', async () => {
223
+ await expect(albums.updateAlbum('albumNodeUid', { name: 'invalid/name' })).rejects.toThrow(ValidationError);
224
+ });
225
+ });
226
+
227
+ describe('deleteAlbum', () => {
228
+ it('deletes album and notifies cache', async () => {
229
+ await albums.deleteAlbum('albumNodeUid');
230
+
231
+ expect(apiService.deleteAlbum).toHaveBeenCalledWith('albumNodeUid', {});
232
+ expect(nodesService.notifyNodeDeleted).toHaveBeenCalledWith('albumNodeUid');
233
+ });
234
+
235
+ it('deletes album with force option', async () => {
236
+ await albums.deleteAlbum('albumNodeUid', { force: true });
237
+
238
+ expect(apiService.deleteAlbum).toHaveBeenCalledWith('albumNodeUid', { force: true });
239
+ expect(nodesService.notifyNodeDeleted).toHaveBeenCalledWith('albumNodeUid');
240
+ });
241
+ });
242
+
243
+ describe('removePhotos', () => {
244
+ it('notifies nodes service only for successfully removed photos', async () => {
245
+ apiService.removePhotosFromAlbum = jest.fn().mockImplementation(async function* () {
246
+ yield { uid: 'photo1', ok: true };
247
+ yield { uid: 'photo2', ok: false, error: 'Some error' };
248
+ yield { uid: 'photo3', ok: true };
249
+ });
250
+
251
+ const results = [];
252
+ for await (const result of albums.removePhotos('albumNodeUid', ['photo1', 'photo2', 'photo3'])) {
253
+ results.push(result);
254
+ }
255
+
256
+ expect(results).toEqual([
257
+ { uid: 'photo1', ok: true },
258
+ { uid: 'photo2', ok: false, error: 'Some error' },
259
+ { uid: 'photo3', ok: true },
260
+ ]);
261
+ expect(apiService.removePhotosFromAlbum).toHaveBeenCalledWith('albumNodeUid', ['photo1', 'photo2', 'photo3'], undefined);
262
+ expect(nodesService.notifyNodeChanged).toHaveBeenCalledTimes(2);
263
+ expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('photo1');
264
+ expect(nodesService.notifyNodeChanged).toHaveBeenCalledWith('photo3');
265
+ expect(nodesService.notifyNodeChanged).not.toHaveBeenCalledWith('photo2');
266
+ });
267
+ });
268
+ });
@@ -1,6 +1,12 @@
1
+ import { MemberRole, NodeResult, NodeType, resultOk } from '../../interface';
1
2
  import { BatchLoading } from '../batchLoading';
2
3
  import { DecryptedNode } from '../nodes';
4
+ import { ALBUM_MEDIA_TYPE } from '../nodes/mediaTypes';
5
+ import { validateNodeName } from '../nodes/validations';
6
+ import { splitNodeUid } from '../uids';
7
+ import { AlbumsCryptoService } from './albumsCrypto';
3
8
  import { PhotosAPIService } from './apiService';
9
+ import { AlbumItem, DecryptedPhotoNode } from './interface';
4
10
  import { PhotosNodesAccess } from './nodes';
5
11
  import { PhotoSharesManager } from './shares';
6
12
 
@@ -12,10 +18,12 @@ const BATCH_LOADING_SIZE = 10;
12
18
  export class Albums {
13
19
  constructor(
14
20
  private apiService: PhotosAPIService,
21
+ private cryptoService: AlbumsCryptoService,
15
22
  private photoShares: PhotoSharesManager,
16
23
  private nodesService: PhotosNodesAccess,
17
24
  ) {
18
25
  this.apiService = apiService;
26
+ this.cryptoService = cryptoService;
19
27
  this.photoShares = photoShares;
20
28
  this.nodesService = nodesService;
21
29
  }
@@ -33,6 +41,134 @@ export class Albums {
33
41
  yield* batchLoading.loadRest();
34
42
  }
35
43
 
44
+ async *iterateAlbum(albumNodeUid: string, signal?: AbortSignal): AsyncGenerator<AlbumItem> {
45
+ yield* this.apiService.iterateAlbumChildren(albumNodeUid, signal);
46
+ }
47
+
48
+ async createAlbum(name: string): Promise<DecryptedPhotoNode> {
49
+ validateNodeName(name);
50
+
51
+ const rootNode = await this.nodesService.getVolumeRootFolder();
52
+ const parentKeys = await this.nodesService.getNodeKeys(rootNode.uid);
53
+ if (!parentKeys.hashKey) {
54
+ throw new Error('Cannot create album: parent folder hash key not available');
55
+ }
56
+
57
+ const signingKeys = await this.nodesService.getNodeSigningKeys({ parentNodeUid: rootNode.uid });
58
+ const { encryptedCrypto } = await this.cryptoService.createAlbum(
59
+ { key: parentKeys.key, hashKey: parentKeys.hashKey },
60
+ signingKeys,
61
+ name,
62
+ );
63
+
64
+ const nodeUid = await this.apiService.createAlbum(rootNode.uid, {
65
+ encryptedName: encryptedCrypto.encryptedName,
66
+ hash: encryptedCrypto.hash,
67
+ armoredKey: encryptedCrypto.armoredKey,
68
+ armoredNodePassphrase: encryptedCrypto.armoredNodePassphrase,
69
+ armoredNodePassphraseSignature: encryptedCrypto.armoredNodePassphraseSignature,
70
+ signatureEmail: encryptedCrypto.signatureEmail,
71
+ armoredHashKey: encryptedCrypto.armoredHashKey,
72
+ });
73
+
74
+ await this.nodesService.notifyChildCreated(rootNode.uid);
75
+
76
+ return {
77
+ // Internal metadata
78
+ hash: encryptedCrypto.hash,
79
+ encryptedName: encryptedCrypto.encryptedName,
80
+
81
+ // Basic node metadata
82
+ uid: nodeUid,
83
+ parentUid: rootNode.uid,
84
+ type: NodeType.Album,
85
+ mediaType: ALBUM_MEDIA_TYPE,
86
+ creationTime: new Date(),
87
+ modificationTime: new Date(),
88
+
89
+ // Share node metadata
90
+ isShared: false,
91
+ isSharedPublicly: false,
92
+ directRole: MemberRole.Inherited,
93
+
94
+ // Decrypted metadata
95
+ isStale: false,
96
+ keyAuthor: resultOk(encryptedCrypto.signatureEmail),
97
+ nameAuthor: resultOk(encryptedCrypto.signatureEmail),
98
+ name: resultOk(name),
99
+ treeEventScopeId: splitNodeUid(nodeUid).volumeId,
100
+ };
101
+ }
102
+
103
+ async updateAlbum(
104
+ nodeUid: string,
105
+ updates: {
106
+ name?: string;
107
+ coverPhotoNodeUid?: string;
108
+ },
109
+ ): Promise<DecryptedPhotoNode> {
110
+ if (updates.name) {
111
+ validateNodeName(updates.name);
112
+ }
113
+
114
+ const node = await this.nodesService.getNode(nodeUid);
115
+ const newNode = { ...node };
116
+
117
+ let nameUpdate:
118
+ | {
119
+ encryptedName: string;
120
+ hash: string;
121
+ originalHash: string;
122
+ nameSignatureEmail: string;
123
+ }
124
+ | undefined;
125
+
126
+ if (updates.name) {
127
+ const parentKeys = await this.nodesService.getParentKeys(node);
128
+ const signingKeys = await this.nodesService.getNodeSigningKeys({ nodeUid, parentNodeUid: node.parentUid });
129
+
130
+ const { signatureEmail, armoredNodeName, hash } = await this.cryptoService.renameAlbum(
131
+ { key: parentKeys.key, hashKey: parentKeys.hashKey },
132
+ node.encryptedName,
133
+ signingKeys,
134
+ updates.name,
135
+ );
136
+
137
+ nameUpdate = {
138
+ encryptedName: armoredNodeName,
139
+ hash,
140
+ originalHash: node.hash || '',
141
+ nameSignatureEmail: signatureEmail,
142
+ };
143
+ newNode.name = resultOk(updates.name);
144
+ newNode.encryptedName = nameUpdate.encryptedName;
145
+ newNode.nameAuthor = resultOk(nameUpdate.nameSignatureEmail);
146
+ newNode.hash = nameUpdate.hash;
147
+ }
148
+
149
+ await this.apiService.updateAlbum(nodeUid, updates.coverPhotoNodeUid, nameUpdate);
150
+ await this.nodesService.notifyNodeChanged(nodeUid);
151
+ return newNode;
152
+ }
153
+
154
+ async deleteAlbum(nodeUid: string, options: { force?: boolean } = {}): Promise<void> {
155
+ await this.apiService.deleteAlbum(nodeUid, options);
156
+ await this.nodesService.notifyNodeDeleted(nodeUid);
157
+ }
158
+
159
+ async *removePhotos(
160
+ albumNodeUid: string,
161
+ photoNodeUids: string[],
162
+ signal?: AbortSignal,
163
+ ): AsyncGenerator<NodeResult> {
164
+ for await (const result of this.apiService.removePhotosFromAlbum(albumNodeUid, photoNodeUids, signal)) {
165
+ if (result.ok) {
166
+ await this.nodesService.notifyNodeChanged(result.uid);
167
+ }
168
+ yield result;
169
+ }
170
+ }
171
+
36
172
  private async *iterateNodesAndIgnoreMissingOnes(
37
173
  nodeUids: string[],
38
174
  signal?: AbortSignal,
@@ -0,0 +1,181 @@
1
+ import { DriveCrypto, PrivateKey, SessionKey } from '../../crypto';
2
+ import { NodeSigningKeys } from '../nodes/interface';
3
+ import { AlbumsCryptoService } from './albumsCrypto';
4
+
5
+ describe('AlbumsCryptoService', () => {
6
+ let driveCrypto: DriveCrypto;
7
+ let albumsCryptoService: AlbumsCryptoService;
8
+
9
+ beforeEach(() => {
10
+ jest.clearAllMocks();
11
+
12
+ // @ts-expect-error No need to implement all methods for mocking
13
+ driveCrypto = {};
14
+
15
+ albumsCryptoService = new AlbumsCryptoService(driveCrypto);
16
+ });
17
+
18
+ describe('createAlbum', () => {
19
+ let parentKeys: any;
20
+
21
+ beforeEach(() => {
22
+ parentKeys = {
23
+ key: 'parentKey' as any,
24
+ hashKey: new Uint8Array([1, 2, 3]),
25
+ };
26
+ driveCrypto.generateKey = jest.fn().mockResolvedValue({
27
+ encrypted: {
28
+ armoredKey: 'encryptedNodeKey',
29
+ armoredPassphrase: 'encryptedPassphrase',
30
+ armoredPassphraseSignature: 'passphraseSignature',
31
+ },
32
+ decrypted: {
33
+ key: 'nodeKey' as any,
34
+ passphrase: 'nodePassphrase',
35
+ passphraseSessionKey: 'passphraseSessionKey' as any,
36
+ },
37
+ });
38
+ driveCrypto.encryptNodeName = jest.fn().mockResolvedValue({
39
+ armoredNodeName: 'encryptedNodeName',
40
+ });
41
+ driveCrypto.generateLookupHash = jest.fn().mockResolvedValue('lookupHash');
42
+ driveCrypto.generateHashKey = jest.fn().mockResolvedValue({
43
+ armoredHashKey: 'encryptedHashKey',
44
+ hashKey: new Uint8Array([4, 5, 6]),
45
+ });
46
+ });
47
+
48
+ it('should encrypt new album with user address key', async () => {
49
+ const signingKeys: NodeSigningKeys = {
50
+ type: 'userAddress',
51
+ email: 'test@example.com',
52
+ addressId: 'addressId',
53
+ key: 'addressKey' as any,
54
+ };
55
+
56
+ const result = await albumsCryptoService.createAlbum(parentKeys, signingKeys, 'My Album');
57
+
58
+ expect(result).toEqual({
59
+ encryptedCrypto: {
60
+ encryptedName: 'encryptedNodeName',
61
+ hash: 'lookupHash',
62
+ armoredKey: 'encryptedNodeKey',
63
+ armoredNodePassphrase: 'encryptedPassphrase',
64
+ armoredNodePassphraseSignature: 'passphraseSignature',
65
+ signatureEmail: 'test@example.com',
66
+ armoredHashKey: 'encryptedHashKey',
67
+ },
68
+ keys: {
69
+ passphrase: 'nodePassphrase',
70
+ key: 'nodeKey',
71
+ passphraseSessionKey: 'passphraseSessionKey',
72
+ hashKey: new Uint8Array([4, 5, 6]),
73
+ },
74
+ });
75
+
76
+ expect(driveCrypto.generateKey).toHaveBeenCalledWith([parentKeys.key], signingKeys.key);
77
+ expect(driveCrypto.encryptNodeName).toHaveBeenCalledWith(
78
+ 'My Album',
79
+ undefined,
80
+ parentKeys.key,
81
+ signingKeys.key,
82
+ );
83
+ expect(driveCrypto.generateLookupHash).toHaveBeenCalledWith('My Album', parentKeys.hashKey);
84
+ expect(driveCrypto.generateHashKey).toHaveBeenCalledWith('nodeKey');
85
+ });
86
+
87
+ it('should throw error when creating album by anonymous user', async () => {
88
+ const signingKeys: NodeSigningKeys = {
89
+ type: 'nodeKey',
90
+ nodeKey: 'nodeSigningKey' as any,
91
+ parentNodeKey: 'parentNodeKey' as any,
92
+ };
93
+
94
+ await expect(albumsCryptoService.createAlbum(parentKeys, signingKeys, 'My Album')).rejects.toThrow(
95
+ 'Creating album by anonymous user is not supported',
96
+ );
97
+ });
98
+ });
99
+
100
+ describe('renameAlbum', () => {
101
+ let parentKeys: any;
102
+ let nodeNameSessionKey: SessionKey;
103
+
104
+ beforeEach(() => {
105
+ parentKeys = {
106
+ key: 'parentKey' as any,
107
+ hashKey: new Uint8Array([1, 2, 3]),
108
+ };
109
+ nodeNameSessionKey = 'nameSessionKey' as any;
110
+ driveCrypto.decryptSessionKey = jest.fn().mockResolvedValue(nodeNameSessionKey);
111
+ driveCrypto.encryptNodeName = jest.fn().mockResolvedValue({
112
+ armoredNodeName: 'encryptedNewNodeName',
113
+ });
114
+ driveCrypto.generateLookupHash = jest.fn().mockResolvedValue('newHash');
115
+ });
116
+
117
+ it('should encrypt new album name with user address key', async () => {
118
+ const signingKeys: NodeSigningKeys = {
119
+ type: 'userAddress',
120
+ email: 'test@example.com',
121
+ addressId: 'addressId',
122
+ key: 'addressKey' as any,
123
+ };
124
+
125
+ const result = await albumsCryptoService.renameAlbum(
126
+ parentKeys,
127
+ 'oldEncryptedName',
128
+ signingKeys,
129
+ 'Renamed Album',
130
+ );
131
+
132
+ expect(result).toEqual({
133
+ signatureEmail: 'test@example.com',
134
+ armoredNodeName: 'encryptedNewNodeName',
135
+ hash: 'newHash',
136
+ });
137
+
138
+ expect(driveCrypto.decryptSessionKey).toHaveBeenCalledWith('oldEncryptedName', parentKeys.key);
139
+ expect(driveCrypto.encryptNodeName).toHaveBeenCalledWith(
140
+ 'Renamed Album',
141
+ nodeNameSessionKey,
142
+ parentKeys.key,
143
+ signingKeys.key,
144
+ );
145
+ expect(driveCrypto.generateLookupHash).toHaveBeenCalledWith('Renamed Album', parentKeys.hashKey);
146
+ });
147
+
148
+ it('should throw error when renaming album by anonymous user', async () => {
149
+ const signingKeys: NodeSigningKeys = {
150
+ type: 'nodeKey',
151
+ nodeKey: 'nodeSigningKey' as any,
152
+ parentNodeKey: 'parentNodeKey' as any,
153
+ };
154
+
155
+ await expect(
156
+ albumsCryptoService.renameAlbum(parentKeys, 'oldEncryptedName', signingKeys, 'Renamed Album'),
157
+ ).rejects.toThrow('Renaming album by anonymous user is not supported');
158
+ });
159
+
160
+ it('should throw error when parent hash key is not available', async () => {
161
+ const parentKeysWithoutHashKey = {
162
+ key: 'parentKey' as any,
163
+ };
164
+ const signingKeys: NodeSigningKeys = {
165
+ type: 'userAddress',
166
+ email: 'test@example.com',
167
+ addressId: 'addressId',
168
+ key: 'addressKey' as any,
169
+ };
170
+
171
+ await expect(
172
+ albumsCryptoService.renameAlbum(
173
+ parentKeysWithoutHashKey,
174
+ 'oldEncryptedName',
175
+ signingKeys,
176
+ 'Renamed Album',
177
+ ),
178
+ ).rejects.toThrow('Cannot rename album: parent folder hash key not available');
179
+ });
180
+ });
181
+ });