diff --git a/.changeset/twelve-seas-battle.md b/.changeset/twelve-seas-battle.md new file mode 100644 index 000000000000..a527a93f6212 --- /dev/null +++ b/.changeset/twelve-seas-battle.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixed an issue where old exports would get overwritten by new ones if generated on the same day, when using external storage services (such as Amazon S3) diff --git a/apps/meteor/server/lib/dataExport/uploadZipFile.ts b/apps/meteor/server/lib/dataExport/uploadZipFile.ts index 5fe9ea2d57dd..77a16004bf64 100644 --- a/apps/meteor/server/lib/dataExport/uploadZipFile.ts +++ b/apps/meteor/server/lib/dataExport/uploadZipFile.ts @@ -3,6 +3,7 @@ import { stat } from 'fs/promises'; import type { IUser } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; +import { Random } from '@rocket.chat/random'; import { FileUpload } from '../../../app/file-upload/server'; @@ -18,10 +19,12 @@ export const uploadZipFile = async (filePath: string, userId: IUser['_id'], expo const utcDate = new Date().toISOString().split('T')[0]; const fileSuffix = exportType === 'json' ? '-data' : ''; + const fileId = Random.id(); - const newFileName = encodeURIComponent(`${utcDate}-${userDisplayName}${fileSuffix}.zip`); + const newFileName = encodeURIComponent(`${utcDate}-${userDisplayName}${fileSuffix}-${fileId}.zip`); const details = { + _id: fileId, userId, type: contentType, size, diff --git a/apps/meteor/tests/unit/server/lib/dataExport/uploadZipFile.spec.ts b/apps/meteor/tests/unit/server/lib/dataExport/uploadZipFile.spec.ts new file mode 100644 index 000000000000..61a477b40df5 --- /dev/null +++ b/apps/meteor/tests/unit/server/lib/dataExport/uploadZipFile.spec.ts @@ -0,0 +1,153 @@ +import { expect } from 'chai'; +import { before, describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +// Create stubs for dependencies +const stubs = { + findOneUserById: sinon.stub(), + randomId: sinon.stub(), + stat: sinon.stub(), + getStore: sinon.stub(), + insertFileStub: sinon.stub(), + createReadStream: sinon.stub(), +}; + +const { uploadZipFile } = proxyquire.noCallThru().load('../../../../../server/lib/dataExport/uploadZipFile.ts', { + '@rocket.chat/models': { + Users: { + findOneById: stubs.findOneUserById, + }, + }, + '@rocket.chat/random': { + Random: { + id: stubs.randomId, + }, + }, + 'fs/promises': { + stat: stubs.stat, + }, + 'fs': { + createReadStream: stubs.createReadStream, + }, + '../../../app/file-upload/server': { + FileUpload: { + getStore: stubs.getStore, + }, + }, +}); + +describe('Export - uploadZipFile', () => { + const randomId = 'random-id'; + const fileStat = 100; + const userName = 'John Doe'; + const userUsername = 'john.doe'; + const userId = 'user-id'; + const filePath = 'random-path'; + + before(() => { + stubs.findOneUserById.returns({ name: userName }); + stubs.stat.returns({ size: fileStat }); + stubs.randomId.returns(randomId); + stubs.getStore.returns({ insert: stubs.insertFileStub }); + stubs.insertFileStub.callsFake((details) => ({ _id: details._id, name: details.name })); + }); + + it('should correctly build file name for json exports', async () => { + const result = await uploadZipFile(filePath, userId, 'json'); + + expect(stubs.findOneUserById.calledWith(userId)).to.be.true; + expect(stubs.stat.calledWith(filePath)).to.be.true; + expect(stubs.createReadStream.calledWith(filePath)).to.be.true; + expect(stubs.getStore.calledWith('UserDataFiles')).to.be.true; + expect( + stubs.insertFileStub.calledWith( + sinon.match({ + _id: randomId, + userId, + type: 'application/zip', + size: fileStat, + }), + ), + ).to.be.true; + + expect(result).to.have.property('_id', randomId); + expect(result).to.have.property('name').that.is.a.string; + const fileName: string = result.name; + expect(fileName.endsWith(encodeURIComponent(`${userName}-data-${randomId}.zip`))).to.be.true; + }); + + it('should correctly build file name for html exports', async () => { + const result = await uploadZipFile(filePath, userId, 'html'); + + expect(stubs.findOneUserById.calledWith(userId)).to.be.true; + expect(stubs.stat.calledWith(filePath)).to.be.true; + expect(stubs.createReadStream.calledWith(filePath)).to.be.true; + expect(stubs.getStore.calledWith('UserDataFiles')).to.be.true; + expect( + stubs.insertFileStub.calledWith( + sinon.match({ + _id: randomId, + userId, + type: 'application/zip', + size: fileStat, + }), + ), + ).to.be.true; + + expect(result).to.have.property('_id', randomId); + expect(result).to.have.property('name').that.is.a.string; + const fileName: string = result.name; + expect(fileName.endsWith(encodeURIComponent(`${userName}-${randomId}.zip`))).to.be.true; + }); + + it("should use username as a fallback in the zip file name when user's name is not defined", async () => { + stubs.findOneUserById.returns({ username: userUsername }); + const result = await uploadZipFile(filePath, userId, 'html'); + + expect(stubs.findOneUserById.calledWith(userId)).to.be.true; + expect(stubs.stat.calledWith(filePath)).to.be.true; + expect(stubs.createReadStream.calledWith(filePath)).to.be.true; + expect(stubs.getStore.calledWith('UserDataFiles')).to.be.true; + expect( + stubs.insertFileStub.calledWith( + sinon.match({ + _id: randomId, + userId, + type: 'application/zip', + size: fileStat, + }), + ), + ).to.be.true; + + expect(result).to.have.property('_id', randomId); + expect(result).to.have.property('name').that.is.a.string; + const fileName: string = result.name; + expect(fileName.endsWith(`${userUsername}-${randomId}.zip`)).to.be.true; + }); + + it("should use userId as a fallback in the zip file name when user's name and username are not defined", async () => { + stubs.findOneUserById.returns(undefined); + const result = await uploadZipFile(filePath, userId, 'html'); + + expect(stubs.findOneUserById.calledWith(userId)).to.be.true; + expect(stubs.stat.calledWith(filePath)).to.be.true; + expect(stubs.createReadStream.calledWith(filePath)).to.be.true; + expect(stubs.getStore.calledWith('UserDataFiles')).to.be.true; + expect( + stubs.insertFileStub.calledWith( + sinon.match({ + _id: randomId, + userId, + type: 'application/zip', + size: fileStat, + }), + ), + ).to.be.true; + + expect(result).to.have.property('_id', randomId); + expect(result).to.have.property('name').that.is.a.string; + const fileName: string = result.name; + expect(fileName.endsWith(`${userId}-${randomId}.zip`)).to.be.true; + }); +});