fix: `rooms.export` endpoint generates an empty export when given an invalid date (#32364)

pull/32454/head^2
Matheus Barbosa Silva 2 years ago committed by GitHub
parent 6ac3607533
commit f83bd56cc5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .changeset/ninety-rivers-mix.md
  2. 20
      apps/meteor/app/api/server/v1/rooms.ts
  3. 8
      apps/meteor/client/views/room/contextualBar/ExportMessages/FileExport.tsx
  4. 4
      apps/meteor/client/views/room/contextualBar/ExportMessages/MailExportForm.tsx
  5. 8
      apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts
  6. 298
      apps/meteor/tests/end-to-end/api/09-rooms.js
  7. 27
      apps/meteor/tests/mocks/data.ts
  8. 21
      apps/meteor/tests/unit/app/apps/server/mocks/data/messages.data.js
  9. 158
      apps/meteor/tests/unit/server/lib/dataExport/exportRoomMessagesToFile.spec.ts
  10. 143
      packages/rest-typings/src/v1/rooms.ts

@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": patch
"@rocket.chat/rest-typings": minor
---
Fixed issue with "Export room as file" feature (`rooms.export` endpoint) generating an empty export when given an invalid date

@ -2,7 +2,7 @@ import { Media } from '@rocket.chat/core-services';
import type { IRoom, IUpload } from '@rocket.chat/core-typings';
import { Messages, Rooms, Users, Uploads } from '@rocket.chat/models';
import type { Notifications } from '@rocket.chat/rest-typings';
import { isGETRoomsNameExists, isRoomsImagesProps, isRoomsMuteUnmuteUserProps } from '@rocket.chat/rest-typings';
import { isGETRoomsNameExists, isRoomsImagesProps, isRoomsMuteUnmuteUserProps, isRoomsExportProps } from '@rocket.chat/rest-typings';
import { Meteor } from 'meteor/meteor';
import { isTruthy } from '../../../../lib/isTruthy';
@ -599,15 +599,11 @@ API.v1.addRoute(
API.v1.addRoute(
'rooms.export',
{ authRequired: true },
{ authRequired: true, validateParams: isRoomsExportProps },
{
async post() {
const { rid, type } = this.bodyParams;
if (!rid || !type || !['email', 'file'].includes(type)) {
throw new Meteor.Error('error-invalid-params');
}
if (!(await hasPermissionAsync(this.userId, 'mail-messages', rid))) {
throw new Meteor.Error('error-action-not-allowed', 'Mailing is not allowed');
}
@ -627,12 +623,8 @@ API.v1.addRoute(
const { dateFrom, dateTo } = this.bodyParams;
const { format } = this.bodyParams;
if (!['html', 'json'].includes(format || '')) {
throw new Meteor.Error('error-invalid-format');
}
const convertedDateFrom = new Date(dateFrom || '');
const convertedDateTo = new Date(dateTo || '');
const convertedDateFrom = dateFrom ? new Date(dateFrom) : new Date(0);
const convertedDateTo = dateTo ? new Date(dateTo) : new Date();
convertedDateTo.setDate(convertedDateTo.getDate() + 1);
if (convertedDateFrom > convertedDateTo) {
@ -658,10 +650,6 @@ API.v1.addRoute(
throw new Meteor.Error('error-invalid-recipient');
}
if (messages?.length === 0) {
throw new Meteor.Error('error-invalid-messages');
}
const result = await dataExport.sendViaEmail(
{
rid,

@ -31,12 +31,12 @@ const FileExport = ({ formId, rid, exportOptions, onCancel }: FileExportProps) =
[t],
);
const handleExport = ({ type, dateFrom, dateTo, format }: MailExportFormValues) => {
const handleExport = ({ dateFrom, dateTo, format }: MailExportFormValues) => {
roomExportMutation.mutateAsync({
rid,
type,
dateFrom,
dateTo,
type: 'file',
...(dateFrom && { dateFrom }),
...(dateTo && { dateTo }),
format,
});
};

@ -72,10 +72,10 @@ const MailExportForm = ({ formId, rid, onCancel, exportOptions }: MailExportForm
setValue('messagesCount', messages.length);
}, [setValue, messages.length]);
const handleExport = async ({ type, toUsers, subject, additionalEmails }: MailExportFormValues) => {
const handleExport = async ({ toUsers, subject, additionalEmails }: MailExportFormValues) => {
roomExportMutation.mutateAsync({
rid,
type,
type: 'email',
toUsers,
toEmails: additionalEmails?.split(','),
subject,

@ -56,13 +56,13 @@ const getAttachmentData = (attachment: MessageAttachment, message: IMessage) =>
};
};
type MessageData = Pick<IMessage, 'msg' | 'ts'> & {
export type MessageData = Pick<IMessage, 'msg' | 'ts'> & {
username?: IUser['username'] | IUser['name'];
attachments?: ReturnType<typeof getAttachmentData>[];
type?: IMessage['t'];
};
const getMessageData = (
export const getMessageData = (
msg: IMessage,
hideUsers: boolean,
userData: Pick<IUser, 'username'> | undefined,
@ -160,7 +160,7 @@ const getMessageData = (
return messageObject;
};
const exportMessageObject = (type: 'json' | 'html', messageObject: MessageData, messageFile?: FileProp): string => {
export const exportMessageObject = (type: 'json' | 'html', messageObject: MessageData, messageFile?: FileProp): string => {
if (type === 'json') {
return JSON.stringify(messageObject);
}
@ -192,7 +192,7 @@ const exportMessageObject = (type: 'json' | 'html', messageObject: MessageData,
return file.join('\n');
};
const exportRoomMessages = async (
export const exportRoomMessages = async (
rid: IRoom['_id'],
exportType: 'json' | 'html',
skip: number,

@ -2140,4 +2140,302 @@ describe('[Rooms]', function () {
});
});
});
describe('/rooms.export', () => {
let testChannel;
let testMessageId;
before(async () => {
const result = await createRoom({ type: 'c', name: `channel.export.test.${Date.now()}-${Math.random()}` });
testChannel = result.body.channel;
const { body: { message } = {} } = await sendSimpleMessage({
roomId: testChannel._id,
text: 'Message to create thread',
});
testMessageId = message._id;
});
after(() => deleteRoom({ type: 'c', roomId: testChannel._id }));
it('should fail exporting room as file if dates are incorrectly provided', async () => {
return request
.post(api('rooms.export'))
.set(credentials)
.send({
rid: testChannel._id,
type: 'file',
dateFrom: 'test-date',
dateTo: 'test-date',
format: 'html',
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'invalid-params');
});
});
it('should fail exporting room as file if no roomId is provided', async () => {
return request
.post(api('rooms.export'))
.set(credentials)
.send({
type: 'file',
dateFrom: '2024-03-15',
dateTo: '2024-03-22',
format: 'html',
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'invalid-params');
expect(res.body).to.have.property('error').include("must have required property 'rid'");
});
});
it('should fail exporting room as file if no type is provided', async () => {
return request
.post(api('rooms.export'))
.set(credentials)
.send({
rid: testChannel._id,
dateFrom: '2024-03-15',
dateTo: '2024-03-22',
format: 'html',
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'invalid-params');
expect(res.body).to.have.property('error').include("must have required property 'type'");
});
});
it('should fail exporting room as file if fromDate is after toDate (incorrect date interval)', async () => {
return request
.post(api('rooms.export'))
.set(credentials)
.send({
rid: testChannel._id,
type: 'file',
dateFrom: '2024-03-22',
dateTo: '2024-03-15',
format: 'html',
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'error-invalid-dates');
expect(res.body).to.have.property('error', 'From date cannot be after To date [error-invalid-dates]');
});
});
it('should fail exporting room as file if invalid roomId is provided', async () => {
return request
.post(api('rooms.export'))
.set(credentials)
.send({
rid: 'invalid-rid',
type: 'file',
dateFrom: '2024-03-22',
dateTo: '2024-03-15',
format: 'html',
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'error-invalid-room');
});
});
it('should fail exporting room as file if no format is provided', async () => {
return request
.post(api('rooms.export'))
.set(credentials)
.send({
rid: testChannel._id,
type: 'file',
dateFrom: '2024-03-15',
dateTo: '2024-03-22',
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'invalid-params');
});
});
it('should fail exporting room as file if an invalid format is provided', async () => {
return request
.post(api('rooms.export'))
.set(credentials)
.send({
rid: testChannel._id,
type: 'file',
dateFrom: '2024-03-15',
dateTo: '2024-03-22',
format: 'invalid-format',
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'invalid-params');
});
});
it('should fail exporting room as file if an invalid type is provided', async () => {
return request
.post(api('rooms.export'))
.set(credentials)
.send({
rid: testChannel._id,
type: 'invalid-type',
dateFrom: '2024-03-15',
dateTo: '2024-03-22',
format: 'html',
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'invalid-params');
});
});
it('should succesfully export room as file', async () => {
return request
.post(api('rooms.export'))
.set(credentials)
.send({
rid: testChannel._id,
type: 'file',
dateFrom: '2024-03-15',
dateTo: '2024-03-22',
format: 'html',
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
});
it('should succesfully export room as file even if no dates are provided', async () => {
return request
.post(api('rooms.export'))
.set(credentials)
.send({
rid: testChannel._id,
type: 'file',
format: 'html',
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
});
it('should fail exporting room via email if target users AND target emails are NOT provided', async () => {
return request
.post(api('rooms.export'))
.set(credentials)
.send({
rid: testChannel._id,
type: 'email',
toUsers: [],
subject: 'Test Subject',
messages: [testMessageId],
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'error-invalid-recipient');
});
});
it('should fail exporting room via email if no target e-mails are provided', async () => {
return request
.post(api('rooms.export'))
.set(credentials)
.send({
rid: testChannel._id,
type: 'email',
toEmails: [],
subject: 'Test Subject',
messages: [testMessageId],
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'error-invalid-recipient');
});
});
it('should fail exporting room via email if no target users or e-mails params are provided', async () => {
return request
.post(api('rooms.export'))
.set(credentials)
.send({
rid: testChannel._id,
type: 'email',
subject: 'Test Subject',
messages: [testMessageId],
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'error-invalid-recipient');
});
});
it('should fail exporting room via email if no messages are provided', async () => {
return request
.post(api('rooms.export'))
.set(credentials)
.send({
rid: testChannel._id,
type: 'email',
toUsers: [credentials['X-User-Id']],
subject: 'Test Subject',
messages: [],
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('errorType', 'invalid-params');
});
});
it('should succesfully export room via email', async () => {
return request
.post(api('rooms.export'))
.set(credentials)
.send({
rid: testChannel._id,
type: 'email',
toUsers: [credentials['X-User-Id']],
subject: 'Test Subject',
messages: [testMessageId],
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('missing');
expect(res.body.missing).to.be.an('array').that.is.empty;
});
});
});
});

@ -228,3 +228,30 @@ export const createFakeLicenseInfo = (partial: Partial<Omit<LicenseInfo, 'licens
trial: faker.datatype.boolean(),
...partial,
});
export function createFakeMessageWithAttachment<TMessage extends IMessage>(overrides?: Partial<TMessage>): TMessage;
export function createFakeMessageWithAttachment(overrides?: Partial<IMessage>): IMessage {
const fakeMessage = createFakeMessage(overrides);
const fileId = faker.database.mongodbObjectId();
const fileName = faker.system.commonFileName('txt');
return {
...fakeMessage,
msg: '',
file: {
_id: fileId,
name: fileName,
type: 'text/plain',
size: faker.number.int(),
format: faker.string.alpha(),
},
attachments: [
{
type: 'file',
title: fileName,
title_link: `/file-upload/${fileId}/${fileName}`,
},
],
...overrides,
};
}

@ -1,3 +1,7 @@
import { faker } from '@faker-js/faker';
import { createFakeMessage, createFakeMessageWithAttachment } from '../../../../../../mocks/data';
export const appMessageMock = {
id: 'appMessageMock',
text: 'rocket.cat',
@ -105,3 +109,20 @@ export const appMessageInvalidRoomMock = {
t: 'uj',
},
};
const testUsername = faker.internet.userName();
const testUserId = faker.database.mongodbObjectId();
export const exportMessagesMock = [
createFakeMessage({ t: 'uj', u: { _id: testUserId, username: testUsername }, msg: testUsername }),
createFakeMessageWithAttachment(),
createFakeMessageWithAttachment({
attachments: [
{
type: 'file',
title_link: '/file-upload/txt-file-id/test.txt',
},
],
}),
createFakeMessage(),
createFakeMessage({ t: 'ujt', u: { _id: testUserId, username: testUsername }, msg: testUsername }),
];

@ -0,0 +1,158 @@
import { faker } from '@faker-js/faker';
import { expect } from 'chai';
import { before, describe, it } from 'mocha';
import proxyquire from 'proxyquire';
import sinon from 'sinon';
import type { MessageData } from '../../../../../server/lib/dataExport/exportRoomMessagesToFile';
import { exportMessagesMock } from '../../../app/apps/server/mocks/data/messages.data';
// Create stubs for dependencies
const stubs = {
findPaginatedMessages: sinon.stub(),
mkdir: sinon.stub(),
writeFile: sinon.stub(),
findPaginatedMessagesCursor: sinon.stub(),
findPaginatedMessagesTotal: sinon.stub(),
translateKey: sinon.stub(),
settings: sinon.stub(),
};
const { getMessageData, exportRoomMessages, exportMessageObject } = proxyquire
.noCallThru()
.load('../../../../../server/lib/dataExport/exportRoomMessagesToFile.ts', {
'@rocket.chat/models': {
Messages: {
findPaginated: stubs.findPaginatedMessages,
},
},
'fs/promises': {
mkdir: stubs.mkdir,
writeFile: stubs.writeFile,
},
'../i18n': {
i18n: {
t: stubs.translateKey,
},
},
'../../../app/settings/server': {
settings: stubs.settings,
},
});
describe('Export - exportMessageObject', () => {
let messagesData: MessageData[];
const translationPlaceholder = 'translation-placeholder';
before(() => {
stubs.translateKey.returns(translationPlaceholder);
messagesData = exportMessagesMock.map((message) => getMessageData(message, false));
});
it('should only stringify message object when exporting message as json', async () => {
const result = await exportMessageObject('json', messagesData[3]);
expect(result).to.be.a.string;
expect(result).to.equal(JSON.stringify(messagesData[3]));
});
it('should correctly add tags when exporting plain text message object as html', async () => {
const result = await exportMessageObject('html', messagesData[3]);
expect(result).to.be.a.string;
expect(result).to.equal(
`<p><strong>${messagesData[3].username}</strong> (${new Date(messagesData[3].ts).toUTCString()}):<br/>\n${messagesData[3].msg}\n</p>`,
);
});
it('should correctly format system messages when exporting message object as html', async () => {
const result = await exportMessageObject('html', messagesData[0]);
expect(messagesData[0].msg).to.equal(translationPlaceholder);
expect(result).to.be.a.string;
expect(result).to.equal(
`<p><strong>${messagesData[0].username}</strong> (${new Date(messagesData[0].ts).toUTCString()}):<br/>\n<i>${
messagesData[0].msg
}</i>\n</p>`,
);
});
it('should correctly format non italic system messages when exporting message object as html', async () => {
const result = await exportMessageObject('html', messagesData[4]);
expect(messagesData[4].msg).to.equal(translationPlaceholder);
expect(result).to.be.a.string;
expect(result).to.equal(
`<p><strong>${messagesData[4].username}</strong> (${new Date(messagesData[4].ts).toUTCString()}):<br/>\n${messagesData[4].msg}\n</p>`,
);
});
it('should correctly reference file when exporting a message object with an attachment as html', async () => {
const result = await exportMessageObject('html', messagesData[1], exportMessagesMock[1].file);
expect(result).to.be.a.string;
expect(result).to.equal(
`<p><strong>${messagesData[1].username}</strong> (${new Date(messagesData[1].ts).toUTCString()}):<br/>\n${
messagesData[1].msg
}\n<br/><a href="./assets/${exportMessagesMock[1].file?._id}-${exportMessagesMock[1].file?.name}">${
messagesData[1].attachments?.[0].title
}</a>\n</p>`,
);
});
it('should use fallback attachment description when no title is provided on message object export as html', async () => {
const result = await exportMessageObject('html', messagesData[2], exportMessagesMock[2].file);
expect(stubs.translateKey.calledWith('Message_Attachments')).to.be.true;
expect(result).to.be.a.string;
expect(result).to.equal(
`<p><strong>${messagesData[2].username}</strong> (${new Date(messagesData[2].ts).toUTCString()}):<br/>\n${
exportMessagesMock[1].msg
}\n<br/><a href="./assets/${exportMessagesMock[2].file?._id}-${
exportMessagesMock[2].file?.name
}">${translationPlaceholder}</a>\n</p>`,
);
});
});
describe('Export - exportRoomMessages', () => {
const totalMessages = 10;
const userData = {
_id: faker.database.mongodbObjectId(),
name: faker.person.fullName(),
username: faker.internet.userName(),
};
before(() => {
stubs.findPaginatedMessagesCursor.resolves(exportMessagesMock);
stubs.findPaginatedMessagesTotal.resolves(totalMessages);
stubs.findPaginatedMessages.returns({
cursor: { toArray: stubs.findPaginatedMessagesCursor },
totalCount: stubs.findPaginatedMessagesTotal(),
});
stubs.translateKey.returns('translated-placeholder-uj');
});
it('should correctly export multiple messages to result when exporting room as json', async () => {
const result = await exportRoomMessages('test-rid', 'json', 0, 100, userData);
expect(stubs.translateKey.calledWith('User_joined_the_channel')).to.be.true;
expect(result).to.be.an('object');
expect(result).to.have.property('total', totalMessages);
expect(result).to.have.property('exported', exportMessagesMock.length);
expect(result).to.have.property('messages').that.is.an('array').of.length(exportMessagesMock.length);
const messagesWithFiles = exportMessagesMock.filter((message) => message.file);
expect(result).to.have.property('uploads').that.is.an('array').of.length(messagesWithFiles.length);
});
it('should correctly export multiple messages to result when exporting room as html', async () => {
const result = await exportRoomMessages('test-rid', 'html', 0, 100, userData);
expect(stubs.translateKey.calledWith('User_joined_the_channel')).to.be.true;
expect(result).to.be.an('object');
expect(result).to.have.property('total', totalMessages);
expect(result).to.have.property('exported', exportMessagesMock.length);
expect(result).to.have.property('messages').that.is.an('array').of.length(exportMessagesMock.length);
const messagesWithFiles = exportMessagesMock.filter((message) => message.file);
expect(result).to.have.property('uploads').that.is.an('array').of.length(messagesWithFiles.length);
});
});

@ -1,12 +1,8 @@
import type { IMessage, IRoom, IUser, RoomAdminFieldsType, IUpload } from '@rocket.chat/core-typings';
import Ajv from 'ajv';
import type { PaginatedRequest } from '../helpers/PaginatedRequest';
import type { PaginatedResult } from '../helpers/PaginatedResult';
const ajv = new Ajv({
coerceTypes: true,
});
import { ajv } from './Ajv';
type RoomsAutoCompleteChannelAndPrivateProps = { selector: string };
@ -169,73 +165,100 @@ const RoomsCreateDiscussionSchema = {
export const isRoomsCreateDiscussionProps = ajv.compile<RoomsCreateDiscussionProps>(RoomsCreateDiscussionSchema);
type RoomsExportProps = {
type RoomsExportProps = RoomsExportFileProps | RoomsExportEmailProps;
type RoomsExportFileProps = {
rid: IRoom['_id'];
type: 'email' | 'file';
type: 'file';
format: 'html' | 'json';
dateFrom?: string;
dateTo?: string;
};
type RoomsExportEmailProps = {
rid: IRoom['_id'];
type: 'email';
toUsers?: IUser['username'][];
toEmails?: string[];
additionalEmails?: string;
subject?: string;
messages?: IMessage['_id'][];
dateFrom?: string;
dateTo?: string;
format?: 'html' | 'json';
messages: IMessage['_id'][];
};
const RoomsExportSchema = {
type: 'object',
properties: {
rid: {
type: 'string',
},
type: {
type: 'string',
nullable: true,
},
toUsers: {
type: 'array',
items: {
type: 'string',
},
nullable: true,
},
toEmails: {
type: 'array',
items: {
type: 'string',
oneOf: [
{
type: 'object',
properties: {
rid: {
type: 'string',
},
type: {
type: 'string',
enum: ['file'],
},
format: {
type: 'string',
enum: ['html', 'json'],
},
dateFrom: {
type: 'string',
nullable: true,
format: 'date',
},
dateTo: {
type: 'string',
nullable: true,
format: 'date',
},
},
nullable: true,
},
additionalEmails: {
type: 'string',
nullable: true,
},
subject: {
type: 'string',
nullable: true,
required: ['rid', 'type', 'format'],
additionalProperties: false,
},
messages: {
type: 'array',
items: {
type: 'string',
{
type: 'object',
properties: {
rid: {
type: 'string',
},
type: {
type: 'string',
enum: ['email'],
},
toUsers: {
type: 'array',
items: {
type: 'string',
},
nullable: true,
},
toEmails: {
type: 'array',
items: {
type: 'string',
},
nullable: true,
},
additionalEmails: {
type: 'string',
nullable: true,
},
subject: {
type: 'string',
nullable: true,
},
messages: {
type: 'array',
items: {
type: 'string',
},
minItems: 1,
},
},
nullable: true,
},
dateFrom: {
type: 'string',
nullable: true,
},
dateTo: {
type: 'string',
nullable: true,
},
format: {
type: 'string',
nullable: true,
required: ['rid', 'type', 'messages'],
additionalProperties: false,
},
},
required: ['rid'],
additionalProperties: false,
],
};
export const isRoomsExportProps = ajv.compile<RoomsExportProps>(RoomsExportSchema);

Loading…
Cancel
Save