From 21221adc78ff3bb5695c96b487418329e75484be Mon Sep 17 00:00:00 2001 From: "n8n-cat-bot[bot]" <283985454+n8n-cat-bot[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:56:23 +0000 Subject: [PATCH] fix: Validate attendee values (#40953) Co-authored-by: n8n-cat-bot[bot] --- .../nodes/Google/Calendar/GenericFunctions.ts | 25 +++ .../Google/Calendar/GoogleCalendar.node.ts | 34 +--- .../Calendar/test/node/event.create.test.ts | 175 ++++++++++++++++++ .../Calendar/test/node/event.update.test.ts | 48 ++++- 4 files changed, 250 insertions(+), 32 deletions(-) create mode 100644 packages/nodes-base/nodes/Google/Calendar/test/node/event.create.test.ts diff --git a/packages/nodes-base/nodes/Google/Calendar/GenericFunctions.ts b/packages/nodes-base/nodes/Google/Calendar/GenericFunctions.ts index ce7dadd4f9e..d542208ecbb 100644 --- a/packages/nodes-base/nodes/Google/Calendar/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Google/Calendar/GenericFunctions.ts @@ -19,6 +19,31 @@ import { sleep } from '@n8n/utils/sleep'; import type { RecurringEventInstance } from './EventInterface'; +export function parseAttendees( + this: IExecuteFunctions, + attendees: unknown, + itemIndex: number, +): Array<{ email: string }> { + if ( + !Array.isArray(attendees) || + !attendees.every( + (attendee: unknown): attendee is string => + typeof attendee === 'string' && + attendee.split(',').every((email) => email.trim().length > 0), + ) + ) { + throw new NodeOperationError(this.getNode(), 'Invalid attendee value', { + description: + 'Set each attendee to an email address string. To include multiple addresses in one entry, separate them with commas.', + itemIndex, + }); + } + + return attendees.flatMap((attendee) => + attendee.split(',').map((email) => ({ email: email.trim() })), + ); +} + export async function googleApiRequest( this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, method: IHttpRequestMethods, diff --git a/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts b/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts index 787c9f6abe9..2a3a281e002 100644 --- a/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts +++ b/packages/nodes-base/nodes/Google/Calendar/GoogleCalendar.node.ts @@ -27,6 +27,7 @@ import { googleApiRequest, googleApiRequestAllItems, googleApiRequestWithRetries, + parseAttendees, type RecurrentEvent, } from './GenericFunctions'; import { sortItemKeysByPriorityList } from '../../../utils/utilities'; @@ -241,16 +242,7 @@ export class GoogleCalendar implements INodeType { }, }; if (additionalFields.attendees) { - body.attendees = []; - (additionalFields.attendees as string[]).forEach((attendee) => { - body.attendees!.push.apply( - body.attendees, - attendee - .split(',') - .map((a) => a.trim()) - .map((email) => ({ email })), - ); - }); + body.attendees = parseAttendees.call(this, additionalFields.attendees, i); } if (additionalFields.color) { body.colorId = additionalFields.color as string; @@ -642,16 +634,7 @@ export class GoogleCalendar implements INodeType { } // nodeVersion < 1.2 if (updateFields.attendees) { - body.attendees = []; - (updateFields.attendees as string[]).forEach((attendee) => { - body.attendees!.push.apply( - body.attendees, - attendee - .split(',') - .map((a) => a.trim()) - .map((email) => ({ email })), - ); - }); + body.attendees = parseAttendees.call(this, updateFields.attendees, i); } // nodeVersion >= 1.2 if (updateFields.attendeesUi) { @@ -663,6 +646,7 @@ export class GoogleCalendar implements INodeType { }; } ).values; + const parsedAttendees = parseAttendees.call(this, attendees, i); body.attendees = []; if (mode === 'add') { const event = await googleApiRequest.call( @@ -674,15 +658,7 @@ export class GoogleCalendar implements INodeType { body.attendees?.push(attendee); }); } - attendees.forEach((attendee) => { - body.attendees!.push.apply( - body.attendees, - attendee - .split(',') - .map((a) => a.trim()) - .map((email) => ({ email })), - ); - }); + body.attendees.push(...parsedAttendees); } if (updateFields.color) { body.colorId = updateFields.color as string; diff --git a/packages/nodes-base/nodes/Google/Calendar/test/node/event.create.test.ts b/packages/nodes-base/nodes/Google/Calendar/test/node/event.create.test.ts new file mode 100644 index 00000000000..c3a6f6ffc5a --- /dev/null +++ b/packages/nodes-base/nodes/Google/Calendar/test/node/event.create.test.ts @@ -0,0 +1,175 @@ +import type { IDataObject, IExecuteFunctions, INode } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; +import { mock, mockDeep } from 'vitest-mock-extended'; + +import * as genericFunctions from '../../GenericFunctions'; +import { GoogleCalendar } from '../../GoogleCalendar.node'; + +describe('Google Calendar > Event > Create', () => { + const googleCalendar = new GoogleCalendar(); + const createdEvent = { id: 'test-event' }; + let executeFunctions: ReturnType>; + + beforeEach(() => { + vi.spyOn(genericFunctions, 'googleApiRequest').mockResolvedValue(createdEvent); + executeFunctions = mockDeep(); + executeFunctions.getInputData.mockReturnValue([{ json: {} }]); + executeFunctions.getNode.mockReturnValue( + mock({ + name: 'Google Calendar', + type: 'n8n-nodes-base.googleCalendar', + typeVersion: 1.3, + }), + ); + executeFunctions.getTimezone.mockReturnValue('UTC'); + executeFunctions.continueOnFail.mockReturnValue(false); + executeFunctions.helpers.returnJsonArray.mockReturnValue([{ json: createdEvent }]); + executeFunctions.helpers.constructExecutionMetaData.mockImplementation((data, { itemData }) => + data.map((item) => ({ ...item, pairedItem: itemData })), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const setupCreate = (additionalFields: IDataObject) => { + executeFunctions.getNodeParameter + .mockReturnValueOnce('event') + .mockReturnValueOnce('create') + .mockReturnValueOnce('primary') + .mockReturnValueOnce('2026-07-18T09:00:00Z') + .mockReturnValueOnce('2026-07-18T10:00:00Z') + .mockReturnValueOnce(true) + .mockReturnValueOnce(additionalFields); + }; + + it('should create an event with email strings and comma-separated attendees', async () => { + setupCreate({ + attendees: ['alice@example.com, bob@example.com', 'carol@example.com'], + }); + + await expect(googleCalendar.execute.call(executeFunctions)).resolves.toEqual([ + [{ json: createdEvent, pairedItem: { item: 0 } }], + ]); + expect(genericFunctions.googleApiRequest).toHaveBeenCalledWith( + 'POST', + '/calendar/v3/calendars/primary/events', + expect.objectContaining({ + attendees: [ + { email: 'alice@example.com' }, + { email: 'bob@example.com' }, + { email: 'carol@example.com' }, + ], + }), + {}, + ); + }); + + it('should create an event with an empty attendee list', async () => { + setupCreate({ attendees: [] }); + + await googleCalendar.execute.call(executeFunctions); + + expect(genericFunctions.googleApiRequest).toHaveBeenCalledWith( + 'POST', + '/calendar/v3/calendars/primary/events', + expect.objectContaining({ attendees: [] }), + {}, + ); + }); + + it.each([ + { attendees: [''] }, + { attendees: [' \t\n '] }, + { attendees: ['alice@example.com,'] }, + { attendees: [',alice@example.com'] }, + { attendees: ['alice@example.com,,bob@example.com'] }, + { attendees: ['alice@example.com, \t ,bob@example.com'] }, + { attendees: ['alice@example.com', ''] }, + ])('should report a node error for blank attendee entries: $attendees', async ({ attendees }) => { + setupCreate({ attendees }); + + const execution = googleCalendar.execute.call(executeFunctions); + + await expect(execution).rejects.toBeInstanceOf(NodeOperationError); + await expect(execution).rejects.toMatchObject({ + message: 'Invalid attendee value', + context: { itemIndex: 0 }, + }); + expect(genericFunctions.googleApiRequest).not.toHaveBeenCalled(); + }); + + it('should report a node error when an attendee expression returns an object', async () => { + // NODE-5494: An attendee expression can return an object instead of an email string. + setupCreate({ attendees: [{ email: 'alice@example.com' }] }); + + const execution = googleCalendar.execute.call(executeFunctions); + + await expect(execution).rejects.toBeInstanceOf(NodeOperationError); + await expect(execution).rejects.toMatchObject({ + message: expect.stringMatching(/attendee/i), + context: { itemIndex: 0 }, + }); + expect(genericFunctions.googleApiRequest).not.toHaveBeenCalled(); + }); + + it.each([null, 42, false, ['alice@example.com']])( + 'should report a node error for a non-string attendee: %j', + async (attendee) => { + setupCreate({ attendees: [attendee] }); + + const execution = googleCalendar.execute.call(executeFunctions); + + await expect(execution).rejects.toBeInstanceOf(NodeOperationError); + await expect(execution).rejects.toMatchObject({ context: { itemIndex: 0 } }); + expect(genericFunctions.googleApiRequest).not.toHaveBeenCalled(); + }, + ); + + it.each([{ email: 'alice@example.com' }, 'alice@example.com', 42, true])( + 'should report a node error when the attendee list is not an array: %j', + async (attendees) => { + setupCreate({ attendees }); + + await expect(googleCalendar.execute.call(executeFunctions)).rejects.toBeInstanceOf( + NodeOperationError, + ); + expect(genericFunctions.googleApiRequest).not.toHaveBeenCalled(); + }, + ); + + it('should identify the input item with an invalid attendee', async () => { + executeFunctions.getInputData.mockReturnValue([{ json: {} }, { json: {} }]); + setupCreate({ attendees: ['alice@example.com'] }); + executeFunctions.getNodeParameter + .mockReturnValueOnce('primary') + .mockReturnValueOnce('2026-07-18T09:00:00Z') + .mockReturnValueOnce('2026-07-18T10:00:00Z') + .mockReturnValueOnce(true) + .mockReturnValueOnce({ attendees: [{ email: 'bob@example.com' }] }); + + await expect(googleCalendar.execute.call(executeFunctions)).rejects.toMatchObject({ + context: { itemIndex: 1 }, + }); + expect(genericFunctions.googleApiRequest).toHaveBeenCalledTimes(1); + }); + + it('should return the attendee error when continue on fail is enabled', async () => { + setupCreate({ attendees: [{ email: 'alice@example.com' }] }); + executeFunctions.continueOnFail.mockReturnValue(true); + executeFunctions.helpers.returnJsonArray.mockImplementation((data) => [ + { json: data as IDataObject }, + ]); + + await expect(googleCalendar.execute.call(executeFunctions)).resolves.toEqual([ + [ + { + json: { error: 'Invalid attendee value' }, + pairedItem: { item: 0 }, + }, + ], + ]); + expect(genericFunctions.googleApiRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Calendar/test/node/event.update.test.ts b/packages/nodes-base/nodes/Google/Calendar/test/node/event.update.test.ts index 6c309c13330..26cf34efcaf 100644 --- a/packages/nodes-base/nodes/Google/Calendar/test/node/event.update.test.ts +++ b/packages/nodes-base/nodes/Google/Calendar/test/node/event.update.test.ts @@ -1,6 +1,7 @@ import type { MockProxy } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended'; import type { IDataObject, INode, IExecuteFunctions } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; import * as genericFunctions from '../../GenericFunctions'; import { GoogleCalendar } from '../../GoogleCalendar.node'; @@ -62,7 +63,10 @@ describe('Google Calendar Node', () => { ); }); - it('should update replace attendees', async () => { + it.each([ + { attendees: ['email1@mail.com'], expectedAttendees: [{ email: 'email1@mail.com' }] }, + { attendees: [], expectedAttendees: [] }, + ])('should update replace attendees: $attendees', async ({ attendees, expectedAttendees }) => { mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]); mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('event'); mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('update'); @@ -75,7 +79,7 @@ describe('Google Calendar Node', () => { attendeesUi: { values: { mode: 'replace', - attendees: ['email1@mail.com'], + attendees, }, }, }); @@ -85,7 +89,7 @@ describe('Google Calendar Node', () => { expect(genericFunctions.googleApiRequest).toHaveBeenCalledWith( 'PATCH', '/calendar/v3/calendars/undefined/events/myEvent', - { attendees: [{ email: 'email1@mail.com' }] }, + { attendees: expectedAttendees }, {}, ); }); @@ -127,6 +131,44 @@ describe('Google Calendar Node', () => { ); }); + describe.each([ + { version: 1.1, mode: 'legacy' }, + { version: 1.2, mode: 'replace' }, + { version: 1.3, mode: 'add' }, + ])('attendee validation in version $version ($mode)', ({ version, mode }) => { + it.each([ + { attendees: [{ email: 'alice@example.com' }] }, + { attendees: [''] }, + { attendees: [' \t '] }, + { attendees: ['alice@example.com,'] }, + ])('should report a node error for invalid attendees: $attendees', async ({ attendees }) => { + const updateFields = + mode === 'legacy' ? { attendees } : { attendeesUi: { values: { mode, attendees } } }; + mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]); + mockExecuteFunctions.getNode.mockReturnValue(mock({ typeVersion: version })); + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce('event') + .mockReturnValueOnce('update') + .mockReturnValueOnce('myCalendar') + .mockReturnValueOnce('myEvent'); + if (version >= 1.3) { + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('instance'); + } + mockExecuteFunctions.getNodeParameter + .mockReturnValueOnce(true) + .mockReturnValueOnce(updateFields); + + const execution = googleCalendar.execute.call(mockExecuteFunctions); + + await expect(execution).rejects.toBeInstanceOf(NodeOperationError); + await expect(execution).rejects.toMatchObject({ + message: expect.stringMatching(/attendee/i), + context: { itemIndex: 0 }, + }); + expect(genericFunctions.googleApiRequest).not.toHaveBeenCalled(); + }); + }); + describe('all-day <-> timed conversion', () => { const setupUpdate = (updateFields: IDataObject) => { mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]);