import { Identity, GetAddressFromPublicKey } from '@spacehq/users'; import { tryParsePublicKey } from '@spacehq/utils'; import { PrivateKey } from '@textile/crypto'; import { Buckets, PathAccessRole, PathItem, PushPathResult, Root } from '@textile/hub'; import { expect, use } from 'chai'; import * as chaiAsPromised from 'chai-as-promised'; import * as chaiSubset from 'chai-subset'; import dayjs from 'dayjs'; import { noop } from 'lodash'; import { anyString, anything, deepEqual, instance, mock, verify, when } from 'ts-mockito'; import { v4 } from 'uuid'; import { DirEntryNotFoundError, UnauthenticatedError } from './errors'; import { BucketMetadata, FileMetadata, SharedFileMetadata, UserMetadataStore, ShareUserMetadata } from './metadata/metadataStore'; import { makeAsyncIterableString } from './testHelpers'; import { AddItemsEventData } from './types'; import { UserStorage } from './userStorage'; import { decodeFileEncryptionKey, generateFileEncryptionKey, newEncryptedDataWriter } from './utils/fsUtils'; use(chaiAsPromised.default); use(chaiSubset.default); const mockIdentity: Identity = PrivateKey.fromRandom(); const encryptionKey = generateFileEncryptionKey(); const initStubbedStorage = (): { storage: UserStorage; mockBuckets: Buckets } => { const mockBuckets: Buckets = mock(); when(mockBuckets.getOrCreate(anyString(), anything())).thenReturn( Promise.resolve({ root: { ...mock<Root>(), key: 'myBucketKey', }, }), ); // const mockMetadataStore: UserMetadataStore = mock(); // when(mockMetadataStore.findBucket(anyString())).thenReturn(Promise.resolve(undefined)); // when(mockMetadataStore.createBucket(anyString(), anyString())).thenReturn(Promise.resolve({ // slug: 'myBucketKey', // encryptionKey: new Uint8Array(80), // dbId: 'dbId', // })); const storage = new UserStorage( { identity: mockIdentity, token: '', endpoint: 'http://space-auth-endpoint.com', storageAuth: { key: 'random-key', token: 'token', sig: 'sig', msg: 'msg', }, }, { bucketsInit: () => instance(mockBuckets), metadataStoreInit: async (): Promise<UserMetadataStore> => // commenting this out now because it causes test to silently fail // return instance(mockMetadataStore); // to be fixed later // eslint-disable-next-line implicit-arrow-linebreak Promise.resolve({ createBucket(bucketSlug: string, dbId: string): Promise<BucketMetadata> { return Promise.resolve({ bucketKey: 'testkey', slug: 'myBucketKey', encryptionKey: new Uint8Array(80), dbId: 'dbId', }); }, findBucket(bucketSlug: string): Promise<BucketMetadata | undefined> { return Promise.resolve({ bucketKey: 'testkey', slug: 'myBucketKey', encryptionKey: new Uint8Array(80), dbId: 'dbId', }); }, listBuckets(): Promise<BucketMetadata[]> { return Promise.resolve([]); }, upsertFileMetadata(input: FileMetadata): Promise<FileMetadata> { return Promise.resolve({ ...input, bucketSlug: 'myBucket', dbId: '', path: '/' }); }, findFileMetadata(bucketSlug, dbId, path): Promise<FileMetadata | undefined> { return Promise.resolve({ uuid: 'generated-uuid', mimeType: 'generic/type', encryptionKey, bucketSlug, dbId, path, }); }, findFileMetadataByUuid(): Promise<FileMetadata | undefined> { return Promise.resolve({ mimeType: 'generic/type', bucketSlug: 'myBucket', bucketKey: 'myBucketKey', dbId: 'mockThreadId', path: '/', encryptionKey, }); }, setFilePublic(_metadata: FileMetadata): Promise<void> { return Promise.resolve(); }, async upsertSharedWithMeFile(data: SharedFileMetadata): Promise<SharedFileMetadata> { return data; }, async listSharedWithMeFiles(): Promise<SharedFileMetadata[]> { return []; }, async upsertSharedByMeFile(data: SharedFileMetadata): Promise<SharedFileMetadata> { return data; }, async findSharedFilesByInvitation(id: string): Promise<SharedFileMetadata | undefined> { return undefined; }, async listSharedByMeFiles(): Promise<SharedFileMetadata[]> { return []; }, async addUserRecentlySharedWith(data: ShareUserMetadata): Promise<ShareUserMetadata> { return data; }, async listUsersRecentlySharedWith(): Promise<ShareUserMetadata[]> { return []; }, async getNotificationsLastSeenAt():Promise<number> { return Date.now(); }, async setNotificationsLastSeenAt(timestamp:number):Promise<void> { noop; }, }), }, ); return { storage, mockBuckets }; }; describe('UserStorage', () => { describe('createFolder()', () => { it('should throw error if user is not authenticated', async () => { const storage = new UserStorage({ identity: mockIdentity, token: '', endpoint: '' }); await expect(storage.createFolder({ bucket: '', path: '' })).to.eventually.be.rejectedWith(UnauthenticatedError); }); it('should push empty .keep file to bucket at specified path', async () => { const createFolderRequest = { bucket: 'personal', path: 'topLevel' }; const { storage, mockBuckets } = initStubbedStorage(); await storage.createFolder(createFolderRequest); verify( mockBuckets.pushPath( 'myBucketKey', '.keep', deepEqual({ path: '/topLevel/.keep', content: Buffer.from(''), }), ), ).called(); }); }); describe('listDirectory()', () => { it('should throw error if user is not authenticated', async () => { const storage = new UserStorage({ identity: mockIdentity, token: '', endpoint: '' }); await expect(storage.listDirectory({ bucket: 'bucket', path: '' })).to.eventually.be.rejectedWith( UnauthenticatedError, ); }); it('should return list of items', async () => { const listDirectoryRequest = { bucket: 'personal', path: 'topLevel' }; const mainItem = mock<PathItem>(); const childItem = { name: 'folder', path: '/ipfs/Qm123/folder', cid: 'Qm...', isDir: true, size: 10, }; const updatedAt = (new Date().getMilliseconds()) * 1000000; const { storage, mockBuckets } = initStubbedStorage(); when(mockBuckets.listPath('myBucketKey', `/${listDirectoryRequest.path}`, 0)).thenResolve({ item: { ...mainItem, items: [ { ...childItem, metadata: { updatedAt, roles: new Map(), }, items: [], count: 1, }, ], }, }); const mockMembers = new Map<string, PathAccessRole>(); const pubkey = 'bbaareieswor4fnmzdwmv6fwij2rxyyjmpc2izognkiqnfxlvnzzsvs7y5y'; mockMembers.set(pubkey, PathAccessRole.PATH_ACCESS_ROLE_WRITER); when(mockBuckets.pullPathAccessRoles(anyString(), anyString())).thenResolve(mockMembers); const result = await storage.listDirectory(listDirectoryRequest); const expectedDate = dayjs(new Date(Math.round(updatedAt / 1000000))).format(); expect(result).to.not.equal(undefined); expect(result.items[0]).to.not.equal(undefined); expect(result.items[0].name).to.equal(childItem.name); expect(result.items[0].bucket).to.not.be.empty; expect(result.items[0].dbId).to.not.be.empty; expect(result.items[0].ipfsHash).to.equal(childItem.cid); expect(result.items[0].isDir).to.equal(childItem.isDir); expect(result.items[0].sizeInBytes).to.equal(childItem.size); expect(result.items[0].created).to.equal(expectedDate); expect(result.items[0].updated).to.equal(expectedDate); expect(result.items[0].fileExtension).to.equal(''); expect(result.items[0].isLocallyAvailable).to.equal(false); expect(result.items[0].backupCount).to.equal(1); expect(result.items[0].members).to.deep.equal([{ publicKey: Buffer.from(tryParsePublicKey(pubkey).pubKey).toString('hex'), role: PathAccessRole.PATH_ACCESS_ROLE_WRITER, address: GetAddressFromPublicKey(pubkey), }]); expect(result.items[0].isBackupInProgress).to.equal(false); expect(result.items[0].isRestoreInProgress).to.equal(false); expect(result.items[0].uuid).to.equal('generated-uuid'); }); }); describe('openFile()', () => { // it('should throw error if user is not authenticated', async () => { // const storage = new UserStorage({ identity: mockIdentity, token: '' }); // await expect(storage.openFile({ bucket: 'bucket', path: '' })).to.eventually.be.rejectedWith( // UnauthenticatedError, // ); // }); it('should throw if file is not found', async () => { const { storage, mockBuckets } = initStubbedStorage(); when(mockBuckets.pullPath('myBucketKey', '/file.txt', anything())).thenThrow( new Error('Error: no link named "file.txt" under QmVQWu2C3ZgdoAmBsffFASrgynAfgvYX8CCK4o9SxRvC4p'), ); await expect(storage.openFile({ bucket: 'personal', path: '/file.txt' })).to.eventually.be.rejectedWith( DirEntryNotFoundError, ); }); it('should return a valid stream of files data', async () => { const { storage, mockBuckets } = initStubbedStorage(); const actualFileContent = "file.txt's file content"; when(mockBuckets.pullPath('myBucketKey', '/file.txt', anything())).thenReturn( // eslint-disable-next-line @typescript-eslint/ban-ts-ignore // @ts-ignore newEncryptedDataWriter(actualFileContent, decodeFileEncryptionKey(encryptionKey)), ); const result = await storage.openFile({ bucket: 'personal', path: '/file.txt' }); const filesData = await result.consumeStream(); expect(new TextDecoder('utf8').decode(filesData)).to.equal(actualFileContent); expect(result.mimeType).to.equal('generic/type'); }); }); describe('openFileByUuid', () => { // it('should throw if uuid is not found', async () => { // // fix this when mocking metadatastore works // }); it('should return a valid stream of files data', async () => { const { storage, mockBuckets } = initStubbedStorage(); const fileUuid = v4(); const actualFileContent = "file.txt's file content"; when(mockBuckets.existing('mockThreadId')).thenReturn(Promise.resolve([ { ...mock<Root>(), name: 'myBucket', key: 'myBucketKey', }, ])); when(mockBuckets.listPath('myBucketKey', anyString())).thenResolve({ item: { ...mock<PathItem>(), name: 'file.txt', path: '/ipfs/Qm123/file.txt', metadata: { updatedAt: (new Date().getMilliseconds()) * 1000000, roles: new Map(), }, items: [], }, }); when(mockBuckets.pullPath('myBucketKey', anyString(), anything())).thenReturn( // eslint-disable-next-line @typescript-eslint/ban-ts-ignore // @ts-ignore newEncryptedDataWriter(actualFileContent, decodeFileEncryptionKey(encryptionKey)), ); const mockMembers = new Map<string, PathAccessRole>(); mockMembers.set('dummykey', PathAccessRole.PATH_ACCESS_ROLE_WRITER); when(mockBuckets.pullPathAccessRoles('myBucketKey', '/ipfs/Qm123/file.txt')).thenResolve(mockMembers); const result = await storage.openFileByUuid({ uuid: fileUuid }); const filesData = await result.consumeStream(); expect(new TextDecoder('utf8').decode(filesData)).to.equal(actualFileContent); expect(result.mimeType).to.equal('generic/type'); expect(result.entry.bucket).to.not.be.empty; expect(result.entry.dbId).to.not.be.empty; }); }); describe('addItems()', () => { it('should publish data, error and done events correctly', async () => { const { storage, mockBuckets } = initStubbedStorage(); const uploadError = new Error('update is non-fast-forward'); when(mockBuckets.pushPath('myBucketKey', anyString(), anything(), anything())).thenResolve({ ...mock<PushPathResult>(), }); const childItem = { name: 'entryName', path: '/ipfs/Qm123/entryName', cid: 'Qm...', isDir: false, size: 10, }; when(mockBuckets.listPath('myBucketKey', anyString())).thenResolve({ item: { ...mock<PathItem>(), name: 'entryName', path: '/ipfs/Qm123/entryName', cid: 'Qm...', isDir: false, size: 10, metadata: { updatedAt: (new Date().getMilliseconds()) * 1000000, roles: new Map<string, PathAccessRole>(), }, count: 0, items: [], }, }); // fail upload of b.txt when(mockBuckets.pushPath('myBucketKey', '/b.txt', anything(), anything())).thenReject(uploadError); const callbackData = { data: [] as AddItemsEventData[], error: [] as AddItemsEventData[], done: [] as AddItemsEventData[], }; // upload files const uploadResponse = await storage.addItems({ bucket: 'personal', files: [ { path: '/top/a.txt', data: 'a content', mimeType: 'text/plain', }, { path: 'b.txt', data: 'b content', mimeType: 'text/plain', }, ], }); // listen for status events uploadResponse.on('data', (it) => callbackData.data.push(it)); uploadResponse.on('error', (err) => callbackData.error.push(err)); await new Promise((resolve) => { uploadResponse.once('done', (it) => { callbackData.done.push(it); resolve(); }); }); // verify callback data expect(callbackData.data).to.containSubset([ { path: '/top/a.txt', status: 'success' }, { path: '/top', status: 'success' }, ]); expect(callbackData.error).to.containSubset([{ path: '/b.txt', status: 'error', error: uploadError }]); expect(callbackData.done).to.containSubset([ { bucket: 'personal', files: [ { path: '/top', status: 'success' }, { path: '/top/a.txt', status: 'success' }, { path: '/b.txt', status: 'error', error: uploadError }, ], }, ]); }); }); describe('shareViaPublicKey()', () => { let storage: UserStorage; let mockBuckets: Buckets; beforeEach(() => { const stub = initStubbedStorage(); storage = stub.storage; mockBuckets = stub.mockBuckets; }); it('should throw if public keys are empty', async () => { await expect( storage.shareViaPublicKey({ publicKeys: [], paths: [ { bucket: 'personal', path: '/randomPath', uuid: v4(), }, ], }), ).to.eventually.be.rejected; }); it('should throw if public keys are not valid', async () => { await expect( storage.shareViaPublicKey({ publicKeys: [{ id: '[email protected]', pk: 'invalid-pk-provided', }], paths: [ { bucket: 'personal', path: '/randomPath', uuid: v4(), }, ], }), ).to.eventually.be.rejectedWith('Unsupported encoding: i'); }); }); });