fix: Validate attendee values (#40953)

Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com>
This commit is contained in:
n8n-cat-bot[bot] 2026-10-16 14:56:23 +00:00 committed by GitHub
parent 532e8ef930
commit 21221adc78
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 250 additions and 32 deletions

View File

@ -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,

View File

@ -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;

View File

@ -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<typeof mockDeep<IExecuteFunctions>>;
beforeEach(() => {
vi.spyOn(genericFunctions, 'googleApiRequest').mockResolvedValue(createdEvent);
executeFunctions = mockDeep<IExecuteFunctions>();
executeFunctions.getInputData.mockReturnValue([{ json: {} }]);
executeFunctions.getNode.mockReturnValue(
mock<INode>({
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();
});
});

View File

@ -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<INode>({ 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: {} }]);