n8n/packages/testing/playwright/tests/e2e/nodes/send-and-wait.spec.ts
Elias Meire 5b6ee17c81
feat(core): Add signature validation for waiting webhooks and forms (#24159)
Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com>
2026-03-23 11:48:52 +00:00

271 lines
9.3 KiB
TypeScript

import type { Page } from '@playwright/test';
import type { ProxyServer } from 'n8n-containers/services/proxy';
import type { IWorkflowBase } from 'n8n-workflow';
import { test as base, expect } from '../../../fixtures/base';
import type { CredentialResponse } from '../../../services/credential-api-helper';
interface SlackBlock {
type: string;
elements?: Array<{ type: string; url?: string }>;
}
type SendAndWaitFixtures = {
slackCredential: CredentialResponse;
slackProxySetup: undefined;
};
const test = base.extend<SendAndWaitFixtures>({
slackProxySetup: [
async ({ services }, use) => {
await services.proxy.clearAllExpectations();
await services.proxy.createExpectation({
httpRequest: { method: 'POST', path: '/api/chat.postMessage' },
httpResponse: {
statusCode: 200,
headers: { 'Content-Type': ['application/json'] },
body: JSON.stringify({
ok: true,
channel: 'C12345678',
ts: '1234567890.123456',
message: { text: 'Approval Request' },
}),
},
times: { unlimited: true },
});
await use(undefined);
},
{ auto: true },
],
slackCredential: async ({ n8n }, use) => {
const credential = await n8n.api.credentials.createCredential({
name: `Slack Test ${crypto.randomUUID().slice(0, 8)}`,
type: 'slackApi',
data: { accessToken: 'xoxb-fake-token-for-testing' },
});
await use(credential);
await n8n.api.credentials.deleteCredential(credential.id);
},
});
const NOT_A_BOT_USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0.0.0';
function getBlocksFromBody(body: { blocks?: SlackBlock[] } | undefined): SlackBlock[] | null {
return body?.blocks ?? null;
}
function extractApprovalUrls(blocks: SlackBlock[]): { approveUrl?: string; rejectUrl?: string } {
const buttons = blocks
.filter((b) => b.type === 'actions' && b.elements)
.flatMap((b) => b.elements?.filter((el) => el.type === 'button' && el.url) ?? []);
return {
approveUrl: buttons.find((btn) => btn.url?.includes('approved=true'))?.url,
rejectUrl: buttons.find((btn) => btn.url?.includes('approved=false'))?.url,
};
}
function extractFormUrl(blocks: SlackBlock[]): string | undefined {
const buttons = blocks
.filter((b) => b.type === 'actions' && b.elements)
.flatMap((b) => b.elements?.filter((el) => el.type === 'button' && el.url) ?? []);
// For custom forms, there's a single button with the form URL (contains signature)
return buttons.find((btn) => btn.url?.includes('signature='))?.url;
}
async function waitForSlackRequest(proxyServer: ProxyServer) {
await expect(async () => {
const requests = await proxyServer.getAllRequestsMade();
const slackRequest = requests.find(
(r) => r.httpRequest?.path === '/api/chat.postMessage' && r.httpRequest?.method === 'POST',
);
expect(slackRequest).toBeDefined();
}).toPass();
}
async function getApprovalUrlsFromSlack(proxyServer: ProxyServer) {
const requests = await proxyServer.getAllRequestsMade();
const slackRequest = requests.find(
(r) => r.httpRequest?.path === '/api/chat.postMessage' && r.httpRequest?.method === 'POST',
);
const blocks = getBlocksFromBody(slackRequest?.httpRequest?.body as { blocks?: SlackBlock[] });
return extractApprovalUrls(blocks!);
}
async function getFormUrlFromSlack(proxyServer: ProxyServer) {
const requests = await proxyServer.getAllRequestsMade();
const slackRequest = requests.find(
(r) => r.httpRequest?.path === '/api/chat.postMessage' && r.httpRequest?.method === 'POST',
);
const blocks = getBlocksFromBody(slackRequest?.httpRequest?.body as { blocks?: SlackBlock[] });
return extractFormUrl(blocks!);
}
async function clickApprovalLink(page: Page, url: string) {
console.log('Clicking approval URL:', url);
const response = await page.request.get(url, {
headers: { 'User-Agent': NOT_A_BOT_USER_AGENT },
});
console.log('Response status:', response.status());
const body = await response.text();
console.log('Response body preview:', body.substring(0, 500));
expect(response.ok()).toBe(true);
}
function withSlackCredential(credential: CredentialResponse) {
return (workflow: Partial<IWorkflowBase>) => {
workflow.nodes?.forEach((node) => {
if (node.type === 'n8n-nodes-base.slack') {
node.credentials = { slackApi: { id: credential.id, name: credential.name } };
}
});
return workflow;
};
}
test.use({ capability: 'proxy' });
test.describe('Send and Wait @capability:proxy', () => {
test('should complete approval flow when clicking approve URL', async ({
n8n,
services,
slackCredential,
}) => {
const { workflowId } = await n8n.api.workflows.importWorkflowFromFile(
'send-and-wait-approval.json',
{ transform: withSlackCredential(slackCredential) },
);
await n8n.navigate.toWorkflow(workflowId);
await n8n.canvas.clickExecuteWorkflowButton('Manual Trigger');
await waitForSlackRequest(services.proxy);
await n8n.api.workflows.waitForWorkflowStatus(workflowId, 'waiting', 10000);
const { approveUrl } = await getApprovalUrlsFromSlack(services.proxy);
expect(approveUrl).toContain('signature=');
await clickApprovalLink(n8n.page, approveUrl!);
await expect(n8n.canvas.getNodeSuccessStatusIndicator('Capture Result')).toBeVisible();
});
test('should complete rejection flow when clicking reject URL', async ({
n8n,
services,
slackCredential,
}) => {
const { workflowId } = await n8n.api.workflows.importWorkflowFromFile(
'send-and-wait-approval.json',
{ transform: withSlackCredential(slackCredential) },
);
await n8n.navigate.toWorkflow(workflowId);
await n8n.canvas.clickExecuteWorkflowButton('Manual Trigger');
await waitForSlackRequest(services.proxy);
await n8n.api.workflows.waitForWorkflowStatus(workflowId, 'waiting', 10000);
const { rejectUrl } = await getApprovalUrlsFromSlack(services.proxy);
expect(rejectUrl).toContain('signature=');
await clickApprovalLink(n8n.page, rejectUrl!);
await expect(n8n.canvas.getNodeSuccessStatusIndicator('Capture Result')).toBeVisible();
});
test('should reject requests with invalid signatures', async ({
n8n,
services,
slackCredential,
}) => {
const { workflowId } = await n8n.api.workflows.importWorkflowFromFile(
'send-and-wait-approval.json',
{ transform: withSlackCredential(slackCredential) },
);
await n8n.navigate.toWorkflow(workflowId);
await n8n.canvas.clickExecuteWorkflowButton('Manual Trigger');
await waitForSlackRequest(services.proxy);
const execution = await n8n.api.workflows.waitForWorkflowStatus(workflowId, 'waiting', 10000);
const unsignedResponse = await n8n.api.webhooks.trigger(
`/webhook-waiting/${execution.id}/slack-send-wait`,
);
expect(unsignedResponse.status()).toBe(401);
const tamperedResponse = await n8n.api.webhooks.trigger(
`/webhook-waiting/${execution.id}/slack-send-wait?approved=true&signature=tampered`,
);
expect(tamperedResponse.status()).toBe(401);
});
test('should complete form submission flow', async ({ n8n, services, slackCredential }) => {
const { workflowId } = await n8n.api.workflows.importWorkflowFromFile(
'send-and-wait-form.json',
{ transform: withSlackCredential(slackCredential) },
);
await n8n.navigate.toWorkflow(workflowId);
await n8n.canvas.clickExecuteWorkflowButton();
await waitForSlackRequest(services.proxy);
await n8n.api.workflows.waitForWorkflowStatus(workflowId, 'waiting', 10000);
const formUrl = await getFormUrlFromSlack(services.proxy);
expect(formUrl).toContain('signature=');
const formPage = await n8n.page.context().newPage();
await formPage.goto(formUrl!);
await expect(formPage.getByText('Test Form')).toBeVisible({ timeout: 10000 });
await expect(formPage.getByText('Please provide your information')).toBeVisible();
await formPage.getByLabel('Name').fill('John Doe');
await formPage.getByLabel('Email').fill('john@example.com');
await formPage.getByLabel('Comments').fill('This is a test comment');
await formPage.getByRole('button', { name: 'Submit Form' }).click();
await expect(formPage.getByText('Got it, thanks')).toBeVisible({ timeout: 10000 });
await formPage.close();
await expect(n8n.canvas.getNodeSuccessStatusIndicator('Capture Result')).toBeVisible();
await n8n.canvas.openNode('Capture Result');
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 0)).toHaveText('John Doe');
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 1)).toHaveText('john@example.com');
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 2)).toHaveText('This is a test comment');
});
test('should complete approval flow in production mode (activated workflow)', async ({
n8n,
services,
slackCredential,
}) => {
const { workflowId, webhookPath } = await n8n.api.workflows.importWorkflowFromFile(
'send-and-wait-approval.json',
{ transform: withSlackCredential(slackCredential) },
);
await n8n.navigate.toWorkflow(workflowId);
await n8n.canvas.publishWorkflow();
const triggerResponse = await n8n.api.webhooks.trigger(`/webhook/${webhookPath}`);
expect(triggerResponse.ok()).toBe(true);
await waitForSlackRequest(services.proxy);
await n8n.api.workflows.waitForWorkflowStatus(workflowId, 'waiting', 10000);
const { approveUrl } = await getApprovalUrlsFromSlack(services.proxy);
expect(approveUrl).toContain('signature=');
await clickApprovalLink(n8n.page, approveUrl!);
const execution = await n8n.api.workflows.waitForWorkflowStatus(workflowId, 'success', 10000);
expect(execution.status).toBe('success');
});
});