diff --git a/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts index 24d18d8cb9a..3ce0c0161f7 100644 --- a/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts @@ -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) => diff --git a/packages/cli/src/webhooks/waiting-webhooks.ts b/packages/cli/src/webhooks/waiting-webhooks.ts index 745b5435622..ed1d8c21801 100644 --- a/packages/cli/src/webhooks/waiting-webhooks.ts +++ b/packages/cli/src/webhooks/waiting-webhooks.ts @@ -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}"`); } diff --git a/packages/cli/src/webhooks/webhook.types.ts b/packages/cli/src/webhooks/webhook.types.ts index 7e10f57b7a1..9f7aaf5342a 100644 --- a/packages/cli/src/webhooks/webhook.types.ts +++ b/packages/cli/src/webhooks/webhook.types.ts @@ -21,7 +21,7 @@ export interface IWebhookManager { getWebhookMethods?: (path: string) => Promise; /** Find the CORS options matching a path and method */ - findAccessControlOptions?: ( + findAccessControlOptions: ( path: string, httpMethod: IHttpRequestMethods, ) => Promise; diff --git a/packages/cli/templates/form-trigger.handlebars b/packages/cli/templates/form-trigger.handlebars index f038e1b59d0..75f31a0b1bf 100644 --- a/packages/cli/templates/form-trigger.handlebars +++ b/packages/cli/templates/form-trigger.handlebars @@ -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; diff --git a/packages/nodes-base/nodes/Form/interfaces.ts b/packages/nodes-base/nodes/Form/interfaces.ts index 1e9adcaa988..62e9cef3ba4 100644 --- a/packages/nodes-base/nodes/Form/interfaces.ts +++ b/packages/nodes-base/nodes/Form/interfaces.ts @@ -48,6 +48,7 @@ export type FormTriggerData = { appendAttribution?: boolean; buttonLabel?: string; dangerousCustomCss?: string; + authToken?: string; }; export const FORM_TRIGGER_AUTHENTICATION_PROPERTY = 'authentication'; diff --git a/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts b/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts index de6a57318d5..f9442c2dbcf 100644 --- a/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts +++ b/packages/nodes-base/nodes/Form/test/FormTriggerV2.node.test.ts @@ -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 }, }, }, diff --git a/packages/nodes-base/nodes/Form/test/utils.test.ts b/packages/nodes-base/nodes/Form/test/utils.test.ts index 89f558a7603..b855f5e71d6 100644 --- a/packages/nodes-base/nodes/Form/test/utils.test.ts +++ b/packages/nodes-base/nodes/Form/test/utils.test.ts @@ -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'); diff --git a/packages/nodes-base/nodes/Form/utils/utils.ts b/packages/nodes-base/nodes/Form/utils/utils.ts index 32d3ab9a77d..86fc4b54403 100644 --- a/packages/nodes-base/nodes/Form/utils/utils.ts +++ b/packages/nodes-base/nodes/Form/utils/utils.ts @@ -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(/
/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 { diff --git a/packages/nodes-base/nodes/Webhook/test/utils.test.ts b/packages/nodes-base/nodes/Webhook/test/utils.test.ts index 0c5e6747be7..ab021afc3fe 100644 --- a/packages/nodes-base/nodes/Webhook/test/utils.test.ts +++ b/packages/nodes-base/nodes/Webhook/test/utils.test.ts @@ -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 = { + 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 = { + 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 = { getNodeParameter: jest.fn().mockReturnValue('headerAuth'), @@ -627,3 +677,127 @@ describe('Webhook Utils', () => { }); }); }); + +describe('Auth token generation', () => { + describe('generateFormPostBasicAuthToken', () => { + let webhookFunctions: ReturnType>; + + beforeEach(() => { + webhookFunctions = mock(); + }); + + 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); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Webhook/utils.ts b/packages/nodes-base/nodes/Webhook/utils.ts index aae00d5e8fe..9398b1c5ede 100644 --- a/packages/nodes-base/nodes/Webhook/utils.ts +++ b/packages/nodes-base/nodes/Webhook/utils.ts @@ -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('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; +} diff --git a/packages/nodes-base/test/nodes/TriggerHelpers.ts b/packages/nodes-base/test/nodes/TriggerHelpers.ts index a1f0aefaf5d..59f2acafafc 100644 --- a/packages/nodes-base/test/nodes/TriggerHelpers.ts +++ b/packages/nodes-base/test/nodes/TriggerHelpers.ts @@ -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: () => ({}), diff --git a/packages/testing/playwright/tests/e2e/nodes/form-trigger-node.spec.ts b/packages/testing/playwright/tests/e2e/nodes/form-trigger-node.spec.ts index 1d76023761f..0ec446e6fe7 100644 --- a/packages/testing/playwright/tests/e2e/nodes/form-trigger-node.spec.ts +++ b/packages/testing/playwright/tests/e2e/nodes/form-trigger-node.spec.ts @@ -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 = { + 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 = { + 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(); + }); + }); });