mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-29 12:05:04 +02:00
fix: Form trigger and Wait Form mode basic authentication fix for form POST request (#23795)
Co-authored-by: Dimitri Lavrenük <20122620+dlavrenuek@users.noreply.github.com> Co-authored-by: Dimitri Lavrenük <dimitri.lavrenuek@n8n.io> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: yehorkardash <yehor.kardash@n8n.io>
This commit is contained in:
parent
2d0a8dd4b9
commit
60615eb43a
|
|
@ -98,6 +98,13 @@ describe('WaitingWebhooks', () => {
|
|||
await expect(promise).rejects.toThrowError(ConflictError);
|
||||
});
|
||||
|
||||
describe('findAccessControlOptions', () => {
|
||||
it('should return * as allowed origins', async () => {
|
||||
const options = await waitingWebhooks.findAccessControlOptions();
|
||||
expect(options).toEqual({ allowedOrigins: '*' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSignatureInRequest', () => {
|
||||
const EXAMPLE_HOST = 'example.com';
|
||||
const generateValidSignature = (host = EXAMPLE_HOST) =>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,14 @@ export class WaitingWebhooks implements IWebhookManager {
|
|||
|
||||
// TODO: implement `getWebhookMethods` for CORS support
|
||||
|
||||
async findAccessControlOptions() {
|
||||
// waiting webhooks do not support cors configuration options
|
||||
// allow all origins because waiting webhook forms are always submitted in cors mode due to sandbox CSP
|
||||
return {
|
||||
allowedOrigins: '*',
|
||||
};
|
||||
}
|
||||
|
||||
protected logReceivedWebhook(method: string, executionId: string) {
|
||||
this.logger.debug(`Received waiting-webhook "${method}" for execution "${executionId}"`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export interface IWebhookManager {
|
|||
getWebhookMethods?: (path: string) => Promise<IHttpRequestMethods[]>;
|
||||
|
||||
/** Find the CORS options matching a path and method */
|
||||
findAccessControlOptions?: (
|
||||
findAccessControlOptions: (
|
||||
path: string,
|
||||
httpMethod: IHttpRequestMethods,
|
||||
) => Promise<WebhookAccessControlOptions | undefined>;
|
||||
|
|
|
|||
|
|
@ -993,9 +993,16 @@
|
|||
postUrl = window.location.search;
|
||||
}
|
||||
|
||||
const authToken = '{{{ authToken }}}';
|
||||
const headers = {};
|
||||
if(authToken) {
|
||||
headers['x-auth-token'] = authToken;
|
||||
}
|
||||
|
||||
fetch(postUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers,
|
||||
})
|
||||
.then(async function (response) {
|
||||
const useResponseData = document.getElementById("useResponseData").value;
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export type FormTriggerData = {
|
|||
appendAttribution?: boolean;
|
||||
buttonLabel?: string;
|
||||
dangerousCustomCss?: string;
|
||||
authToken?: string;
|
||||
};
|
||||
|
||||
export const FORM_TRIGGER_AUTHENTICATION_PROPERTY = 'authentication';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import crypto from 'crypto';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { NodeOperationError, type INode } from 'n8n-workflow';
|
||||
|
||||
|
|
@ -37,6 +38,7 @@ describe('FormTrigger', () => {
|
|||
formTitle: 'Test Form',
|
||||
formDescription: 'Test Description',
|
||||
responseMode: 'onReceived',
|
||||
authentication: 'none',
|
||||
formFields: { values: formFields },
|
||||
options: {
|
||||
appendAttribution: false,
|
||||
|
|
@ -145,6 +147,7 @@ describe('FormTrigger', () => {
|
|||
formTitle: 'Test Form',
|
||||
formDescription: 'Test Description',
|
||||
responseMode: 'onReceived',
|
||||
authentication: 'none',
|
||||
formFields: { values: formFields },
|
||||
},
|
||||
},
|
||||
|
|
@ -219,13 +222,13 @@ describe('FormTrigger', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should throw on invalid webhook authentication', async () => {
|
||||
it('webhook execution not successful when token is invalid', async () => {
|
||||
const formFields = [
|
||||
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
|
||||
{ fieldLabel: 'Age', fieldType: 'number', requiredField: false },
|
||||
];
|
||||
|
||||
const { responseData, response } = await testVersionedWebhookTriggerNode(FormTrigger, 2, {
|
||||
const { responseData } = await testVersionedWebhookTriggerNode(FormTrigger, 2, {
|
||||
mode: 'manual',
|
||||
node: {
|
||||
parameters: {
|
||||
|
|
@ -236,15 +239,75 @@ describe('FormTrigger', () => {
|
|||
authentication: 'basicAuth',
|
||||
},
|
||||
},
|
||||
request: { method: 'POST' },
|
||||
request: { method: 'POST', query: {}, headers: {} },
|
||||
credential: {
|
||||
user: 'testuser',
|
||||
password: 'testpass',
|
||||
},
|
||||
});
|
||||
|
||||
expect(responseData).toEqual({ noWebhookResponse: true });
|
||||
expect(response.status).toHaveBeenCalledWith(401);
|
||||
expect(response.setHeader).toHaveBeenCalledWith(
|
||||
'WWW-Authenticate',
|
||||
'Basic realm="Enter credentials"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate POST requests with correct authentication token', async () => {
|
||||
const formFields = [
|
||||
{ fieldLabel: 'Name', fieldType: 'text', requiredField: true },
|
||||
{ fieldLabel: 'Age', fieldType: 'number', requiredField: false },
|
||||
];
|
||||
|
||||
const nodeId = 'test-node-id';
|
||||
const webhookId = 'test-webhook-id';
|
||||
const credentials = { user: 'testuser', password: 'testpass' };
|
||||
|
||||
const token = crypto
|
||||
.createHmac('sha256', `${credentials.user}:${credentials.password}`)
|
||||
.update(`${nodeId}-${webhookId}`)
|
||||
.digest('hex');
|
||||
|
||||
const bodyData = {
|
||||
data: {
|
||||
'field-0': 'John Doe',
|
||||
'field-1': '30',
|
||||
},
|
||||
};
|
||||
|
||||
const { responseData } = await testVersionedWebhookTriggerNode(FormTrigger, 2, {
|
||||
mode: 'manual',
|
||||
node: {
|
||||
id: nodeId,
|
||||
webhookId,
|
||||
parameters: {
|
||||
formTitle: 'Test Form',
|
||||
formDescription: 'Test Description',
|
||||
responseMode: 'onReceived',
|
||||
formFields: { values: formFields },
|
||||
authentication: 'basicAuth',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
method: 'POST',
|
||||
contentType: 'multipart/form-data',
|
||||
headers: { 'content-type': 'multipart/form-data', 'x-auth-token': token },
|
||||
},
|
||||
bodyData,
|
||||
credential: credentials,
|
||||
});
|
||||
|
||||
expect(responseData).toEqual({
|
||||
webhookResponse: { status: 200 },
|
||||
workflowData: [
|
||||
[
|
||||
{
|
||||
json: {
|
||||
Name: 'John Doe',
|
||||
Age: 30,
|
||||
submittedAt: expect.any(String),
|
||||
formMode: 'test',
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply customCss property to form render', async () => {
|
||||
|
|
@ -258,6 +321,7 @@ describe('FormTrigger', () => {
|
|||
formTitle: 'Custom CSS Test',
|
||||
formDescription: 'Testing custom CSS',
|
||||
responseMode: 'onReceived',
|
||||
authentication: 'none',
|
||||
formFields: { values: formFields },
|
||||
options: {
|
||||
customCss: '.form-input { border-color: red; }',
|
||||
|
|
@ -318,6 +382,7 @@ describe('FormTrigger', () => {
|
|||
formTitle: 'Test Form',
|
||||
formDescription: 'Test Description',
|
||||
responseMode: 'onReceived',
|
||||
authentication: 'none',
|
||||
formFields: { values: formFields },
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -354,6 +354,7 @@ describe('FormTrigger, formWebhook', () => {
|
|||
.calledWith('formDescription')
|
||||
.mockReturnValue('Test Description');
|
||||
executeFunctions.getNodeParameter.calledWith('responseMode').mockReturnValue('onReceived');
|
||||
executeFunctions.getNodeParameter.calledWith('authentication').mockReturnValue('none');
|
||||
executeFunctions.getRequestObject.mockReturnValue({ method: 'GET', query: {} } as any);
|
||||
executeFunctions.getMode.mockReturnValue('manual');
|
||||
executeFunctions.getInstanceId.mockReturnValue('instanceId');
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import sanitize from 'sanitize-html';
|
|||
|
||||
import { getResolvables } from '../../../utils/utilities';
|
||||
import { WebhookAuthorizationError } from '../../Webhook/error';
|
||||
import { validateWebhookAuthentication } from '../../Webhook/utils';
|
||||
import { generateFormPostBasicAuthToken, validateWebhookAuthentication } from '../../Webhook/utils';
|
||||
import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from '../interfaces';
|
||||
import type { FormTriggerData, FormField } from '../interfaces';
|
||||
|
||||
|
|
@ -185,6 +185,7 @@ export function prepareFormData({
|
|||
buttonLabel,
|
||||
customCss,
|
||||
nodeVersion,
|
||||
authToken,
|
||||
}: {
|
||||
formTitle: string;
|
||||
formDescription: string;
|
||||
|
|
@ -200,6 +201,7 @@ export function prepareFormData({
|
|||
formSubmittedHeader?: string;
|
||||
customCss?: string;
|
||||
nodeVersion?: number;
|
||||
authToken?: string;
|
||||
}) {
|
||||
const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : '';
|
||||
const n8nWebsiteLink = `https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger${utm_campaign}`;
|
||||
|
|
@ -221,6 +223,7 @@ export function prepareFormData({
|
|||
appendAttribution,
|
||||
buttonLabel,
|
||||
dangerousCustomCss: sanitizeCustomCss(customCss),
|
||||
authToken,
|
||||
};
|
||||
|
||||
if (redirectUrl) {
|
||||
|
|
@ -477,6 +480,7 @@ export function renderForm({
|
|||
appendAttribution,
|
||||
buttonLabel,
|
||||
customCss,
|
||||
authToken,
|
||||
}: {
|
||||
context: IWebhookFunctions;
|
||||
res: Response;
|
||||
|
|
@ -490,6 +494,7 @@ export function renderForm({
|
|||
appendAttribution?: boolean;
|
||||
buttonLabel?: string;
|
||||
customCss?: string;
|
||||
authToken?: string;
|
||||
}) {
|
||||
formDescription = (formDescription || '').replace(/\\n/g, '\n').replace(/<br>/g, '\n');
|
||||
const instanceId = context.getInstanceId();
|
||||
|
|
@ -532,6 +537,7 @@ export function renderForm({
|
|||
buttonLabel,
|
||||
customCss,
|
||||
nodeVersion: context.getNode().typeVersion,
|
||||
authToken,
|
||||
});
|
||||
|
||||
res.setHeader('Content-Security-Policy', getWebhookSandboxCSP());
|
||||
|
|
@ -633,6 +639,11 @@ export async function formWebhook(
|
|||
responseMode = 'responseNode';
|
||||
}
|
||||
|
||||
let authToken: string | undefined;
|
||||
if (node.typeVersion > 1) {
|
||||
authToken = await generateFormPostBasicAuthToken(context, authProperty);
|
||||
}
|
||||
|
||||
renderForm({
|
||||
context,
|
||||
res,
|
||||
|
|
@ -646,6 +657,7 @@ export async function formWebhook(
|
|||
appendAttribution,
|
||||
buttonLabel,
|
||||
customCss: options.customCss,
|
||||
authToken,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -5,12 +5,16 @@ import {
|
|||
type INodeExecutionData,
|
||||
type IDataObject,
|
||||
type MultiPartFormData,
|
||||
type INode,
|
||||
type ICredentialDataDecryptedObject,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { WebhookParameters } from '../utils';
|
||||
import {
|
||||
checkResponseModeConfiguration,
|
||||
configuredOutputs,
|
||||
generateBasicAuthToken,
|
||||
generateFormPostBasicAuthToken,
|
||||
getResponseCode,
|
||||
getResponseData,
|
||||
handleFormData,
|
||||
|
|
@ -18,6 +22,7 @@ import {
|
|||
setupOutputConnection,
|
||||
validateWebhookAuthentication,
|
||||
} from '../utils';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
jest.mock('jsonwebtoken', () => ({
|
||||
verify: jest.fn(),
|
||||
|
|
@ -350,6 +355,51 @@ describe('Webhook Utils', () => {
|
|||
).rejects.toThrowError('Authorization is required!');
|
||||
});
|
||||
|
||||
it('should successfully pass if basicAuth is enabled and provided basic auth data is correct', async () => {
|
||||
const headers = {
|
||||
authorization: `Basic ${Buffer.from('admin:password').toString('base64')}`,
|
||||
};
|
||||
const ctx: Partial<IWebhookFunctions> = {
|
||||
getNodeParameter: jest.fn().mockReturnValue('basicAuth'),
|
||||
getCredentials: jest.fn().mockResolvedValue({
|
||||
user: 'admin',
|
||||
password: 'password',
|
||||
}),
|
||||
getRequestObject: jest.fn().mockReturnValue({
|
||||
headers,
|
||||
}),
|
||||
getHeaderData: jest.fn().mockReturnValue(headers),
|
||||
};
|
||||
const authPropertyName = 'authentication';
|
||||
await validateWebhookAuthentication(ctx as IWebhookFunctions, authPropertyName);
|
||||
});
|
||||
|
||||
it('should successfully pass if basicAuth is enabled and provided auth token data is correct', async () => {
|
||||
const node = {
|
||||
id: 'node-789',
|
||||
webhookId: 'webhook-456',
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
} as INode;
|
||||
const credentials = {
|
||||
user: (Math.random() * 10000).toString(),
|
||||
password: (Math.random() * 10000).toString(),
|
||||
};
|
||||
const headers = {
|
||||
'x-auth-token': generateBasicAuthToken(node, credentials),
|
||||
};
|
||||
const ctx: Partial<IWebhookFunctions> = {
|
||||
getNode: jest.fn().mockReturnValue(node),
|
||||
getCredentials: jest.fn().mockResolvedValue(credentials),
|
||||
getNodeParameter: jest.fn().mockReturnValue('basicAuth'),
|
||||
getRequestObject: jest.fn().mockReturnValue({
|
||||
headers,
|
||||
}),
|
||||
getHeaderData: jest.fn().mockReturnValue(headers),
|
||||
};
|
||||
const authPropertyName = 'authentication';
|
||||
await validateWebhookAuthentication(ctx as IWebhookFunctions, authPropertyName);
|
||||
});
|
||||
|
||||
it('should throw an error if headerAuth is enabled but no authentication data is defined on the node', async () => {
|
||||
const ctx: Partial<IWebhookFunctions> = {
|
||||
getNodeParameter: jest.fn().mockReturnValue('headerAuth'),
|
||||
|
|
@ -627,3 +677,127 @@ describe('Webhook Utils', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auth token generation', () => {
|
||||
describe('generateFormPostBasicAuthToken', () => {
|
||||
let webhookFunctions: ReturnType<typeof mock<IWebhookFunctions>>;
|
||||
|
||||
beforeEach(() => {
|
||||
webhookFunctions = mock<IWebhookFunctions>();
|
||||
});
|
||||
|
||||
it('should use authentication property for Form Trigger nodes', async () => {
|
||||
webhookFunctions.getNode.mockReturnValue({
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
} as INode);
|
||||
webhookFunctions.getNodeParameter.mockReturnValue('basicAuth');
|
||||
|
||||
await generateFormPostBasicAuthToken(webhookFunctions, 'authentication');
|
||||
|
||||
expect(webhookFunctions.getNodeParameter).toHaveBeenCalledWith('authentication');
|
||||
});
|
||||
|
||||
it('should use passed authentication key', async () => {
|
||||
webhookFunctions.getNode.mockReturnValue({
|
||||
type: 'n8n-nodes-base.wait',
|
||||
} as INode);
|
||||
webhookFunctions.getNodeParameter.mockReturnValue('basicAuth');
|
||||
|
||||
await generateFormPostBasicAuthToken(webhookFunctions, 'incomingAuthentication');
|
||||
|
||||
expect(webhookFunctions.getNodeParameter).toHaveBeenCalledWith('incomingAuthentication');
|
||||
});
|
||||
|
||||
it('should handle "none" authentication', async () => {
|
||||
webhookFunctions.getNode.mockReturnValue({
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
} as INode);
|
||||
webhookFunctions.getNodeParameter.mockReturnValue('none');
|
||||
|
||||
const result = await generateFormPostBasicAuthToken(webhookFunctions, 'authentication');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateBasicAuthToken', () => {
|
||||
let testNode: INode;
|
||||
let randomCredentials: ICredentialDataDecryptedObject & {
|
||||
user: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
testNode = {
|
||||
id: new Date().getMilliseconds().toString(),
|
||||
webhookId: 'webhook-456',
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
} as INode;
|
||||
|
||||
randomCredentials = {
|
||||
user: (Math.random() * 100000).toString(),
|
||||
password: (Math.random() * 100000).toString(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should return undefined when credentials are empty', () => {
|
||||
const result = generateBasicAuthToken(testNode, undefined);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should generate valid HMAC token using credentials', () => {
|
||||
const result = generateBasicAuthToken(testNode, randomCredentials);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result?.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should generate deterministic token for same inputs', () => {
|
||||
const token1 = generateBasicAuthToken(testNode, randomCredentials);
|
||||
const token2 = generateBasicAuthToken(testNode, randomCredentials);
|
||||
|
||||
expect(token1).toBe(token2);
|
||||
});
|
||||
|
||||
it('should generate different tokens for different credentials', () => {
|
||||
const token1 = generateBasicAuthToken(testNode, { user: 'user1', password: 'password' });
|
||||
const token2 = generateBasicAuthToken(testNode, { user: 'user2', password: 'password' });
|
||||
const token3 = generateBasicAuthToken(testNode, { user: 'user1', password: 'passwOrd' });
|
||||
|
||||
expect(token1).not.toBe(token2);
|
||||
expect(token1).not.toBe(token3);
|
||||
});
|
||||
|
||||
it('should generate different tokens for different node IDs', () => {
|
||||
const token1 = generateBasicAuthToken(
|
||||
{
|
||||
id: 'node-789',
|
||||
webhookId: 'webhook-456',
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
} as INode,
|
||||
randomCredentials,
|
||||
);
|
||||
const token2 = generateBasicAuthToken(
|
||||
{
|
||||
id: 'node-678',
|
||||
webhookId: 'webhook-456',
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
} as INode,
|
||||
randomCredentials,
|
||||
);
|
||||
const token3 = generateBasicAuthToken(
|
||||
{
|
||||
id: 'node-789',
|
||||
webhookId: 'webhook-459',
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
} as INode,
|
||||
randomCredentials,
|
||||
);
|
||||
|
||||
expect(token1).not.toBe(token2);
|
||||
expect(token1).not.toBe(token3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@ import type {
|
|||
IDataObject,
|
||||
ICredentialDataDecryptedObject,
|
||||
MultiPartFormData,
|
||||
INode,
|
||||
} from 'n8n-workflow';
|
||||
import * as a from 'node:assert';
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { BlockList } from 'node:net';
|
||||
|
||||
import { WebhookAuthorizationError } from './error';
|
||||
|
|
@ -220,9 +222,25 @@ export async function validateWebhookAuthentication(
|
|||
|
||||
const providedAuth = basicAuth(req);
|
||||
// Authorization data is missing
|
||||
if (!providedAuth) throw new WebhookAuthorizationError(401);
|
||||
if (!providedAuth) {
|
||||
const authToken = headers['x-auth-token'];
|
||||
if (!authToken) {
|
||||
throw new WebhookAuthorizationError(401);
|
||||
}
|
||||
|
||||
if (providedAuth.name !== expectedAuth.user || providedAuth.pass !== expectedAuth.password) {
|
||||
const expectedAuthToken = generateBasicAuthToken(ctx.getNode(), expectedAuth);
|
||||
if (
|
||||
!expectedAuthToken ||
|
||||
typeof authToken !== 'string' ||
|
||||
expectedAuthToken.length !== authToken.length ||
|
||||
!timingSafeEqual(Buffer.from(expectedAuthToken), Buffer.from(authToken))
|
||||
) {
|
||||
throw new WebhookAuthorizationError(403);
|
||||
}
|
||||
} else if (
|
||||
providedAuth.name !== expectedAuth.user ||
|
||||
providedAuth.pass !== expectedAuth.password
|
||||
) {
|
||||
// Provided authentication data is wrong
|
||||
throw new WebhookAuthorizationError(403);
|
||||
}
|
||||
|
|
@ -367,3 +385,36 @@ export async function handleFormData(
|
|||
|
||||
return { workflowData: prepareOutput(returnItem) };
|
||||
}
|
||||
|
||||
export async function generateFormPostBasicAuthToken(
|
||||
context: IWebhookFunctions,
|
||||
authPropertyName: string,
|
||||
) {
|
||||
const node = context.getNode();
|
||||
|
||||
const authentication = context.getNodeParameter(authPropertyName);
|
||||
if (authentication === 'none') return;
|
||||
|
||||
let credentials: ICredentialDataDecryptedObject | undefined;
|
||||
|
||||
try {
|
||||
credentials = await context.getCredentials<ICredentialDataDecryptedObject>('httpBasicAuth');
|
||||
} catch {}
|
||||
|
||||
return generateBasicAuthToken(node, credentials);
|
||||
}
|
||||
|
||||
export function generateBasicAuthToken(
|
||||
node: INode,
|
||||
credentials: ICredentialDataDecryptedObject | undefined,
|
||||
) {
|
||||
if (!credentials || !credentials.user || !credentials.password) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = createHmac('sha256', `${credentials.user}:${credentials.password}`)
|
||||
.update(`${node.id}-${node.webhookId}`)
|
||||
.digest('hex');
|
||||
|
||||
return token;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ export async function testWebhookTriggerNode(
|
|||
getMode: () => options.mode ?? 'trigger',
|
||||
getInstanceId: () => 'instanceId',
|
||||
getBodyData: () => options.bodyData ?? {},
|
||||
getHeaderData: () => options.headerData ?? {},
|
||||
getHeaderData: () => options.headerData ?? request.headers ?? {},
|
||||
getInputConnectionData: async () => ({}),
|
||||
getNodeWebhookUrl: (name) => `/test-webhook-url/${name}`,
|
||||
getParamsData: () => ({}),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { IWorkflowBase } from 'n8n-workflow';
|
||||
|
||||
import { test, expect } from '../../../fixtures/base';
|
||||
|
||||
test.describe('Form Trigger', () => {
|
||||
|
|
@ -120,4 +122,203 @@ test.describe('Form Trigger', () => {
|
|||
// Close the form page
|
||||
await formPage.close();
|
||||
});
|
||||
|
||||
test.describe('form execution with basic auth', () => {
|
||||
const password = new Date().toDateString();
|
||||
|
||||
test.use({
|
||||
httpCredentials: {
|
||||
username: 'test',
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
test('form submission works with basic auth', async ({ api, n8n }) => {
|
||||
const { id, name } = await api.credentials.createCredential({
|
||||
name: 'Basic Auth test:test',
|
||||
type: 'httpBasicAuth',
|
||||
data: {
|
||||
user: 'test',
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
const workflow: Partial<IWorkflowBase> = {
|
||||
nodes: [
|
||||
{
|
||||
parameters: {
|
||||
authentication: 'basicAuth',
|
||||
formTitle: 'Test',
|
||||
options: {
|
||||
respondWithOptions: {
|
||||
values: {
|
||||
formSubmittedText: 'This worked',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
typeVersion: 2.5,
|
||||
position: [0, 0],
|
||||
id: '49b31a69-3fc9-43d0-944e-990783330e7a',
|
||||
name: 'On form submission',
|
||||
webhookId: '17eae80c-039e-4779-be68-08cd5afc5f65',
|
||||
credentials: {
|
||||
httpBasicAuth: {
|
||||
id,
|
||||
name,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
pinData: {},
|
||||
meta: {
|
||||
instanceId: 'acd7615bcc3af421bbd7517e305cc16505176a6a47045dbe39e25d904c940573',
|
||||
},
|
||||
};
|
||||
|
||||
const { workflowId } = await api.workflows.createWorkflowFromDefinition(workflow, {
|
||||
makeUnique: true,
|
||||
});
|
||||
|
||||
await n8n.page.goto(`/workflow/${workflowId}`);
|
||||
|
||||
// Start the workflow execution so it's waiting for form submissions
|
||||
await n8n.canvas.clickExecuteWorkflowButton();
|
||||
await expect(n8n.canvas.getExecuteWorkflowButton()).toHaveText('Waiting for trigger event');
|
||||
|
||||
// Get the form test URL from the NDV
|
||||
await n8n.canvas.openNode('On form submission');
|
||||
const formUrlLocator = n8n.page.locator('text=/form-test\\/[a-f0-9-]+/');
|
||||
const formUrl = await formUrlLocator.textContent();
|
||||
|
||||
// Open form URL in a new browser tab
|
||||
|
||||
const formPage = await n8n.page.context().newPage();
|
||||
await formPage.goto(formUrl!);
|
||||
|
||||
// Submit the form
|
||||
await formPage.getByRole('button', { name: 'Submit' }).click();
|
||||
await expect(formPage.getByText('This worked')).toBeVisible();
|
||||
});
|
||||
|
||||
test('multi-step form submission works with basic auth', async ({ api, n8n }) => {
|
||||
const { id, name } = await api.credentials.createCredential({
|
||||
name: 'Basic Auth test:test',
|
||||
type: 'httpBasicAuth',
|
||||
data: {
|
||||
user: 'test',
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
const workflow: Partial<IWorkflowBase> = {
|
||||
nodes: [
|
||||
{
|
||||
parameters: {
|
||||
authentication: 'basicAuth',
|
||||
formTitle: 'Test',
|
||||
},
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
typeVersion: 2.5,
|
||||
position: [0, 0],
|
||||
id: '49b31a69-3fc9-43d0-944e-990783330e7a',
|
||||
name: 'On form submission',
|
||||
webhookId: '17eae80c-039e-4779-be68-08cd5afc5f64',
|
||||
credentials: {
|
||||
httpBasicAuth: {
|
||||
id,
|
||||
name,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
options: {
|
||||
formDescription: 'Step 2',
|
||||
},
|
||||
},
|
||||
type: 'n8n-nodes-base.form',
|
||||
typeVersion: 2.5,
|
||||
position: [208, 0],
|
||||
id: 'e748b959-faeb-4476-aa30-1c7a6434843a',
|
||||
name: 'Form',
|
||||
webhookId: '1e1a6d32-d3a4-4150-886b-2119cc4072bc',
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
operation: 'completion',
|
||||
completionTitle: 'Success',
|
||||
completionMessage: 'This worked',
|
||||
options: {},
|
||||
},
|
||||
type: 'n8n-nodes-base.form',
|
||||
typeVersion: 2.5,
|
||||
position: [416, 0],
|
||||
id: '2e52c834-e08a-4848-bd86-be1f7909a956',
|
||||
name: 'Form1',
|
||||
webhookId: '1839391a-f07d-4bee-858f-678c1b45252b',
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'On form submission': {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: 'Form',
|
||||
type: 'main',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
Form: {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: 'Form1',
|
||||
type: 'main',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
pinData: {},
|
||||
meta: {
|
||||
templateCredsSetupCompleted: true,
|
||||
instanceId: 'acd7615bcc3af421bbd7517e305cc16505176a6a47045dbe39e25d904c940573',
|
||||
},
|
||||
};
|
||||
|
||||
const { workflowId } = await api.workflows.createWorkflowFromDefinition(workflow, {
|
||||
makeUnique: true,
|
||||
});
|
||||
|
||||
await n8n.page.goto(`/workflow/${workflowId}`);
|
||||
|
||||
// Start the workflow execution so it's waiting for form submissions
|
||||
await n8n.canvas.clickExecuteWorkflowButton();
|
||||
await expect(n8n.canvas.getExecuteWorkflowButton()).toHaveText('Waiting for trigger event');
|
||||
|
||||
// Get the form test URL from the NDV
|
||||
await n8n.canvas.openNode('On form submission');
|
||||
const formUrlLocator = n8n.page.locator('text=/form-test\\/[a-f0-9-]+/');
|
||||
const formUrl = await formUrlLocator.textContent();
|
||||
|
||||
// Open form URL in a new browser tab
|
||||
|
||||
const formPage = await n8n.page.context().newPage();
|
||||
await formPage.goto(formUrl!);
|
||||
|
||||
// Submit first page
|
||||
await formPage.getByRole('button', { name: 'Submit' }).click();
|
||||
await expect(formPage.getByText('Step 2')).toBeVisible();
|
||||
|
||||
// submit second page
|
||||
await formPage.getByRole('button', { name: 'Submit' }).click();
|
||||
await expect(formPage.getByText('This worked')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user