mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
532e8ef930
commit
21221adc78
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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: {} }]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user