From cd69d2c816baa7fe66cfbf02b19c788696eb2df7 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Tue, 20 Jan 2026 20:23:13 -0300 Subject: [PATCH 1/7] fix: schema definition POSTLivechatSaveCustomFieldsSchema --- .../model-typings/src/models/ILivechatCustomFieldModel.ts | 2 +- packages/models/src/models/LivechatCustomField.ts | 2 +- packages/rest-typings/src/v1/omnichannel.ts | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/model-typings/src/models/ILivechatCustomFieldModel.ts b/packages/model-typings/src/models/ILivechatCustomFieldModel.ts index 3c1844e370c6a..20cb2d610355a 100644 --- a/packages/model-typings/src/models/ILivechatCustomFieldModel.ts +++ b/packages/model-typings/src/models/ILivechatCustomFieldModel.ts @@ -26,7 +26,7 @@ export interface ILivechatCustomFieldModel extends IBaseModel, ): FindCursor; createOrUpdateCustomField( - _id: string, + _id: string | null, field: string, label: ILivechatCustomField['label'], scope: ILivechatCustomField['scope'], diff --git a/packages/models/src/models/LivechatCustomField.ts b/packages/models/src/models/LivechatCustomField.ts index df13b279442a2..20b445cf46135 100644 --- a/packages/models/src/models/LivechatCustomField.ts +++ b/packages/models/src/models/LivechatCustomField.ts @@ -50,7 +50,7 @@ export class LivechatCustomFieldRaw extends BaseRaw implem } async createOrUpdateCustomField( - _id: string, + _id: string | null, field: string, label: ILivechatCustomField['label'], scope: ILivechatCustomField['scope'], diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index 801396f3bc31f..72504f380786e 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -4445,12 +4445,15 @@ const POSTLivechatSaveCustomFieldsSchema = { properties: { customFieldId: { type: 'string', + pattern: '^[0-9a-zA-Z_-]+$', + nullable: true, }, customFieldData: { type: 'object', properties: { field: { type: 'string', + pattern: '^[0-9a-zA-Z_-]+$', }, label: { type: 'string', @@ -4497,7 +4500,7 @@ const POSTLivechatSaveCustomFieldsSchema = { }; export const isPOSTLivechatSaveCustomFieldsParams = ajv.compile<{ - customFieldId: string; + customFieldId: string | null; customFieldData: Omit & { field: string }; }>(POSTLivechatSaveCustomFieldsSchema); From cda9430751841de21a82e2cb50a9335629ad2d76 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Tue, 27 Jan 2026 18:30:24 -0300 Subject: [PATCH 2/7] refactor: Kevin said this is not needed --- packages/rest-typings/src/v1/omnichannel.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index 72504f380786e..a2e0a595fd022 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -4446,7 +4446,6 @@ const POSTLivechatSaveCustomFieldsSchema = { customFieldId: { type: 'string', pattern: '^[0-9a-zA-Z_-]+$', - nullable: true, }, customFieldData: { type: 'object', From 84c46274884b1b97a02c41f1240f0c4c3a258785 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 3 Feb 2026 09:22:09 -0300 Subject: [PATCH 3/7] types --- packages/rest-typings/src/v1/omnichannel.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index a2e0a595fd022..5141fa6b45001 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -4445,7 +4445,6 @@ const POSTLivechatSaveCustomFieldsSchema = { properties: { customFieldId: { type: 'string', - pattern: '^[0-9a-zA-Z_-]+$', }, customFieldData: { type: 'object', From e63bead4262360fce86a639b19ce3ff6be351845 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 4 Feb 2026 14:45:51 -0300 Subject: [PATCH 4/7] test --- .../tests/end-to-end/api/livechat/00-rooms.ts | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts index 6610708eba6c0..928ebf1850de0 100644 --- a/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts +++ b/apps/meteor/tests/end-to-end/api/livechat/00-rooms.ts @@ -2940,7 +2940,6 @@ describe('LIVECHAT - rooms', () => { expect(latestRoom).to.have.property('tags').to.include('tag2'); expect(latestRoom).to.have.property('livechatData').to.have.property(cfName, 'test-input-1-value'); }); - it('should throw an error if custom fields are not valid', async () => { await request .post(api('livechat/room.saveInfo')) @@ -3000,6 +2999,56 @@ describe('LIVECHAT - rooms', () => { expect(response.body).to.have.property('success', false); expect(response.body).to.have.property('error', 'Invalid value for intfield field'); }); + it('should throw an error if room _id is empty string', async () => { + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: '', + livechatData: {}, + }, + guestData: { + _id: visitor._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + it('should throw an error if visitor _id is empty string', async () => { + await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: room._id, + livechatData: {}, + }, + guestData: { + _id: '', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + }); + + it('should throw an error if livechatData contains invalid field format', async () => { + const response = await request + .post(api('livechat/room.saveInfo')) + .set(credentials) + .send({ + roomData: { + _id: room._id, + livechatData: { intfield: ['array', 'instead', 'of', 'string'] }, + }, + guestData: { + _id: visitor._id, + }, + }) + .expect('Content-Type', 'application/json') + .expect(400); + expect(response.body).to.have.property('success', false); + }); it('should not throw an error if a valid custom field passes the check', async () => { const response2 = await request .post(api('livechat/room.saveInfo')) From f5266713b45edda08a2e7df560bd712a454abd1b Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Thu, 5 Feb 2026 13:54:58 -0300 Subject: [PATCH 5/7] fix: schema definition --- packages/rest-typings/src/v1/omnichannel.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index 5141fa6b45001..d42354fd0e332 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -4445,6 +4445,7 @@ const POSTLivechatSaveCustomFieldsSchema = { properties: { customFieldId: { type: 'string', + nullable: true, }, customFieldData: { type: 'object', @@ -4492,6 +4493,7 @@ const POSTLivechatSaveCustomFieldsSchema = { nullable: true, }, }, + required: ['field', 'label', 'scope', 'visibility'], }, }, additionalProperties: false, From f4f2cbdc014ef0829a76a724d52cff5bba00aaeb Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Thu, 5 Feb 2026 14:00:29 -0300 Subject: [PATCH 6/7] add changeset --- .changeset/silver-clocks-help.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/silver-clocks-help.md diff --git a/.changeset/silver-clocks-help.md b/.changeset/silver-clocks-help.md new file mode 100644 index 0000000000000..98b16342eed74 --- /dev/null +++ b/.changeset/silver-clocks-help.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/model-typings': patch +'@rocket.chat/rest-typings': patch +'@rocket.chat/models': patch +'@rocket.chat/meteor': patch +--- + +Fix a validation issue in the `livechat/custom-fields.save` endpoint From 9fe725784e0f9acd2c2930d5f8e40ada1ecd7754 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Tue, 10 Feb 2026 11:24:09 -0300 Subject: [PATCH 7/7] tests: add http API tests for the livechat/custom-fields.save endpoint --- .../tests/data/livechat/custom-fields.ts | 2 +- .../api/livechat/custom-fields-save.ts | 297 ++++++++++++++++++ 2 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 apps/meteor/tests/end-to-end/api/livechat/custom-fields-save.ts diff --git a/apps/meteor/tests/data/livechat/custom-fields.ts b/apps/meteor/tests/data/livechat/custom-fields.ts index b1f4244e3ab82..d85ea04c26e4b 100644 --- a/apps/meteor/tests/data/livechat/custom-fields.ts +++ b/apps/meteor/tests/data/livechat/custom-fields.ts @@ -5,7 +5,7 @@ import { credentials, request, api } from '../api-data'; type ExtendedCustomField = Omit & { field: string }; -export const createCustomField = (customField: ExtendedCustomField): Promise => +export const createCustomField = (customField: ExtendedCustomField): Promise => new Promise((resolve, reject) => { void request .get(api(`livechat/custom-fields/${customField.label}`)) diff --git a/apps/meteor/tests/end-to-end/api/livechat/custom-fields-save.ts b/apps/meteor/tests/end-to-end/api/livechat/custom-fields-save.ts new file mode 100644 index 0000000000000..2ba82c4e7767a --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/custom-fields-save.ts @@ -0,0 +1,297 @@ +import type { ILivechatCustomField } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; +import type { Response } from 'supertest'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { createCustomField, deleteCustomField } from '../../../data/livechat/custom-fields'; +import { + getSettingValueById, + removePermissionFromAllRoles, + restorePermissionToRoles, + updateSetting, +} from '../../../data/permissions.helper'; + +describe('LIVECHAT - custom fields', () => { + let settingLivechatEnabled: boolean; + + before((done) => getCredentials(done)); + + before(async () => { + settingLivechatEnabled = (await getSettingValueById('Livechat_enabled')) as boolean; + await updateSetting('Livechat_enabled', true); + }); + + after(async () => { + await updateSetting('Livechat_enabled', settingLivechatEnabled); + }); + + describe('livechat/custom-fields.save', () => { + let customFieldId: string; + + after(async () => { + if (customFieldId) { + await deleteCustomField(customFieldId); + } + }); + + describe('Authentication/Authorization', () => { + it('should return an "unauthenticated error" when user is not logged in', async () => { + await request + .post(api('livechat/custom-fields.save')) + .send({ + customFieldId: null, + customFieldData: { + field: 'test_field', + label: 'Test Field', + scope: 'visitor', + visibility: 'public', + }, + }) + .expect('Content-Type', 'application/json') + .expect(401); + }); + + it('should return an "unauthorized error" when the user does not have the necessary permission', async () => { + await removePermissionFromAllRoles('view-livechat-manager'); + + await request + .post(api('livechat/custom-fields.save')) + .set(credentials) + .send({ + customFieldId: null, + customFieldData: { + field: 'test_field', + label: 'Test Field', + scope: 'visitor', + visibility: 'public', + }, + }) + .expect('Content-Type', 'application/json') + .expect(403); + + await restorePermissionToRoles('view-livechat-manager'); + }); + }); + + describe('Create custom field', () => { + afterEach(async () => { + if (customFieldId) { + await deleteCustomField(customFieldId); + customFieldId = ''; + } + }); + + it('should create a new custom field with minimal required fields', async () => { + const fieldName = `field_${Date.now()}`; + + await request + .post(api('livechat/custom-fields.save')) + .set(credentials) + .send({ + customFieldId: null, + customFieldData: { + field: fieldName, + label: 'Test Field', + scope: 'visitor', + visibility: 'public', + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('customField'); + expect(res.body.customField).to.have.property('_id'); + expect(res.body.customField).to.have.property('label', 'Test Field'); + expect(res.body.customField).to.have.property('scope', 'visitor'); + expect(res.body.customField).to.have.property('visibility', 'public'); + customFieldId = res.body.customField._id; + }); + }); + + it('should create a new custom field with scope "room"', async () => { + const fieldName = `room_field_${Date.now()}`; + + await request + .post(api('livechat/custom-fields.save')) + .set(credentials) + .send({ + customFieldId: null, + customFieldData: { + field: fieldName, + label: 'Room Test Field', + scope: 'room', + visibility: 'public', + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('customField'); + expect(res.body.customField).to.have.property('scope', 'room'); + customFieldId = res.body.customField._id; + }); + }); + + it('should create a new custom field with all optional fields', async () => { + const fieldName = `full_field_${Date.now()}`; + + await request + .post(api('livechat/custom-fields.save')) + .set(credentials) + .send({ + customFieldId: null, + customFieldData: { + field: fieldName, + label: 'Full Test Field', + scope: 'visitor', + visibility: 'public', + type: 'input', + regexp: '^[A-Za-z]+$', + required: true, + defaultValue: 'default', + options: 'option1,option2,option3', + public: true, + searchable: true, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('customField'); + expect(res.body.customField).to.have.property('type', 'input'); + expect(res.body.customField).to.have.property('regexp', '^[A-Za-z]+$'); + expect(res.body.customField).to.have.property('required', true); + expect(res.body.customField).to.have.property('defaultValue', 'default'); + expect(res.body.customField).to.have.property('options', 'option1,option2,option3'); + expect(res.body.customField).to.have.property('public', true); + expect(res.body.customField).to.have.property('searchable', true); + customFieldId = res.body.customField._id; + }); + }); + + it('should fail when trying to create a custom field with a field name that already exists', async () => { + const fieldName = `duplicate_field_${Date.now()}`; + + // Create the first custom field + const { body } = await request + .post(api('livechat/custom-fields.save')) + .set(credentials) + .send({ + customFieldId: null, + customFieldData: { + field: fieldName, + label: 'First Field', + scope: 'visitor', + visibility: 'public', + }, + }) + .expect(200); + + customFieldId = body.customField._id; + + // Try to create another with the same field name + await request + .post(api('livechat/custom-fields.save')) + .set(credentials) + .send({ + customFieldId: null, + customFieldData: { + field: fieldName, + label: 'Second Field', + scope: 'visitor', + visibility: 'public', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + }); + + describe('Update custom field', () => { + let existingField: ILivechatCustomField; + + before(async () => { + const fieldName = `update_test_field_${Date.now()}`; + existingField = await createCustomField({ + searchable: true, + field: fieldName, + label: 'Original Label', + defaultValue: 'original_default', + scope: 'visitor', + visibility: 'public', + regexp: '', + }); + }); + + after(async () => { + if (existingField?._id) { + await deleteCustomField(existingField._id); + } + }); + + it('should fail when trying to update a non-existent custom field', async () => { + await request + .post(api('livechat/custom-fields.save')) + .set(credentials) + .send({ + customFieldId: 'non-existent-id', + customFieldData: { + field: 'test_field', + label: 'Updated Label', + scope: 'visitor', + visibility: 'public', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res: Response) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + + it('should update an existing custom field with all optional fields', async () => { + await request + .post(api('livechat/custom-fields.save')) + .set(credentials) + .send({ + customFieldId: existingField._id, + customFieldData: { + field: existingField._id, + label: 'Fully Updated Field', + scope: existingField.scope, + visibility: 'public', + type: 'select', + regexp: '^[0-9]+$', + required: true, + defaultValue: 'new_default', + options: 'opt1,opt2,opt3', + public: true, + searchable: false, + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res: Response) => { + expect(res.body).to.have.property('success', true); + expect(res.body.customField).to.have.property('label', 'Fully Updated Field'); + expect(res.body.customField).to.have.property('type', 'select'); + expect(res.body.customField).to.have.property('regexp', '^[0-9]+$'); + expect(res.body.customField).to.have.property('required', true); + expect(res.body.customField).to.have.property('defaultValue', 'new_default'); + expect(res.body.customField).to.have.property('options', 'opt1,opt2,opt3'); + expect(res.body.customField).to.have.property('public', true); + expect(res.body.customField).to.have.property('searchable', false); + }); + }); + }); + }); +});