fix: Set Content-Type for Meta-family trigger node responses (#31354)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sandra Zollner 2026-05-29 16:49:12 +02:00 committed by GitHub
parent 068547b500
commit 16728b301c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 135 additions and 15 deletions

View File

@ -12,7 +12,6 @@ import type {
JsonObject,
} from 'n8n-workflow';
import { NodeApiError, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { facebookApiRequest, getAllFields, getFields } from './GenericFunctions';
import type { FacebookWebhookSubscription } from './types';
@ -241,7 +240,6 @@ export class FacebookTrigger implements INodeType {
return true;
},
async create(this: IHookFunctions): Promise<boolean> {
const webhookData = this.getWorkflowStaticData('node');
const webhookUrl = this.getNodeWebhookUrl('default') as string;
const object = this.getNodeParameter('object') as string;
const appId = this.getNodeParameter('appId') as string;
@ -251,7 +249,7 @@ export class FacebookTrigger implements INodeType {
const body = {
object: snakeCase(object),
callback_url: webhookUrl,
verify_token: uuid(),
verify_token: this.getNode().id,
fields: fields.includes('*') ? getAllFields(object) : fields,
} as IDataObject;
@ -266,8 +264,6 @@ export class FacebookTrigger implements INodeType {
body,
);
webhookData.verifyToken = body.verify_token;
if (responseData.success !== true) {
// Facebook did not return success, so something went wrong
throw new NodeApiError(this.getNode(), responseData as JsonObject, {
@ -305,13 +301,11 @@ export class FacebookTrigger implements INodeType {
// Check if we're getting facebook's challenge request (https://developers.facebook.com/docs/graph-api/webhooks/getting-started)
if (this.getWebhookName() === 'setup') {
if (query['hub.challenge']) {
//TODO
//compare hub.verify_token with the saved token
//const webhookData = this.getWorkflowStaticData('node');
// if (webhookData.verifyToken !== query['hub.verify_token']) {
// return {};
// }
res.status(200).send(query['hub.challenge']).end();
if (this.getNode().id !== query['hub.verify_token']) {
return {};
}
res.status(200).type('text/plain').send(query['hub.challenge']).end();
return {
noWebhookResponse: true,
};

View File

@ -0,0 +1,74 @@
import type { Response } from 'express';
import { mock } from 'jest-mock-extended';
import type { IDataObject, IWebhookFunctions } from 'n8n-workflow';
import { FacebookTrigger } from '../FacebookTrigger.node';
describe('FacebookTrigger', () => {
let node: FacebookTrigger;
let mockWebhookFunctions: jest.Mocked<IWebhookFunctions>;
beforeEach(() => {
node = new FacebookTrigger();
mockWebhookFunctions = mock<IWebhookFunctions>();
jest.clearAllMocks();
});
describe('webhook', () => {
const createMockResponse = () =>
({
status: jest.fn().mockReturnThis(),
type: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
end: jest.fn(),
}) as unknown as Response;
it('should respond to verification challenge as text/plain when the verify token matches', async () => {
const mockResponse = createMockResponse();
mockWebhookFunctions.getNode.mockReturnValue({ id: 'test-token' } as any);
mockWebhookFunctions.getWebhookName.mockReturnValue('setup');
mockWebhookFunctions.getQueryData.mockReturnValue({
'hub.challenge': 'test-challenge',
'hub.verify_token': 'test-token',
} as IDataObject);
mockWebhookFunctions.getBodyData.mockReturnValue({});
mockWebhookFunctions.getHeaderData.mockReturnValue({});
mockWebhookFunctions.getRequestObject.mockReturnValue({ rawBody: Buffer.from('') } as any);
mockWebhookFunctions.getResponseObject.mockReturnValue(mockResponse);
mockWebhookFunctions.getNodeParameter.mockReturnValue('accessToken');
mockWebhookFunctions.getCredentials.mockResolvedValue({ appSecret: '' });
const result = await node.webhook.call(mockWebhookFunctions);
expect(mockResponse.status).toHaveBeenCalledWith(200);
expect(mockResponse.type).toHaveBeenCalledWith('text/plain');
expect(mockResponse.send).toHaveBeenCalledWith('test-challenge');
expect(mockResponse.end).toHaveBeenCalled();
expect(result).toEqual({ noWebhookResponse: true });
});
it('should reject the verification challenge when the verify token does not match', async () => {
const mockResponse = createMockResponse();
mockWebhookFunctions.getNode.mockReturnValue({ id: 'expected-token' } as any);
mockWebhookFunctions.getWebhookName.mockReturnValue('setup');
mockWebhookFunctions.getQueryData.mockReturnValue({
'hub.challenge': 'test-challenge',
'hub.verify_token': 'attacker-supplied-token',
} as IDataObject);
mockWebhookFunctions.getBodyData.mockReturnValue({});
mockWebhookFunctions.getHeaderData.mockReturnValue({});
mockWebhookFunctions.getRequestObject.mockReturnValue({ rawBody: Buffer.from('') } as any);
mockWebhookFunctions.getResponseObject.mockReturnValue(mockResponse);
mockWebhookFunctions.getNodeParameter.mockReturnValue('accessToken');
mockWebhookFunctions.getCredentials.mockResolvedValue({ appSecret: '' });
const result = await node.webhook.call(mockWebhookFunctions);
expect(mockResponse.send).not.toHaveBeenCalled();
expect(mockResponse.status).not.toHaveBeenCalled();
expect(result).toEqual({});
});
});
});

View File

@ -224,7 +224,7 @@ export class FacebookLeadAdsTrigger implements INodeType {
return {};
}
res.status(200).send(query['hub.challenge']).end();
res.status(200).type('text/plain').send(query['hub.challenge']).end();
return { noWebhookResponse: true };
}

View File

@ -0,0 +1,48 @@
import type { Response } from 'express';
import { mock } from 'jest-mock-extended';
import type { IDataObject, INode, IWebhookFunctions } from 'n8n-workflow';
import { FacebookLeadAdsTrigger } from '../FacebookLeadAdsTrigger.node';
describe('FacebookLeadAdsTrigger', () => {
let node: FacebookLeadAdsTrigger;
let mockWebhookFunctions: jest.Mocked<IWebhookFunctions>;
beforeEach(() => {
node = new FacebookLeadAdsTrigger();
mockWebhookFunctions = mock<IWebhookFunctions>();
jest.clearAllMocks();
});
describe('webhook', () => {
it('should respond to verification challenge as text/plain', async () => {
const mockResponse = {
status: jest.fn().mockReturnThis(),
type: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
end: jest.fn(),
} as unknown as Response;
mockWebhookFunctions.getWebhookName.mockReturnValue('setup');
mockWebhookFunctions.getQueryData.mockReturnValue({
'hub.challenge': 'test-challenge',
'hub.verify_token': 'test-node-id',
} as IDataObject);
mockWebhookFunctions.getBodyData.mockReturnValue({});
mockWebhookFunctions.getHeaderData.mockReturnValue({});
mockWebhookFunctions.getRequestObject.mockReturnValue({ rawBody: Buffer.from('') } as any);
mockWebhookFunctions.getResponseObject.mockReturnValue(mockResponse);
mockWebhookFunctions.getCredentials.mockResolvedValue({ clientSecret: 'secret' });
mockWebhookFunctions.getNodeParameter.mockReturnValue('');
mockWebhookFunctions.getNode.mockReturnValue(mock<INode>({ id: 'test-node-id' }));
const result = await node.webhook.call(mockWebhookFunctions);
expect(mockResponse.status).toHaveBeenCalledWith(200);
expect(mockResponse.type).toHaveBeenCalledWith('text/plain');
expect(mockResponse.send).toHaveBeenCalledWith('test-challenge');
expect(mockResponse.end).toHaveBeenCalled();
expect(result).toEqual({ noWebhookResponse: true });
});
});
});

View File

@ -398,7 +398,7 @@ export class MicrosoftTeamsTrigger implements INodeType {
// Handle Microsoft Graph validation request
if (req.query.validationToken) {
res.status(200).send(req.query.validationToken);
res.status(200).type('text/plain').send(req.query.validationToken);
return { noWebhookResponse: true };
}

View File

@ -217,6 +217,7 @@ describe('Microsoft Teams Trigger Node', () => {
};
const mockResponse = {
status: jest.fn().mockReturnThis(),
type: jest.fn().mockReturnThis(),
send: jest.fn(),
};
@ -225,6 +226,7 @@ describe('Microsoft Teams Trigger Node', () => {
const result = await new MicrosoftTeamsTrigger().webhook.call(mockWebhookFunctions);
expect(mockResponse.status).toHaveBeenCalledWith(200);
expect(mockResponse.type).toHaveBeenCalledWith('text/plain');
expect(mockResponse.send).toHaveBeenCalledWith('validation-token');
expect(result.noWebhookResponse).toBe(true);
});

View File

@ -268,7 +268,7 @@ export class WhatsAppTrigger implements INodeType {
return {};
}
res.status(200).send(query['hub.challenge']).end();
res.status(200).type('text/plain').send(query['hub.challenge']).end();
return { noWebhookResponse: true };
}

View File

@ -229,6 +229,7 @@ describe('WhatsAppTrigger', () => {
};
const mockResponse = {
status: jest.fn().mockReturnThis(),
type: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
end: jest.fn(),
} as unknown as express.Response;
@ -245,6 +246,7 @@ describe('WhatsAppTrigger', () => {
expect(result).toEqual({ noWebhookResponse: true });
expect(mockResponse.status).toHaveBeenCalledWith(200);
expect(mockResponse.type).toHaveBeenCalledWith('text/plain');
expect(mockResponse.send).toHaveBeenCalledWith('test-challenge');
expect(mockResponse.end).toHaveBeenCalled();
});