feat(Slack Node): Add advanced HITL configuration for one-tap approval (#34123)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yen Su 2026-07-15 05:38:46 -07:00 committed by GitHub
parent 3b6af5199a
commit d30e7b042c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1720 additions and 128 deletions

View File

@ -45,8 +45,8 @@ describe('util', () => {
title: '',
message: '',
options: [
{ label: 'Disapprove', url: 'https://no.com', style: 'secondary' },
{ label: 'Approve', url: 'https://yes.com', style: 'primary' },
{ label: 'Disapprove', url: 'https://no.com', style: 'secondary', approved: false },
{ label: 'Approve', url: 'https://yes.com', style: 'primary', approved: true },
],
});
ctx.getNode.mockReturnValue(mock<INode>({ typeVersion: 1.1 }));

View File

@ -8,7 +8,7 @@ import express from 'express';
import { readFile } from 'fs/promises';
import type { Server } from 'http';
import isbot from 'isbot';
import { TELEGRAM_HITL_WEBHOOK_SUFFIX } from 'n8n-core';
import { SLACK_HITL_WEBHOOK_SUFFIX, TELEGRAM_HITL_WEBHOOK_SUFFIX } from 'n8n-core';
import config from '@/config';
import { N8N_VERSION, TEMPLATES_DIR } from '@/constants';
@ -18,6 +18,7 @@ import { bodyParser, corsMiddleware, rawBodyReader } from '@/middlewares';
import { sendErrorResponse } from '@/response-helper';
import { createHandlebarsEngine } from '@/utils/handlebars.util';
import { LiveWebhooks } from '@/webhooks/live-webhooks';
import { SlackInteractionWebhooks } from '@/webhooks/slack-interaction-webhooks';
import { TelegramInteractionWebhooks } from '@/webhooks/telegram-interaction-webhooks';
import { TestWebhooks } from '@/webhooks/test-webhooks';
import { WaitingForms } from '@/webhooks/waiting-forms';
@ -262,6 +263,13 @@ export abstract class AbstractServer {
createWebhookHandlerFor(Container.get(WaitingWebhooks)),
);
// Slack posts all button clicks to one fixed URL, so the ids travel in the button
// value instead of the path.
this.app.all(
`/${this.endpointWebhookWaiting}${SLACK_HITL_WEBHOOK_SUFFIX}`,
createWebhookHandlerFor(Container.get(SlackInteractionWebhooks)),
);
// Register a handler for Telegram HITL callback-button taps. Fixed path (no
// per-execution suffix): the reference travels inside the Telegram update body
// instead of the URL, since Telegram delivers every registered bot's updates to

View File

@ -0,0 +1,289 @@
import type { IExecutionResponse } from '@n8n/db';
import type express from 'express';
import type { InstanceSettings } from 'n8n-core';
import { buildHitlCallbackReference, isSlackInteractionRequest } from 'n8n-core';
import type { IWorkflowBase } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import type { EventService } from '@/events/event.service';
import type { ExecutionPersistence } from '@/executions/execution-persistence';
import { SlackInteractionWebhooks } from '@/webhooks/slack-interaction-webhooks';
import type { IWebhookResponseCallbackData, WaitingWebhookRequest } from '@/webhooks/webhook.types';
const TEST_HMAC_SECRET = 'test-hmac-secret-key';
type GetWebhookExecutionDataArgs = {
executionId: string;
suffix?: string;
req: WaitingWebhookRequest;
};
class TestSlackInteractionWebhooks extends SlackInteractionWebhooks {
getWebhookExecutionDataArgs: GetWebhookExecutionDataArgs | null = null;
// eslint-disable-next-line @typescript-eslint/require-await
protected async getWebhookExecutionData(args: {
executionId: string;
suffix?: string;
req: WaitingWebhookRequest;
}): Promise<IWebhookResponseCallbackData> {
this.getWebhookExecutionDataArgs = {
executionId: args.executionId,
suffix: args.suffix,
req: args.req,
};
return { noWebhookResponse: true };
}
}
describe('SlackInteractionWebhooks', () => {
const executionPersistence = mock<ExecutionPersistence>();
const slackInteractionWebhooks = new TestSlackInteractionWebhooks(
mock(),
mock(),
executionPersistence,
mock(),
mock<InstanceSettings>({ hmacSignatureSecret: TEST_HMAC_SECRET }),
mock<EventService>(),
);
beforeEach(() => {
vi.clearAllMocks();
slackInteractionWebhooks.getWebhookExecutionDataArgs = null;
});
/**
* Build a Slack interaction request. `reference` is the callback value carried in the clicked
* button (normally minted by buildHitlCallbackReference); `opts` lets tests drop the method or
* supply a raw body directly.
*/
const createRequest = (
reference: string | undefined,
opts: { method?: string; rawBody?: string } = {},
) => {
const payload = JSON.stringify({
actions: reference === undefined ? [] : [{ value: reference }],
});
const req = mock<WaitingWebhookRequest>({ readRawBody: vi.fn().mockResolvedValue(undefined) });
req.method = (opts.method ?? 'POST') as WaitingWebhookRequest['method'];
// Assign the real Buffer after construction so the deep mock does not proxy it.
req.rawBody = Buffer.from(opts.rawBody ?? `payload=${encodeURIComponent(payload)}`);
return req;
};
const createResponse = () => {
const status = vi.fn().mockReturnThis();
const send = vi.fn().mockReturnThis();
return { res: mock<express.Response>({ status, send }), status, send };
};
/** A waiting execution whose workflow contains a single node with the given id. */
const waitingExecutionWithNode = (nodeId: string, lastNodeExecuted = 'SlackNode') =>
mock<IExecutionResponse>({
status: 'waiting',
finished: false,
data: { resultData: { lastNodeExecuted, error: undefined } },
workflowData: {
id: 'workflow-1',
name: 'Test Workflow',
nodes: [
{
name: 'SlackNode',
type: 'n8n-nodes-base.slack',
typeVersion: 1,
parameters: {},
id: nodeId,
position: [0, 0],
},
],
connections: {},
active: false,
activeVersionId: null,
settings: {},
staticData: {},
isArchived: false,
} as unknown as IWorkflowBase,
});
it('responds 404 without resuming for a non-POST request', async () => {
const reference = buildHitlCallbackReference('exec-1', 'a', TEST_HMAC_SECRET);
const req = createRequest(reference, { method: 'GET' });
const { res, status } = createResponse();
const result = await slackInteractionWebhooks.executeWebhook(req, res);
expect(status).toHaveBeenCalledWith(404);
expect(result).toEqual({ noWebhookResponse: true });
expect(executionPersistence.findSingleExecution).not.toHaveBeenCalled();
expect(slackInteractionWebhooks.getWebhookExecutionDataArgs).toBeNull();
});
// Slack also fires interactions for URL buttons (plain-link, free-text / custom-form "Respond")
// that carry no resumable reference. These must be acked with 200 (a non-2xx surfaces "the app
// responded with status code 400") and must never resume the execution.
it('acknowledges with 200 without resuming when the request has no payload field', async () => {
const req = createRequest(undefined, { rawBody: 'foo=bar' });
const { res, status } = createResponse();
const result = await slackInteractionWebhooks.executeWebhook(req, res);
expect(status).toHaveBeenCalledWith(200);
expect(result).toEqual({ noWebhookResponse: true });
expect(executionPersistence.findSingleExecution).not.toHaveBeenCalled();
expect(slackInteractionWebhooks.getWebhookExecutionDataArgs).toBeNull();
});
it('acknowledges with 200 without resuming when the button value is not a recognizable reference', async () => {
const req = createRequest('not-a-reference');
const { res, status } = createResponse();
const result = await slackInteractionWebhooks.executeWebhook(req, res);
expect(status).toHaveBeenCalledWith(200);
expect(result).toEqual({ noWebhookResponse: true });
expect(executionPersistence.findSingleExecution).not.toHaveBeenCalled();
expect(slackInteractionWebhooks.getWebhookExecutionDataArgs).toBeNull();
});
it('responds 404 without resuming when the HMAC does not verify against this instance secret', async () => {
const reference = buildHitlCallbackReference('exec-1', 'a', 'a-different-secret');
const req = createRequest(reference);
const { res, status } = createResponse();
const result = await slackInteractionWebhooks.executeWebhook(req, res);
expect(status).toHaveBeenCalledWith(404);
expect(result).toEqual({ noWebhookResponse: true });
expect(executionPersistence.findSingleExecution).not.toHaveBeenCalled();
expect(slackInteractionWebhooks.getWebhookExecutionDataArgs).toBeNull();
// A rejected request must never be flagged: marking happens only on the resume path.
expect(isSlackInteractionRequest(req)).toBe(false);
});
it('responds 404 without resuming when the referenced execution does not exist', async () => {
const reference = buildHitlCallbackReference('exec-1', 'a', TEST_HMAC_SECRET);
const req = createRequest(reference);
const { res, status } = createResponse();
executionPersistence.findSingleExecution.mockResolvedValue(undefined);
const result = await slackInteractionWebhooks.executeWebhook(req, res);
expect(status).toHaveBeenCalledWith(404);
expect(result).toEqual({ noWebhookResponse: true });
expect(slackInteractionWebhooks.getWebhookExecutionDataArgs).toBeNull();
});
it('responds 409 when the execution is already running', async () => {
const reference = buildHitlCallbackReference('exec-1', 'a', TEST_HMAC_SECRET);
const req = createRequest(reference);
const { res, status } = createResponse();
executionPersistence.findSingleExecution.mockResolvedValue(
mock<IExecutionResponse>({ status: 'running', finished: false }),
);
const result = await slackInteractionWebhooks.executeWebhook(req, res);
expect(status).toHaveBeenCalledWith(409);
expect(result).toEqual({ noWebhookResponse: true });
expect(slackInteractionWebhooks.getWebhookExecutionDataArgs).toBeNull();
});
it('responds 409 when the execution has already finished', async () => {
const reference = buildHitlCallbackReference('exec-1', 'a', TEST_HMAC_SECRET);
const req = createRequest(reference);
const { res, status } = createResponse();
executionPersistence.findSingleExecution.mockResolvedValue(
mock<IExecutionResponse>({ status: 'waiting', finished: true }),
);
const result = await slackInteractionWebhooks.executeWebhook(req, res);
expect(status).toHaveBeenCalledWith(409);
expect(result).toEqual({ noWebhookResponse: true });
});
it('responds 409 without resuming when the execution finished with an error', async () => {
const reference = buildHitlCallbackReference('exec-1', 'a', TEST_HMAC_SECRET);
const req = createRequest(reference);
const { res, status } = createResponse();
executionPersistence.findSingleExecution.mockResolvedValue(
mock<IExecutionResponse>({
status: 'waiting',
finished: false,
data: { resultData: { error: { message: 'boom' } } },
}),
);
const result = await slackInteractionWebhooks.executeWebhook(req, res);
expect(status).toHaveBeenCalledWith(409);
expect(result).toEqual({ noWebhookResponse: true });
// Must not route into the shared resume machinery.
expect(slackInteractionWebhooks.getWebhookExecutionDataArgs).toBeNull();
});
it('responds 404 without resuming when lastNodeExecuted cannot be found in the workflow', async () => {
const reference = buildHitlCallbackReference('exec-1', 'a', TEST_HMAC_SECRET);
const req = createRequest(reference);
const { res, status } = createResponse();
executionPersistence.findSingleExecution.mockResolvedValue(
waitingExecutionWithNode('node-1', 'Missing Node'),
);
const result = await slackInteractionWebhooks.executeWebhook(req, res);
expect(status).toHaveBeenCalledWith(404);
expect(result).toEqual({ noWebhookResponse: true });
expect(slackInteractionWebhooks.getWebhookExecutionDataArgs).toBeNull();
});
it('resolves the node id from lastNodeExecuted and routes into the shared resume', async () => {
const reference = buildHitlCallbackReference('exec-1', 'a', TEST_HMAC_SECRET);
const req = createRequest(reference);
const { res } = createResponse();
executionPersistence.findSingleExecution.mockResolvedValue(waitingExecutionWithNode('node-1'));
const result = await slackInteractionWebhooks.executeWebhook(req, res);
expect(result).toEqual({ noWebhookResponse: true });
expect(slackInteractionWebhooks.getWebhookExecutionDataArgs).toMatchObject({
executionId: 'exec-1',
suffix: 'node-1',
});
// The request must be flagged before routing into the shared resume so the Slack node
// hard-requires a valid signature instead of falling back to the query-param approval path.
const routedReq = slackInteractionWebhooks.getWebhookExecutionDataArgs!.req;
expect(isSlackInteractionRequest(routedReq)).toBe(true);
});
it('strips n8n auth and browserId cookies before routing into the shared resume', async () => {
const reference = buildHitlCallbackReference('exec-1', 'a', TEST_HMAC_SECRET);
const req = createRequest(reference);
// Assign real cookie values after construction so the deep mock does not proxy them.
req.headers = { cookie: 'n8n-auth=token; n8n-browserId=bid; other=keep' };
req.cookies = { 'n8n-auth': 'token', 'n8n-browserId': 'bid', other: 'keep' };
const { res } = createResponse();
executionPersistence.findSingleExecution.mockResolvedValue(waitingExecutionWithNode('node-1'));
await slackInteractionWebhooks.executeWebhook(req, res);
const routedReq = slackInteractionWebhooks.getWebhookExecutionDataArgs!.req;
expect(routedReq.headers.cookie).toBe('other=keep');
expect(routedReq.cookies).toEqual({ other: 'keep' });
});
it('routes a verified decline reference into the shared resume', async () => {
const reference = buildHitlCallbackReference('exec-1', 'd', TEST_HMAC_SECRET);
const req = createRequest(reference);
const { res } = createResponse();
executionPersistence.findSingleExecution.mockResolvedValue(waitingExecutionWithNode('node-1'));
const result = await slackInteractionWebhooks.executeWebhook(req, res);
expect(result).toEqual({ noWebhookResponse: true });
expect(slackInteractionWebhooks.getWebhookExecutionDataArgs).toMatchObject({
executionId: 'exec-1',
suffix: 'node-1',
});
});
});

View File

@ -0,0 +1,102 @@
import type { IExecutionResponse } from '@n8n/db';
import type express from 'express';
import type { ParsedHitlCallbackReference } from 'n8n-core';
import { verifyHitlCallbackReference } from 'n8n-core';
import { WaitingWebhooks } from './waiting-webhooks';
import { sanitizeWebhookRequest } from './webhook-request-sanitizer';
import type { IWebhookResponseCallbackData, WaitingWebhookRequest } from './webhook.types';
interface ValidatedCallback {
execution: IExecutionResponse;
executionId: string;
lastNodeExecuted: string;
nodeId: string;
}
type ValidationResult = { ok: true; value: ValidatedCallback } | { ok: false; status: number };
/**
* Shared base for the fixed-URL HITL interaction webhook handlers (Slack, Telegram).
* The Send and Wait reference travels in the request body (not the URL) and is verified
* here via HMAC; the platform's own request-authenticity check happens downstream in that
* node's webhook handler. Subclasses supply how to read the reference (`parseCallback`) and
* may mark the request before it resumes (`beforeResume`).
*/
export abstract class HitlInteractionWebhooks extends WaitingWebhooks {
protected reject(res: express.Response, status: number): IWebhookResponseCallbackData {
res.status(status).send('');
return { noWebhookResponse: true };
}
/** Parses the raw HTTP body into a typed callback reference, or null if malformed. */
protected abstract parseCallback(
req: WaitingWebhookRequest,
): Promise<ParsedHitlCallbackReference | null>;
/**
* Response when the request carries no resumable reference. Defaults to 400; platforms
* that also deliver fire-and-forget interactions here (Slack URL buttons) override to 200.
*/
protected handleUnrecognizedCallback(res: express.Response): IWebhookResponseCallbackData {
return this.reject(res, 400);
}
/** Runs after the callback is verified and confirmed resumable, before the shared resume. */
protected beforeResume(_req: WaitingWebhookRequest): void {}
/** Checks that the reference is authentic and the execution exists and is still resumable. */
private async validate(parsed: ParsedHitlCallbackReference): Promise<ValidationResult> {
if (!verifyHitlCallbackReference(parsed, this.instanceSettings.hmacSignatureSecret)) {
// Don't reveal whether the execution id exists.
return { ok: false, status: 404 };
}
const execution = await this.getExecution(parsed.executionId);
if (!execution) return { ok: false, status: 404 };
if (execution.status === 'running' || execution.finished || execution.data?.resultData?.error) {
// Replayed or late interaction: nothing left to resume.
return { ok: false, status: 409 };
}
const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string;
const workflow = this.createWorkflow(execution.workflowData);
const nodeId = workflow.getNode(lastNodeExecuted)?.id;
if (!nodeId) return { ok: false, status: 404 };
return {
ok: true,
value: { execution, executionId: parsed.executionId, lastNodeExecuted, nodeId },
};
}
async executeWebhook(
req: WaitingWebhookRequest,
res: express.Response,
): Promise<IWebhookResponseCallbackData> {
// Strip n8n auth/browserId cookies before node code sees the request.
sanitizeWebhookRequest(req);
if (req.method !== 'POST') return this.reject(res, 404);
const parsed: ParsedHitlCallbackReference | null = await this.parseCallback(req);
if (!parsed) return this.handleUnrecognizedCallback(res);
const validated: ValidationResult = await this.validate(parsed);
if (!validated.ok) return this.reject(res, validated.status);
this.beforeResume(req);
// Delegate to the shared resume machinery.
const { execution, executionId, lastNodeExecuted, nodeId } = validated.value;
return await this.getWebhookExecutionData({
execution,
req,
res,
lastNodeExecuted,
executionId,
suffix: nodeId,
});
}
}

View File

@ -0,0 +1,49 @@
import { Service } from '@n8n/di';
import type express from 'express';
import type { ParsedHitlCallbackReference } from 'n8n-core';
import { markSlackInteractionRequest, parseHitlCallbackReference } from 'n8n-core';
import { jsonParse } from 'n8n-workflow';
import { HitlInteractionWebhooks } from './hitl-interaction-webhooks';
import type { IWebhookResponseCallbackData, WaitingWebhookRequest } from './webhook.types';
interface SlackInteractionPayload {
actions?: Array<{ value?: string }>;
}
/**
* Resumes a Send and Wait execution from a Slack interactive button callback. The reference
* travels in the clicked button's `value` and is HMAC-verified here; Slack's own
* `x-slack-signature` is verified downstream by the Slack node's handler (which holds the secret).
*/
@Service()
export class SlackInteractionWebhooks extends HitlInteractionWebhooks {
protected async parseCallback(
req: WaitingWebhookRequest,
): Promise<ParsedHitlCallbackReference | null> {
await req.readRawBody();
// Slack sends interactions as form data with the JSON inside a `payload` field.
const payloadRaw = new URLSearchParams(req.rawBody?.toString() ?? '').get('payload');
if (!payloadRaw) return null;
const payload = jsonParse<SlackInteractionPayload>(payloadRaw, { fallbackValue: {} });
return parseHitlCallbackReference(payload.actions?.[0]?.value ?? '');
}
/**
* Flag the request so the Slack node's webhook handler knows it arrived via this route
* and must hard-require a valid Slack signature (never the query-param approval fallback).
*/
protected beforeResume(req: WaitingWebhookRequest): void {
markSlackInteractionRequest(req);
}
/**
* Slack also posts here for URL buttons (plain-link approval, free-text / custom-form "Respond"),
* which resume via the signed URL the button opens, not here. It requires a 200 ack even though
* there is nothing to resume a non-2xx surfaces "the app responded with status code 400".
*/
protected handleUnrecognizedCallback(res: express.Response): IWebhookResponseCallbackData {
res.status(200).send('');
return { noWebhookResponse: true };
}
}

View File

@ -1,12 +1,10 @@
import type { IExecutionResponse } from '@n8n/db';
import { Service } from '@n8n/di';
import type express from 'express';
import type { ParsedHitlCallbackReference } from 'n8n-core';
import { parseHitlCallbackReference, verifyHitlCallbackReference } from 'n8n-core';
import { parseHitlCallbackReference } from 'n8n-core';
import { jsonParse } from 'n8n-workflow';
import { WaitingWebhooks } from './waiting-webhooks';
import type { IWebhookResponseCallbackData, WaitingWebhookRequest } from './webhook.types';
import { HitlInteractionWebhooks } from './hitl-interaction-webhooks';
import type { WaitingWebhookRequest } from './webhook.types';
interface TelegramCallbackUpdate {
callback_query?: {
@ -14,32 +12,14 @@ interface TelegramCallbackUpdate {
};
}
interface ValidatedCallback {
execution: IExecutionResponse;
executionId: string;
lastNodeExecuted: string;
nodeId: string;
}
type ValidationResult = { ok: true; value: ValidatedCallback } | { ok: false; status: number };
/**
* Receives Telegram callback-button taps (one fixed URL, registered directly
* by the Telegram node when the bot's webhook is free) and resumes the
* matching Send and Wait execution. The reference travels in `callback_query.data`
* and is verified here via HMAC; it carries no Telegram credential, so the
* `X-Telegram-Bot-Api-Secret-Token` header is verified downstream by the
* Telegram node's own webhook handler, which holds the bot credential.
* Resumes a Send and Wait execution from a Telegram callback-button tap. The reference travels
* in `callback_query.data` and is HMAC-verified here; the `X-Telegram-Bot-Api-Secret-Token`
* header is verified downstream by the Telegram node's handler (which holds the bot credential).
*/
@Service()
export class TelegramInteractionWebhooks extends WaitingWebhooks {
private reject(res: express.Response, status: number): IWebhookResponseCallbackData {
res.status(status).send('');
return { noWebhookResponse: true };
}
/** Transform: raw HTTP body -> the typed callback reference, or null if malformed. */
private async parseCallback(
export class TelegramInteractionWebhooks extends HitlInteractionWebhooks {
protected async parseCallback(
req: WaitingWebhookRequest,
): Promise<ParsedHitlCallbackReference | null> {
await req.readRawBody();
@ -48,54 +28,4 @@ export class TelegramInteractionWebhooks extends WaitingWebhooks {
});
return parseHitlCallbackReference(update.callback_query?.data ?? '');
}
/** Validate: reference is authentic, execution exists and is still resumable. */
private async validate(parsed: ParsedHitlCallbackReference): Promise<ValidationResult> {
if (!verifyHitlCallbackReference(parsed, this.instanceSettings.hmacSignatureSecret)) {
// Don't reveal whether the execution id exists.
return { ok: false, status: 404 };
}
const execution = await this.getExecution(parsed.executionId);
if (!execution) return { ok: false, status: 404 };
if (execution.status === 'running' || execution.finished || execution.data?.resultData?.error) {
// Replayed or late tap: nothing left to resume.
return { ok: false, status: 409 };
}
const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string;
const workflow = this.createWorkflow(execution.workflowData);
const nodeId = workflow.getNode(lastNodeExecuted)?.id;
if (!nodeId) return { ok: false, status: 404 };
return {
ok: true,
value: { execution, executionId: parsed.executionId, lastNodeExecuted, nodeId },
};
}
async executeWebhook(
req: WaitingWebhookRequest,
res: express.Response,
): Promise<IWebhookResponseCallbackData> {
if (req.method !== 'POST') return this.reject(res, 404);
const parsed: ParsedHitlCallbackReference | null = await this.parseCallback(req);
if (!parsed) return this.reject(res, 400);
const validated: ValidationResult = await this.validate(parsed);
if (!validated.ok) return this.reject(res, validated.status);
// Business logic: delegate to the shared resume machinery.
const { execution, executionId, lastNodeExecuted, nodeId } = validated.value;
return await this.getWebhookExecutionData({
execution,
req,
res,
lastNodeExecuted,
executionId,
suffix: nodeId,
});
}
}

View File

@ -8,13 +8,39 @@ import { UnexpectedError } from 'n8n-workflow';
export const HITL_CALLBACK_PREFIX = 'nhitl1|';
/**
* Suffix appended to the waiting-webhook base path to build Telegram's fixed
* HITL endpoint. Shared by the CLI (mounting the endpoint), the Telegram node
* (registering it as the bot's webhook), and the Telegram Trigger (forwarding
* callback taps to it), so the three stay in sync without a duplicated literal.
* Suffix on the waiting-webhook base path for Telegram's fixed HITL endpoint. Shared by the
* CLI, the Telegram node, and the Telegram Trigger so the literal stays in one place.
*/
export const TELEGRAM_HITL_WEBHOOK_SUFFIX = '-telegram';
/**
* Suffix on the waiting-webhook base path for Slack's fixed HITL endpoint. Shared by the CLI
* and the Slack Request URL so the literal stays in one place.
*/
export const SLACK_HITL_WEBHOOK_SUFFIX = '-slack';
/**
* Marker set by the CLI's Slack interaction route (`SlackInteractionWebhooks`) on the request.
* The Slack node's handler reads it to hard-require a valid Slack signature and never fall back
* to the query-param approval path. A non-enumerable Symbol keeps it off the request's
* serializable surface, so it can't be spoofed via the body, query, or headers.
*/
const SLACK_INTERACTION_REQUEST = Symbol('n8nSlackInteractionRequest');
/** Flags a request as having arrived via the Slack interaction webhook route. */
export function markSlackInteractionRequest(req: object): void {
Object.defineProperty(req, SLACK_INTERACTION_REQUEST, {
value: true,
enumerable: false,
configurable: true,
});
}
/** Whether a request was flagged by `markSlackInteractionRequest`. */
export function isSlackInteractionRequest(req: object): boolean {
return (req as Record<PropertyKey, unknown>)[SLACK_INTERACTION_REQUEST] === true;
}
export type HitlCallbackDecision = 'a' | 'd';
export interface ParsedHitlCallbackReference {
@ -37,11 +63,9 @@ function computeHmac(executionId: string, decision: HitlCallbackDecision, secret
}
/**
* Builds a compact, HMAC-verified reference for a HITL approval decision, meant
* to be embedded in a platform-native callback payload (e.g. Telegram's
* `callback_data`, capped at 64 bytes). The reference is platform-neutral: it
* carries only the execution id, the decision, and a truncated HMAC, so the
* same helper can back Telegram, Slack, or Gmail HITL callbacks.
* Builds a compact, HMAC-verified reference for a HITL approval decision, embedded in a
* platform-native callback payload (e.g. Telegram's `callback_data`, capped at 64 bytes). It
* carries only the execution id, decision, and a truncated HMAC, so it's platform-neutral.
*/
export function buildHitlCallbackReference(
executionId: string,

View File

@ -1 +1,2 @@
export { useEnhancedHitlSlackExperiment } from './useEnhancedHitlSlackExperiment';
export { filterSlackHitlParameters, SLACK_HITL_PARAMETER_NAMES } from './slackHitlParameters';

View File

@ -0,0 +1,43 @@
import type { INodeProperties } from 'n8n-workflow';
import { describe, expect, it } from 'vitest';
import { filterSlackHitlParameters } from './slackHitlParameters';
const captureResponder: INodeProperties = {
displayName: 'Capture Who Responded',
name: 'captureResponder',
type: 'boolean',
default: false,
};
const approvers: INodeProperties = {
displayName: 'Approvers',
name: 'approvers',
type: 'multiOptions',
default: [],
};
const channelId: INodeProperties = {
displayName: 'Channel',
name: 'channelId',
type: 'string',
default: '',
};
describe('filterSlackHitlParameters', () => {
it('removes captureResponder and approvers', () => {
const result = filterSlackHitlParameters([captureResponder, approvers, channelId]);
expect(result).toEqual([channelId]);
});
it('leaves unrelated parameters untouched', () => {
const result = filterSlackHitlParameters([channelId]);
expect(result).toEqual([channelId]);
});
it('returns an empty array when given no parameters', () => {
expect(filterSlackHitlParameters([])).toEqual([]);
});
});

View File

@ -0,0 +1,9 @@
import type { INodeProperties } from 'n8n-workflow';
/** Advanced HITL parameters on the Slack node, hidden when the experiment is off. */
export const SLACK_HITL_PARAMETER_NAMES = new Set(['captureResponder', 'approvers']);
/** Removes the advanced HITL parameters from a Slack node's rendered parameter list. */
export function filterSlackHitlParameters(parameters: INodeProperties[]): INodeProperties[] {
return parameters.filter((parameter) => !SLACK_HITL_PARAMETER_NAMES.has(parameter.name));
}

View File

@ -65,7 +65,12 @@ import { FORM_NODE_TYPE, FORM_TRIGGER_NODE_TYPE } from 'n8n-workflow';
import type { INode, INodeProperties } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import type { MockInstance } from 'vitest';
import { WAIT_NODE_TYPE, AGENT_NODE_TYPE, TELEGRAM_NODE_TYPE } from '@/app/constants';
import {
WAIT_NODE_TYPE,
AGENT_NODE_TYPE,
TELEGRAM_NODE_TYPE,
SLACK_NODE_TYPE,
} from '@/app/constants';
import { useAiGateway } from '@/app/composables/useAiGateway';
const mockConfirm = vi.fn();
@ -2418,4 +2423,84 @@ describe('ParameterInputList', () => {
}
});
});
describe('Slack HITL parameter gating', () => {
const slackNode = {
...TEST_NODE_NO_ISSUES,
type: SLACK_NODE_TYPE,
};
// captureResponder/approvers are simple types that route through the stubbed
// ParameterInputFull (which forwards its `path` prop as a DOM attribute), so
// presence/absence can be asserted without their real rendering.
const slackParameters: INodeProperties[] = [
{ displayName: 'Channel', name: 'channelId', type: 'string', default: '' },
{
displayName: 'Capture Who Responded',
name: 'captureResponder',
type: 'boolean',
default: false,
},
{ displayName: 'Approvers', name: 'approvers', type: 'string', default: '' },
];
it('hides the advanced HITL parameters when the experiment is off', async () => {
ndvStore.activeNode = slackNode;
const { container } = renderComponent({
props: {
parameters: slackParameters,
nodeValues: TEST_NODE_VALUES,
},
});
await flushPromises();
expect(container.querySelector('[path="channelId"]')).toBeInTheDocument();
expect(container.querySelector('[path="captureResponder"]')).not.toBeInTheDocument();
expect(container.querySelector('[path="approvers"]')).not.toBeInTheDocument();
});
it('shows the advanced HITL parameters when the experiment is on', async () => {
mockedStore(usePostHog).isFeatureEnabled.mockReturnValue(true);
ndvStore.activeNode = slackNode;
const { container } = renderComponent({
props: {
parameters: slackParameters,
nodeValues: TEST_NODE_VALUES,
},
});
await flushPromises();
expect(container.querySelector('[path="channelId"]')).toBeInTheDocument();
expect(container.querySelector('[path="captureResponder"]')).toBeInTheDocument();
expect(container.querySelector('[path="approvers"]')).toBeInTheDocument();
});
it('gates the Slack tool variant too', async () => {
ndvStore.activeNode = { ...slackNode, type: `${SLACK_NODE_TYPE}Tool` };
const { container } = renderComponent({
props: {
parameters: slackParameters,
nodeValues: TEST_NODE_VALUES,
},
});
await flushPromises();
expect(container.querySelector('[path="captureResponder"]')).not.toBeInTheDocument();
expect(container.querySelector('[path="approvers"]')).not.toBeInTheDocument();
});
it('does not filter parameters for other node types', async () => {
ndvStore.activeNode = TEST_NODE_NO_ISSUES;
const { container } = renderComponent({
props: {
parameters: slackParameters,
nodeValues: TEST_NODE_VALUES,
},
});
await flushPromises();
expect(container.querySelector('[path="captureResponder"]')).toBeInTheDocument();
expect(container.querySelector('[path="approvers"]')).toBeInTheDocument();
});
});
});

View File

@ -26,6 +26,7 @@ import {
FORM_TRIGGER_NODE_TYPE,
KEEP_AUTH_IN_NDV_FOR_NODES,
MODAL_CONFIRM,
SLACK_NODE_TYPE,
TELEGRAM_NODE_TYPE,
WAIT_NODE_TYPE,
} from '@/app/constants';
@ -48,6 +49,10 @@ import {
filterTelegramHitlParameters,
useEnhancedHitlTelegramExperiment,
} from '@/experiments/enhancedHitlTelegram';
import {
filterSlackHitlParameters,
useEnhancedHitlSlackExperiment,
} from '@/experiments/enhancedHitlSlack';
import {
getParameterTypeOption,
type ParameterOptionsOverrides,
@ -119,6 +124,7 @@ const workflowHelpers = useWorkflowHelpers();
const i18n = useI18n();
const { isEnabled: isCollectionOverhaulEnabled } = useCollectionOverhaul();
const { isFeatureEnabled: isEnhancedHitlTelegramEnabled } = useEnhancedHitlTelegramExperiment();
const { isFeatureEnabled: isEnhancedHitlSlackEnabled } = useEnhancedHitlSlackExperiment();
const {
dismissCallout,
isCalloutDismissed,
@ -196,6 +202,7 @@ throttledWatch(
node,
hasChatOrManualChatParent,
isEnhancedHitlTelegramEnabled,
isEnhancedHitlSlackEnabled,
],
async () => {
// Pre-calculate disabled state map
@ -243,6 +250,13 @@ throttledWatch(
!isEnhancedHitlTelegramEnabled.value
) {
filteredParameters = filterTelegramHitlParameters(parameters);
} else if (
node.value &&
// usableAsTool appends `Tool` to the node type; gate the tool variant too.
(node.value.type === SLACK_NODE_TYPE || node.value.type === `${SLACK_NODE_TYPE}Tool`) &&
!isEnhancedHitlSlackEnabled.value
) {
filteredParameters = filterSlackHitlParameters(parameters);
} else {
filteredParameters = parameters;
}

View File

@ -23,6 +23,8 @@ export const userScopes = [
'users.profile:read',
'users.profile:write',
'users:read',
// Needed so /users.info returns the responder's email for the HITL capture-responder option.
'users:read.email',
'search:read',
];

View File

@ -81,8 +81,11 @@ export async function downloadFile(this: IWebhookFunctions, url: string): Promis
return response;
}
export async function verifySignature(this: IWebhookFunctions): Promise<boolean> {
const credential = await this.getCredentials('slackApi');
export async function verifySignature(
this: IWebhookFunctions,
credentialType = 'slackApi',
): Promise<boolean> {
const credential = await this.getCredentials(credentialType);
const req = this.getRequestObject();
const timestamp = req.header('x-slack-request-timestamp');

View File

@ -1,4 +1,6 @@
import { Container } from '@n8n/di';
import get from 'lodash/get';
import { buildHitlCallbackReference, InstanceSettings } from 'n8n-core';
import type {
IDataObject,
IExecuteFunctions,
@ -10,7 +12,11 @@ import type {
} from 'n8n-workflow';
import { NodeOperationError, sleep } from 'n8n-workflow';
import type { SendAndWaitMessageBody } from './MessageInterface';
import {
HITL_APPROVE_ACTION_ID,
HITL_DECLINE_ACTION_ID,
type SendAndWaitMessageBody,
} from './MessageInterface';
import { getSendAndWaitConfig } from '../../../utils/sendAndWait/utils';
import { createUtmCampaignLink } from '../../../utils/utilities';
@ -427,6 +433,18 @@ export function createSendAndWaitMessageBody(context: IExecuteFunctions) {
const config = getSendAndWaitConfig(context);
const responseType = context.getNodeParameter('responseType', 0, 'approval');
// Capture-responder only works with Approve/Reject buttons. Free-text and custom-form
// replies still use the plain link button.
const captureResponder =
context.getNodeParameter('captureResponder', 0, false) === true && responseType === 'approval';
// HMAC secret for the callback reference the CLI layer verifies to prove which execution and
// decision to resume (same helper/secret as Telegram). Only needed in capture-responder mode.
const executionId = context.getExecutionId();
const hmacSecret = captureResponder ? Container.get(InstanceSettings).hmacSignatureSecret : '';
const body: SendAndWaitMessageBody = {
channel: target,
blocks: [
@ -453,18 +471,27 @@ export function createSendAndWaitMessageBody(context: IExecuteFunctions) {
},
{
type: 'actions',
elements: config.options.map((option) => {
return {
type: 'button',
style: option.style === 'primary' ? 'primary' : undefined,
text: {
type: 'plain_text',
text: option.label,
emoji: true,
},
url: option.url,
};
}),
elements: config.options.map((option) => ({
type: 'button',
style: option.style === 'primary' ? 'primary' : undefined,
text: {
type: 'plain_text',
text: option.label,
emoji: true,
},
// A button with a `url` is a plain link. In capture-responder mode we drop
// the url so Slack treats it as interactive and POSTs the click to us instead.
...(captureResponder
? {
action_id: option.approved ? HITL_APPROVE_ACTION_ID : HITL_DECLINE_ACTION_ID,
value: buildHitlCallbackReference(
executionId,
option.approved ? 'a' : 'd',
hmacSecret,
),
}
: { url: option.url }),
})),
},
],
};

View File

@ -192,6 +192,43 @@ export const userRLC: INodeProperties = {
],
};
export const captureResponderField: INodeProperties = {
displayName: 'Capture Who Responded',
name: 'captureResponder',
type: 'boolean',
default: false,
// Approval only: the form response types need the plain link button. Both auth modes
// carry a signing secret, so either works for the interactive callback.
displayOptions: {
show: {
authentication: ['accessToken', 'oAuth2'],
responseType: ['approval'],
},
},
description:
"Whether to use Slack interactive buttons so the responder's identity (ID, name, email) is captured and returned with the response. Requires the Slack app to have Interactivity enabled (Request URL pointed at this n8n instance), a signing secret on the credential, and the users:read and users:read.email scopes.",
};
export const approversField: INodeProperties = {
displayName: 'Approver Names or IDs',
name: 'approvers',
type: 'multiOptions',
typeOptions: {
loadOptionsMethod: 'getUsers',
},
default: [],
// Only meaningful for the interactive-button flow (approval + capture responder).
displayOptions: {
show: {
authentication: ['accessToken', 'oAuth2'],
responseType: ['approval'],
captureResponder: [true],
},
},
description:
'Restrict who can approve or decline: a click from anyone not listed is ignored and they get a private notice. Leave empty to let anyone respond. Choose from the list, or specify IDs using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
};
export const replyToMessageField: INodeProperties = {
displayName: 'Reply to a Message',
name: 'thread_ts',

View File

@ -4,6 +4,14 @@ export interface IAttachment {
};
}
/**
* `action_id` values for the interactive HITL approve/decline buttons. Shared by the
* button builder and the webhook consumer so a typo can't silently desync (which would
* fail closed to "declined").
*/
export const HITL_APPROVE_ACTION_ID = 'n8n_hitl_approve';
export const HITL_DECLINE_ACTION_ID = 'n8n_hitl_decline';
// Used for SendAndWaitMessage
export interface TextBlock {
type: string;
@ -24,7 +32,12 @@ export interface ButtonElement {
type: 'button';
style?: 'primary';
text: TextBlock;
url: string;
/** Present for plain link buttons (default HITL behaviour). */
url?: string;
/** Present for interactive buttons (capture-responder mode): the decision. */
action_id?: string;
/** Present for interactive buttons: which run/node to resume, echoed back by Slack. */
value?: string;
}
export interface ActionsBlock {

View File

@ -0,0 +1,224 @@
import { isSlackInteractionRequest, parseHitlCallbackReference } from 'n8n-core';
import { jsonParse, type IDataObject, type IWebhookFunctions } from 'n8n-workflow';
import { slackApiRequest } from './GenericFunctions';
import { HITL_APPROVE_ACTION_ID, type SectionBlock } from './MessageInterface';
import type { SendAndWaitResponder } from '../../../utils/sendAndWait/interfaces';
import { sendAndWaitWebhook } from '../../../utils/sendAndWait/utils';
import { verifySignature } from '../SlackTriggerHelpers';
interface SlackInteractionPayload {
user?: { id?: string; name?: string; username?: string };
actions?: Array<{ action_id?: string; value?: string; action_ts?: string }>;
channel?: { id?: string };
message?: { ts?: string; blocks?: IDataObject[] };
container?: { message_ts?: string };
response_url?: string;
}
/**
* Works out who clicked the button. Always returns their Slack id and name; the email is a
* bonus that only comes through if the app has the `users:read.email` scope.
*/
async function extractSlackResponder(
context: IWebhookFunctions,
payload: SlackInteractionPayload,
): Promise<SendAndWaitResponder> {
const user = payload.user ?? {};
const responder: SendAndWaitResponder = {
id: user.id ?? '',
name: user.name,
username: user.username,
source: 'slack',
};
if (user.id) {
try {
const info = (await slackApiRequest.call(
context,
'GET',
'/users.info',
{},
{ user: user.id },
)) as {
user?: { profile?: { email?: string } };
};
if (info?.user?.profile?.email) responder.email = info.user.profile.email;
} catch {
// No users:read.email scope? Just skip the email and keep id + name.
}
}
return responder;
}
/**
* When someone not on the approver list clicks a button, send them a private ("ephemeral")
* message so only they see that it was ignored. If this fails, we carry on regardless it
* must never cause the execution to resume.
*/
async function notifyNotAuthorized(
context: IWebhookFunctions,
payload: SlackInteractionPayload,
): Promise<void> {
const channel = payload.channel?.id;
const user = payload.user?.id;
if (!channel || !user) return;
try {
await slackApiRequest.call(context, 'POST', '/chat.postEphemeral', {
channel,
user,
text: 'You are not authorized to respond to this request.',
});
} catch {
// Missing chat:write scope, or the user left the channel. Nothing more we can do.
}
}
/** Builds the "Approved/Declined by @user" line added under the message once someone decides. */
function buildDecisionBlocks(approved: boolean, responder: SendAndWaitResponder): SectionBlock[] {
const who = responder.id ? `<@${responder.id}>` : (responder.name ?? 'a user');
const outcome = approved ? ':white_check_mark: *Approved*' : ':x: *Declined*';
return [{ type: 'section', text: { type: 'mrkdwn', text: `${outcome} by ${who}` } }];
}
/**
* Rewrites the original message so the decision is final (buttons gone, outcome shown). Tries
* `response_url` first (no token, but expires ~30 min), then falls back to `chat.update`. If both
* fail the execution still resumes locking the message is nice-to-have, not required.
*/
async function lockSlackMessage(
context: IWebhookFunctions,
payload: SlackInteractionPayload,
blocks: IDataObject[],
text: string,
): Promise<void> {
if (payload.response_url) {
try {
await context.helpers.httpRequest({
method: 'POST',
url: payload.response_url,
headers: { 'Content-Type': 'application/json' },
// response_url replies with a plain "ok", not JSON, so we stringify the body
// ourselves rather than letting the client try to parse the response.
body: JSON.stringify({ replace_original: true, text, blocks }),
});
return;
} catch (error) {
context.logger.warn(
`Slack HITL: response_url update failed (${error instanceof Error ? error.message : String(error)}); trying chat.update`,
);
}
}
const channel = payload.channel?.id;
const ts = payload.message?.ts ?? payload.container?.message_ts;
if (channel && ts) {
try {
await slackApiRequest.call(context, 'POST', '/chat.update', {
channel,
ts,
text,
blocks,
});
} catch (error) {
// A missing chat:write scope shouldn't fail the resume, so leave the message as-is.
context.logger.warn(
`Slack HITL: chat.update failed (${error instanceof Error ? error.message : String(error)}); message left as-is`,
);
}
} else {
context.logger.warn('Slack HITL: cannot lock message — missing channel or message ts');
}
}
/**
* Slack webhook entry point. Interactive button clicks (signed POSTs) are signature-verified,
* attributed to a responder, and lock the message; everything else plain-link approvals, form
* responses is handed to the shared handler unchanged.
*/
export async function slackSendAndWaitWebhook(this: IWebhookFunctions) {
// Whether the request came via the `-slack` interaction route is decided by the CLI flagging
// the request object, not by the attacker-controlled x-slack-signature header. Unflagged
// requests belong to the shared handler, authenticated by the signed resume URL + query param.
const req = this.getRequestObject();
if (!isSlackInteractionRequest(req)) {
return await sendAndWaitWebhook.call(this);
}
// Interaction-route requests carry no signed resume URL, so they must be a validly Slack-signed
// interaction: fail closed with 401 if the secret is missing or the signature doesn't verify,
// never falling back to the query-param path. verifySignature also enforces replay protection
// and constant-time comparison. Pick the credential matching the node's auth mode.
const authentication = this.getNodeParameter('authentication', 'accessToken') as string;
const credentialType = authentication === 'oAuth2' ? 'slackOAuth2Api' : 'slackApi';
const credential = await this.getCredentials(credentialType);
const signingSecret =
typeof credential.signatureSecret === 'string' ? credential.signatureSecret : '';
if (!signingSecret || !(await verifySignature.call(this, credentialType))) {
this.getResponseObject().status(401).send('');
return { noWebhookResponse: true };
}
// Slack sends interactions as form data with the JSON inside a `payload` field.
const body = this.getBodyData();
const payload =
typeof body.payload === 'string'
? jsonParse<SlackInteractionPayload>(body.payload, { fallbackValue: {} })
: (body as SlackInteractionPayload);
// No approvers configured means anyone can respond (the original default). If a list is set,
// a click from anyone not on it is ignored: tell them privately and keep waiting.
const approvers = this.getNodeParameter('approvers', []) as string[];
if (approvers.length > 0 && !approvers.includes(payload.user?.id ?? '')) {
await notifyNotAuthorized(this, payload);
return { noWebhookResponse: true };
}
// Fail closed: approve only when both signals agree — the HMAC-minted callback reference
// (verified at the CLI layer) and Slack's native action_id. Anything else counts as declined.
const action = payload.actions?.[0];
const parsed = parseHitlCallbackReference(action?.value ?? '');
const approved = parsed?.decision === 'a' && action?.action_id === HITL_APPROVE_ACTION_ID;
const responder = await extractSlackResponder(this, payload);
// Slack tells us when the button was clicked via action_ts (epoch seconds). If it's
// missing, fall back to the time we received the request.
const actionTs = action?.action_ts;
const actionMs = actionTs ? Number(actionTs) * 1000 : NaN;
const respondedAt = new Date(Number.isFinite(actionMs) ? actionMs : Date.now()).toISOString();
// Rebuild the message: keep everything except the buttons, then add the outcome line.
const keptBlocks = (payload.message?.blocks ?? []).filter(
(block) => (block as { type?: string }).type !== 'actions',
);
const lockedBlocks = [
...keptBlocks,
...buildDecisionBlocks(approved, responder),
] as IDataObject[];
await lockSlackMessage(
this,
payload,
lockedBlocks,
approved ? 'Request approved' : 'Request declined',
);
return {
webhookResponse: '',
workflowData: [
[
{
json: {
data: {
approved,
responder,
respondedAt,
channel: payload.channel?.id,
messageId: payload.message?.ts ?? payload.container?.message_ts,
},
},
},
],
],
};
}

View File

@ -34,6 +34,8 @@ import {
toMultiOptionsCsv,
} from './GenericFunctions';
import {
approversField,
captureResponderField,
channelRLC,
messageFields,
messageOperations,
@ -42,6 +44,7 @@ import {
userRLC,
} from './MessageDescription';
import { reactionFields, reactionOperations } from './ReactionDescription';
import { slackSendAndWaitWebhook } from './SlackHitlWebhook';
import { starFields, starOperations } from './StarDescription';
import { userFields, userOperations } from './UserDescription';
import { userGroupFields, userGroupOperations } from './UserGroupDescription';
@ -50,7 +53,6 @@ import { sendAndWaitWebhooksDescription } from '../../../utils/sendAndWait/descr
import {
getSendAndWaitProperties,
SEND_AND_WAIT_WAITING_TOOLTIP,
sendAndWaitWebhook,
} from '../../../utils/sendAndWait/utils';
export class SlackV2 implements INodeType {
@ -169,7 +171,7 @@ export class SlackV2 implements INodeType {
},
],
undefined,
undefined,
[captureResponderField, approversField],
{ extraOptions: [replyToMessageField] },
).filter((p) => p.name !== 'subject'),
...starOperations,
@ -365,7 +367,7 @@ export class SlackV2 implements INodeType {
},
};
webhook = sendAndWaitWebhook;
webhook = slackSendAndWaitWebhook;
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();

View File

@ -0,0 +1,604 @@
import { buildHitlCallbackReference, markSlackInteractionRequest } from 'n8n-core';
import type { IWebhookFunctions } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import * as sendAndWaitUtils from '../../../../../utils/sendAndWait/utils';
import * as SlackTriggerHelpers from '../../../SlackTriggerHelpers';
import * as GenericFunctions from '../../../V2/GenericFunctions';
import { HITL_APPROVE_ACTION_ID, HITL_DECLINE_ACTION_ID } from '../../../V2/MessageInterface';
import { slackSendAndWaitWebhook } from '../../../V2/SlackHitlWebhook';
vi.mock('../../../V2/GenericFunctions', () => ({ slackApiRequest: vi.fn() }));
vi.mock('../../../SlackTriggerHelpers', () => ({ verifySignature: vi.fn() }));
vi.mock('../../../../../utils/sendAndWait/utils', () => ({ sendAndWaitWebhook: vi.fn() }));
const slackApiRequest = vi.mocked(GenericFunctions.slackApiRequest);
const verifySignature = vi.mocked(SlackTriggerHelpers.verifySignature);
const sendAndWaitWebhook = vi.mocked(sendAndWaitUtils.sendAndWaitWebhook);
// The button `value` now carries the shared HITL callback reference (executionId|decision|hmac).
// The node only parses it for the decision (parseHitlCallbackReference does no HMAC check — the
// HMAC is verified at the CLI layer), so any secret produces a well-formed reference here.
const APPROVE_VALUE = buildHitlCallbackReference('e1', 'a', 'ref-secret');
const DECLINE_VALUE = buildHitlCallbackReference('e1', 'd', 'ref-secret');
interface ContextOptions {
method?: string;
// Whether the request arrived via the `-slack` interaction route (the CLI flags such requests
// via markSlackInteractionRequest). This — not the x-slack-signature header — is what makes
// the node treat it as a Slack interaction that must be signature-verified.
interaction?: boolean;
signatureSecret?: string;
payload?: Record<string, unknown>;
approvers?: string[];
authentication?: string;
}
function createContext(opts: ContextOptions) {
const ctx = mock<IWebhookFunctions>();
const req = {
method: opts.method ?? 'POST',
headers: {},
} as unknown as ReturnType<IWebhookFunctions['getRequestObject']>;
if (opts.interaction) markSlackInteractionRequest(req);
ctx.getRequestObject.mockReturnValue(req);
const status = vi.fn().mockReturnThis();
const send = vi.fn().mockReturnThis();
ctx.getResponseObject.mockReturnValue({
status,
send,
} as unknown as ReturnType<IWebhookFunctions['getResponseObject']>);
ctx.getCredentials.mockResolvedValue({ signatureSecret: opts.signatureSecret });
ctx.getBodyData.mockReturnValue({ payload: JSON.stringify(opts.payload ?? {}) });
ctx.getNodeParameter.mockImplementation((name: string, fallback?: unknown) => {
if (name === 'approvers') return (opts.approvers ?? []) as never;
if (name === 'authentication') return (opts.authentication ?? fallback) as never;
return fallback as never;
});
const httpRequest = vi.fn();
ctx.helpers = { httpRequest } as unknown as IWebhookFunctions['helpers'];
ctx.logger = {
warn: vi.fn(),
error: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
} as unknown as IWebhookFunctions['logger'];
return { ctx, status, send, httpRequest };
}
describe('slackSendAndWaitWebhook', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('delegates to the shared handler for non-interaction requests', async () => {
const { ctx } = createContext({ method: 'GET', interaction: false });
sendAndWaitWebhook.mockResolvedValue({ noWebhookResponse: true });
await slackSendAndWaitWebhook.call(ctx);
expect(sendAndWaitWebhook).toHaveBeenCalledTimes(1);
expect(ctx.getCredentials).not.toHaveBeenCalled();
});
it('delegates a POST not flagged as an interaction to the shared handler', async () => {
// A request that did not arrive via the `-slack` interaction route (unflagged) belongs to
// the shared handler, which authenticates it with the signed resume URL. It must never be
// treated as a Slack interaction — a custom-form field named `payload` must not change this.
const { ctx } = createContext({
method: 'POST',
interaction: false,
payload: { payload: 'a user-defined form field' },
});
sendAndWaitWebhook.mockResolvedValue({ noWebhookResponse: true });
await slackSendAndWaitWebhook.call(ctx);
expect(sendAndWaitWebhook).toHaveBeenCalledTimes(1);
expect(ctx.getCredentials).not.toHaveBeenCalled();
});
it('responds 401 without resuming when no signing secret is configured', async () => {
const { ctx, status } = createContext({ interaction: true, signatureSecret: undefined });
verifySignature.mockResolvedValue(true);
const result = await slackSendAndWaitWebhook.call(ctx);
expect(status).toHaveBeenCalledWith(401);
expect(result).toEqual({ noWebhookResponse: true });
// Must not fall back to the query-param approval path.
expect(sendAndWaitWebhook).not.toHaveBeenCalled();
});
it('responds 401 without resuming when the signature is invalid', async () => {
const { ctx, status } = createContext({ interaction: true, signatureSecret: 'secret' });
verifySignature.mockResolvedValue(false);
const result = await slackSendAndWaitWebhook.call(ctx);
expect(status).toHaveBeenCalledWith(401);
expect(result).toEqual({ noWebhookResponse: true });
// Must not fall back to the query-param approval path.
expect(sendAndWaitWebhook).not.toHaveBeenCalled();
});
it('rejects an unsigned interaction-route POST with 401 instead of resuming as approved', async () => {
// An interaction-route request with no signature at all (verifySignature never even called
// because there is no secret) must fail closed, not fall through to the shared handler
// where an attacker-controlled `?approved=true` would resume the execution as approved.
const { ctx, status } = createContext({
interaction: true,
signatureSecret: undefined,
payload: {
user: { id: 'U1' },
actions: [{ action_id: HITL_APPROVE_ACTION_ID, value: APPROVE_VALUE }],
},
});
const result = await slackSendAndWaitWebhook.call(ctx);
expect(status).toHaveBeenCalledWith(401);
expect(result).toEqual({ noWebhookResponse: true });
expect(sendAndWaitWebhook).not.toHaveBeenCalled();
// verifySignature is short-circuited by the missing secret, so no resume data is produced.
expect(verifySignature).not.toHaveBeenCalled();
});
it('resumes with the responder, channel and message id, and locks the message via response_url', async () => {
const { ctx, httpRequest } = createContext({
interaction: true,
signatureSecret: 'secret',
payload: {
user: { id: 'U1', name: 'alice', username: 'alice.w' },
actions: [
{ action_id: HITL_APPROVE_ACTION_ID, value: APPROVE_VALUE, action_ts: '1700000000.000' },
],
channel: { id: 'C1' },
message: {
ts: '111.222',
blocks: [
{ type: 'section', text: { type: 'mrkdwn', text: 'Please approve this request' } },
{ type: 'actions', elements: [{ type: 'button' }] },
],
},
response_url: 'https://hooks.slack.com/actions/xxx',
},
});
verifySignature.mockResolvedValue(true);
slackApiRequest.mockResolvedValue({ user: { profile: { email: 'alice@example.com' } } });
httpRequest.mockResolvedValue({});
const result = (await slackSendAndWaitWebhook.call(ctx)) as {
workflowData: Array<Array<{ json: { data: Record<string, unknown> } }>>;
};
expect(result.workflowData[0][0].json.data).toEqual({
approved: true,
responder: {
id: 'U1',
name: 'alice',
username: 'alice.w',
email: 'alice@example.com',
source: 'slack',
},
respondedAt: '2023-11-14T22:13:20.000Z',
channel: 'C1',
messageId: '111.222',
});
// A verified interaction resumes directly and never reaches the unauthenticated handler.
expect(sendAndWaitWebhook).not.toHaveBeenCalled();
// Message locked via response_url, with the buttons removed. Body is a JSON string
// (Slack replies with plain "ok", so we don't parse the response as JSON).
expect(httpRequest).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
url: 'https://hooks.slack.com/actions/xxx',
body: expect.stringContaining('"replace_original":true'),
}),
);
// The original message is preserved and only the actions block is dropped.
const lockBody = httpRequest.mock.calls[0][0].body as string;
expect(lockBody).toContain('Please approve this request');
expect(lockBody).not.toContain('"type":"actions"');
expect(lockBody).toContain('Approved');
});
it('verifies against the OAuth2 credential when the node uses OAuth2 auth', async () => {
const { ctx } = createContext({
interaction: true,
signatureSecret: 'secret',
authentication: 'oAuth2',
payload: {
user: { id: 'U1' },
actions: [
{ action_id: HITL_APPROVE_ACTION_ID, value: APPROVE_VALUE, action_ts: '1700000000.000' },
],
channel: { id: 'C1' },
message: { ts: '111.222' },
response_url: 'https://hooks.slack.com/actions/xxx',
},
});
verifySignature.mockResolvedValue(true);
slackApiRequest.mockResolvedValue({ user: { profile: {} } });
await slackSendAndWaitWebhook.call(ctx);
expect(ctx.getCredentials).toHaveBeenCalledWith('slackOAuth2Api');
expect(verifySignature).toHaveBeenCalledWith('slackOAuth2Api');
});
it('treats a decline action as not approved', async () => {
const { ctx, httpRequest } = createContext({
interaction: true,
signatureSecret: 'secret',
payload: {
user: { id: 'U1' },
actions: [
{ action_id: HITL_DECLINE_ACTION_ID, value: DECLINE_VALUE, action_ts: '1700000000.000' },
],
channel: { id: 'C1' },
message: { ts: '111.222' },
response_url: 'https://hooks.slack.com/actions/xxx',
},
});
verifySignature.mockResolvedValue(true);
slackApiRequest.mockResolvedValue({ user: { profile: {} } });
httpRequest.mockResolvedValue({});
const result = (await slackSendAndWaitWebhook.call(ctx)) as {
workflowData: Array<Array<{ json: { data: { approved: boolean } } }>>;
};
expect(result.workflowData[0][0].json.data.approved).toBe(false);
});
it('ignores a click from someone not on the approver list and notifies them privately', async () => {
const { ctx, httpRequest } = createContext({
interaction: true,
signatureSecret: 'secret',
approvers: ['U_ALLOWED'],
payload: {
user: { id: 'U_OTHER' },
actions: [
{ action_id: HITL_APPROVE_ACTION_ID, value: APPROVE_VALUE, action_ts: '1700000000.000' },
],
channel: { id: 'C1' },
message: { ts: '111.222' },
response_url: 'https://hooks.slack.com/actions/xxx',
},
});
verifySignature.mockResolvedValue(true);
slackApiRequest.mockResolvedValue({ ok: true });
const result = await slackSendAndWaitWebhook.call(ctx);
// Execution stays waiting: no workflowData returned, and the message is not locked.
expect(result).toEqual({ noWebhookResponse: true });
expect(httpRequest).not.toHaveBeenCalled();
expect(slackApiRequest).toHaveBeenCalledWith(
'POST',
'/chat.postEphemeral',
expect.objectContaining({ channel: 'C1', user: 'U_OTHER' }),
);
});
it('resumes when the responder is on the approver list', async () => {
const { ctx, httpRequest } = createContext({
interaction: true,
signatureSecret: 'secret',
approvers: ['U1'],
payload: {
user: { id: 'U1' },
actions: [
{ action_id: HITL_APPROVE_ACTION_ID, value: APPROVE_VALUE, action_ts: '1700000000.000' },
],
channel: { id: 'C1' },
message: { ts: '111.222' },
response_url: 'https://hooks.slack.com/actions/xxx',
},
});
verifySignature.mockResolvedValue(true);
slackApiRequest.mockResolvedValue({ user: { profile: {} } });
httpRequest.mockResolvedValue({});
const result = (await slackSendAndWaitWebhook.call(ctx)) as {
workflowData: Array<Array<{ json: { data: { approved: boolean } } }>>;
};
expect(result.workflowData[0][0].json.data.approved).toBe(true);
});
it('falls back to chat.update when response_url fails', async () => {
const { ctx, httpRequest } = createContext({
interaction: true,
signatureSecret: 'secret',
payload: {
user: { id: 'U1' },
actions: [
{ action_id: HITL_APPROVE_ACTION_ID, value: APPROVE_VALUE, action_ts: '1700000000.000' },
],
channel: { id: 'C1' },
message: { ts: '111.222' },
response_url: 'https://hooks.slack.com/actions/xxx',
},
});
verifySignature.mockResolvedValue(true);
slackApiRequest.mockImplementation(async (_method, resource) => {
if (resource === '/users.info') return { user: { profile: {} } };
return { ok: true };
});
httpRequest.mockRejectedValue(new Error('response_url expired'));
await slackSendAndWaitWebhook.call(ctx);
expect(slackApiRequest).toHaveBeenCalledWith(
'POST',
'/chat.update',
expect.objectContaining({ channel: 'C1', ts: '111.222' }),
);
});
it('resolves as not approved for an unknown action_id and still resumes', async () => {
const { ctx, httpRequest } = createContext({
interaction: true,
signatureSecret: 'secret',
payload: {
user: { id: 'U1' },
actions: [{ action_id: 'something_else', action_ts: '1700000000.000' }],
channel: { id: 'C1' },
message: { ts: '111.222' },
response_url: 'https://hooks.slack.com/actions/xxx',
},
});
verifySignature.mockResolvedValue(true);
slackApiRequest.mockResolvedValue({ user: { profile: {} } });
httpRequest.mockResolvedValue({});
const result = (await slackSendAndWaitWebhook.call(ctx)) as {
workflowData: Array<Array<{ json: { data: { approved: boolean } } }>>;
};
expect(result.workflowData[0][0].json.data.approved).toBe(false);
});
it('fails closed as not approved when an approve action_id carries a decline reference', async () => {
// The two approval signals disagree (native action_id says approve, HMAC-minted reference
// says decline), so the guard must resolve to not approved rather than trusting either.
const { ctx, httpRequest } = createContext({
interaction: true,
signatureSecret: 'secret',
payload: {
user: { id: 'U1' },
actions: [
{ action_id: HITL_APPROVE_ACTION_ID, value: DECLINE_VALUE, action_ts: '1700000000.000' },
],
channel: { id: 'C1' },
message: { ts: '111.222' },
response_url: 'https://hooks.slack.com/actions/xxx',
},
});
verifySignature.mockResolvedValue(true);
slackApiRequest.mockResolvedValue({ user: { profile: {} } });
httpRequest.mockResolvedValue({});
const result = (await slackSendAndWaitWebhook.call(ctx)) as {
workflowData: Array<Array<{ json: { data: { approved: boolean } } }>>;
};
expect(result.workflowData[0][0].json.data.approved).toBe(false);
});
it('fails closed as not approved when a decline action_id carries an approve reference', async () => {
const { ctx, httpRequest } = createContext({
interaction: true,
signatureSecret: 'secret',
payload: {
user: { id: 'U1' },
actions: [
{ action_id: HITL_DECLINE_ACTION_ID, value: APPROVE_VALUE, action_ts: '1700000000.000' },
],
channel: { id: 'C1' },
message: { ts: '111.222' },
response_url: 'https://hooks.slack.com/actions/xxx',
},
});
verifySignature.mockResolvedValue(true);
slackApiRequest.mockResolvedValue({ user: { profile: {} } });
httpRequest.mockResolvedValue({});
const result = (await slackSendAndWaitWebhook.call(ctx)) as {
workflowData: Array<Array<{ json: { data: { approved: boolean } } }>>;
};
expect(result.workflowData[0][0].json.data.approved).toBe(false);
});
it('resolves as not approved when the interaction carries no actions and still resumes', async () => {
const { ctx, httpRequest } = createContext({
interaction: true,
signatureSecret: 'secret',
payload: {
user: { id: 'U1' },
actions: [],
channel: { id: 'C1' },
message: { ts: '111.222' },
response_url: 'https://hooks.slack.com/actions/xxx',
},
});
verifySignature.mockResolvedValue(true);
slackApiRequest.mockResolvedValue({ user: { profile: {} } });
httpRequest.mockResolvedValue({});
const result = (await slackSendAndWaitWebhook.call(ctx)) as {
workflowData: Array<Array<{ json: { data: { approved: boolean } } }>>;
};
expect(result.workflowData[0][0].json.data.approved).toBe(false);
});
it('still resumes when both response_url and chat.update fail to lock the message', async () => {
const { ctx, httpRequest } = createContext({
interaction: true,
signatureSecret: 'secret',
payload: {
user: { id: 'U1' },
actions: [
{ action_id: HITL_APPROVE_ACTION_ID, value: APPROVE_VALUE, action_ts: '1700000000.000' },
],
channel: { id: 'C1' },
message: { ts: '111.222' },
response_url: 'https://hooks.slack.com/actions/xxx',
},
});
verifySignature.mockResolvedValue(true);
httpRequest.mockRejectedValue(new Error('response_url expired'));
slackApiRequest.mockImplementation(async (_method, resource) => {
if (resource === '/users.info') return { user: { profile: {} } };
throw new Error('missing chat:write scope');
});
const result = (await slackSendAndWaitWebhook.call(ctx)) as {
workflowData: Array<Array<{ json: { data: { approved: boolean } } }>>;
};
expect(result.workflowData[0][0].json.data.approved).toBe(true);
});
it('still resumes when the message cannot be locked because channel and ts are missing', async () => {
const { ctx } = createContext({
interaction: true,
signatureSecret: 'secret',
payload: {
user: { id: 'U1' },
actions: [
{ action_id: HITL_APPROVE_ACTION_ID, value: APPROVE_VALUE, action_ts: '1700000000.000' },
],
},
});
verifySignature.mockResolvedValue(true);
slackApiRequest.mockResolvedValue({ user: { profile: {} } });
const result = (await slackSendAndWaitWebhook.call(ctx)) as {
workflowData: Array<Array<{ json: { data: { approved: boolean } } }>>;
};
expect(result.workflowData[0][0].json.data.approved).toBe(true);
});
it('resumes with id and name but no email when users.info fails', async () => {
const { ctx, httpRequest } = createContext({
interaction: true,
signatureSecret: 'secret',
payload: {
user: { id: 'U1', name: 'alice' },
actions: [
{ action_id: HITL_APPROVE_ACTION_ID, value: APPROVE_VALUE, action_ts: '1700000000.000' },
],
channel: { id: 'C1' },
message: { ts: '111.222' },
response_url: 'https://hooks.slack.com/actions/xxx',
},
});
verifySignature.mockResolvedValue(true);
httpRequest.mockResolvedValue({});
slackApiRequest.mockRejectedValue(new Error('missing users:read.email scope'));
const result = (await slackSendAndWaitWebhook.call(ctx)) as {
workflowData: Array<
Array<{ json: { data: { responder: { id: string; name?: string; source: string } } } }>
>;
};
expect(result.workflowData[0][0].json.data.responder).toEqual({
id: 'U1',
name: 'alice',
source: 'slack',
});
});
it('does not fall back to the username for the responder name when name is absent', async () => {
const { ctx, httpRequest } = createContext({
interaction: true,
signatureSecret: 'secret',
payload: {
user: { id: 'U1', username: 'alice.w' },
actions: [
{ action_id: HITL_APPROVE_ACTION_ID, value: APPROVE_VALUE, action_ts: '1700000000.000' },
],
channel: { id: 'C1' },
message: { ts: '111.222' },
response_url: 'https://hooks.slack.com/actions/xxx',
},
});
verifySignature.mockResolvedValue(true);
httpRequest.mockResolvedValue({});
slackApiRequest.mockResolvedValue({ user: { profile: {} } });
const result = (await slackSendAndWaitWebhook.call(ctx)) as {
workflowData: Array<
Array<{ json: { data: { responder: { name?: string; username?: string } } } }>
>;
};
const { responder } = result.workflowData[0][0].json.data;
expect(responder.name).toBeUndefined();
expect(responder.username).toBe('alice.w');
});
it('keeps waiting when notifying an unauthorized responder fails', async () => {
const { ctx } = createContext({
interaction: true,
signatureSecret: 'secret',
approvers: ['U_ALLOWED'],
payload: {
user: { id: 'U_OTHER' },
actions: [
{ action_id: HITL_APPROVE_ACTION_ID, value: APPROVE_VALUE, action_ts: '1700000000.000' },
],
channel: { id: 'C1' },
message: { ts: '111.222' },
},
});
verifySignature.mockResolvedValue(true);
slackApiRequest.mockRejectedValue(new Error('missing chat:write scope'));
const result = await slackSendAndWaitWebhook.call(ctx);
expect(result).toEqual({ noWebhookResponse: true });
});
it('falls back to the receive time for respondedAt when action_ts is invalid', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-02T03:04:05.000Z'));
try {
const { ctx, httpRequest } = createContext({
interaction: true,
signatureSecret: 'secret',
payload: {
user: { id: 'U1' },
actions: [
{ action_id: HITL_APPROVE_ACTION_ID, value: APPROVE_VALUE, action_ts: 'not-a-number' },
],
channel: { id: 'C1' },
message: { ts: '111.222' },
response_url: 'https://hooks.slack.com/actions/xxx',
},
});
verifySignature.mockResolvedValue(true);
slackApiRequest.mockResolvedValue({ user: { profile: {} } });
httpRequest.mockResolvedValue({});
const result = (await slackSendAndWaitWebhook.call(ctx)) as {
workflowData: Array<Array<{ json: { data: { respondedAt: string } } }>>;
};
expect(result.workflowData[0][0].json.data.respondedAt).toBe('2024-01-02T03:04:05.000Z');
} finally {
vi.useRealTimers();
}
});
});

View File

@ -1,10 +1,17 @@
import { Container } from '@n8n/di';
import { buildHitlCallbackReference, InstanceSettings } from 'n8n-core';
import { type INode, SEND_AND_WAIT_OPERATION, type IExecuteFunctions } from 'n8n-workflow';
import type { MockInstance } from 'vitest';
import type { MockProxy } from 'vitest-mock-extended';
import { mock } from 'vitest-mock-extended';
import { type INode, SEND_AND_WAIT_OPERATION, type IExecuteFunctions } from 'n8n-workflow';
import { SlackV2 } from '../../../../V2/SlackV2.node';
import * as GenericFunctions from '../../../../V2/GenericFunctions';
import type { MockInstance } from 'vitest';
import { HITL_APPROVE_ACTION_ID, HITL_DECLINE_ACTION_ID } from '../../../../V2/MessageInterface';
import { SlackV2 } from '../../../../V2/SlackV2.node';
const TEST_HMAC_SECRET = 'test-hmac-secret';
// The button builder derives the callback reference from the instance HMAC secret.
Container.set(InstanceSettings, { hmacSignatureSecret: TEST_HMAC_SECRET } as InstanceSettings);
describe('Test SlackV2, message => sendAndWait', () => {
let slack: SlackV2;
@ -32,6 +39,7 @@ describe('Test SlackV2, message => sendAndWait', () => {
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId');
mockExecuteFunctions.getExecutionId.mockReturnValue('exec-1');
mockExecuteFunctions.getInputData.mockReturnValue([{ json: { data: 'test' } }]);
mockExecuteFunctions.continueOnFail.mockReturnValue(false);
mockExecuteFunctions.putExecutionToWait.mockImplementation(async () => {});
@ -99,6 +107,72 @@ describe('Test SlackV2, message => sendAndWait', () => {
});
});
it('should render interactive buttons carrying the HMAC callback reference when capturing the responder', async () => {
slackApiRequestSpy.mockResolvedValue({ ok: true });
mockExecuteFunctions.getNodeParameter.mockImplementation((key: string) => {
if (key === 'authentication') return 'accessToken';
if (key === 'resource') return 'message';
if (key === 'operation') return SEND_AND_WAIT_OPERATION;
if (key === 'select') return 'channel';
if (key === 'channelId') return 'C123456789';
if (key === 'message') return 'test message';
if (key === 'subject') return '';
if (key === 'approvalOptions.values') return {};
if (key === 'options') return {};
if (key === 'options.limitWaitTime.values') return {};
if (key === 'responseType') return 'approval';
if (key === 'captureResponder') return true;
return undefined;
});
await slack.execute.call(mockExecuteFunctions);
const body = slackApiRequestSpy.mock.calls[0][2];
const actionsBlock = body.blocks.find(
(block: { type: string }) => block.type === 'actions',
) as { elements: Array<{ url?: string; action_id?: string; value?: string }> };
const [approveButton] = actionsBlock.elements;
expect(approveButton.url).toBeUndefined();
expect(approveButton.action_id).toBe(HITL_APPROVE_ACTION_ID);
// The approve button carries a decision-'a' reference for this execution.
expect(approveButton.value).toBe(buildHitlCallbackReference('exec-1', 'a', TEST_HMAC_SECRET));
});
it('assigns the decline action_id to the disapprove button when capturing the responder', async () => {
slackApiRequestSpy.mockResolvedValue({ ok: true });
mockExecuteFunctions.getNodeParameter.mockImplementation((key: string) => {
if (key === 'authentication') return 'accessToken';
if (key === 'resource') return 'message';
if (key === 'operation') return SEND_AND_WAIT_OPERATION;
if (key === 'select') return 'channel';
if (key === 'channelId') return 'C123456789';
if (key === 'message') return 'test message';
if (key === 'subject') return '';
if (key === 'approvalOptions.values') return { approvalType: 'double' };
if (key === 'options') return {};
if (key === 'options.limitWaitTime.values') return {};
if (key === 'responseType') return 'approval';
if (key === 'captureResponder') return true;
return undefined;
});
await slack.execute.call(mockExecuteFunctions);
const body = slackApiRequestSpy.mock.calls[0][2];
const actionsBlock = body.blocks.find(
(block: { type: string }) => block.type === 'actions',
) as { elements: Array<{ action_id?: string; value?: string }> };
// getSendAndWaitConfig pushes the disapprove option first, then approve.
const [declineButton, approveButton] = actionsBlock.elements;
expect(declineButton.action_id).toBe(HITL_DECLINE_ACTION_ID);
expect(approveButton.action_id).toBe(HITL_APPROVE_ACTION_ID);
// Each button carries the matching decision in its reference.
expect(declineButton.value).toBe(buildHitlCallbackReference('exec-1', 'd', TEST_HMAC_SECRET));
expect(approveButton.value).toBe(buildHitlCallbackReference('exec-1', 'a', TEST_HMAC_SECRET));
});
it('should route API errors to error output when continueOnFail is true', async () => {
slackApiRequestSpy.mockRejectedValueOnce(new Error('channel_not_found'));
mockExecuteFunctions.continueOnFail.mockReturnValue(true);

View File

@ -268,19 +268,6 @@ export function getSecretToken(this: IHookFunctions | IWebhookFunctions) {
return secret_token.replace(/[^a-zA-Z0-9\_\-]+/g, '');
}
/**
* Derives which decision ('a' approve / 'd' decline) a Send and Wait button
* represents from its signed resume URL's `approved` query param, so the
* shared `getSendAndWaitConfig` stays untouched by the callback-button path.
*
* This is an implicit coupling to `getSendAndWaitConfig`'s URL-building
* convention (utils/sendAndWait/utils.ts): if its options ever gain an
* explicit decision field, prefer that over re-parsing the link href here.
*/
function decisionFromSignedUrl(url: string): 'a' | 'd' {
return new URL(url).searchParams.get('approved') === 'false' ? 'd' : 'a';
}
export function createSendAndWaitMessageBody(context: IExecuteFunctions, chatApproval = false) {
const chat_id = context.getNodeParameter('chatId', 0) as string;
@ -310,7 +297,7 @@ export function createSendAndWaitMessageBody(context: IExecuteFunctions, chatApp
text: option.label,
callback_data: buildHitlCallbackReference(
executionId,
decisionFromSignedUrl(option.url),
option.approved ? 'a' : 'd',
hmacSecret,
),
};

View File

@ -1,3 +1,5 @@
import { Container } from '@n8n/di';
import { InstanceSettings, parseHitlCallbackReference } from 'n8n-core';
import type { MockProxy } from 'vitest-mock-extended';
import { mock } from 'vitest-mock-extended';
import { type INode, SEND_AND_WAIT_OPERATION, type IExecuteFunctions } from 'n8n-workflow';
@ -7,6 +9,9 @@ import { Telegram } from '../../Telegram.node';
import type { Mock } from 'vitest';
import type * as _importType0 from '../../GenericFunctions';
const TEST_HMAC_SECRET = 'test-hmac-secret';
Container.set(InstanceSettings, { hmacSignatureSecret: TEST_HMAC_SECRET } as InstanceSettings);
vi.mock('../../GenericFunctions', async () => {
const originalModule = await vi.importActual<typeof _importType0>('../../GenericFunctions');
return {
@ -133,3 +138,53 @@ describe('Test Telegram, message => sendAndWait', () => {
expect(mockExecuteFunctions.putExecutionToWait).not.toHaveBeenCalled();
});
});
describe('createSendAndWaitMessageBody - chat approval callback references', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('mints an approve callback with decision "a" and a decline callback with decision "d"', () => {
const context = mock<IExecuteFunctions>();
context.getExecutionId.mockReturnValue('exec-42');
context.getInstanceId.mockReturnValue('instanceId');
context.getSignedResumeUrl.mockImplementation(
(params) =>
`http://localhost/waiting-webhook/nodeID?approved=${params?.approved}&signature=abc`,
);
context.getNodeParameter.mockImplementation((name: string) => {
switch (name) {
case 'chatId':
return 'chatID';
case 'message':
return 'my message';
case 'subject':
return 'my subject';
case 'approvalOptions.values':
return {
approvalType: 'double',
approveLabel: 'Approve',
disapproveLabel: 'Decline',
};
case 'responseType':
return 'approval';
default:
return {};
}
});
const body = genericFunctions.createSendAndWaitMessageBody(context, true);
const buttons = body.reply_markup.inline_keyboard[0] as Array<{
text: string;
callback_data: string;
}>;
const decisionForLabel = (label: string) => {
const button = buttons.find((b) => b.text === label);
return button && parseHitlCallbackReference(button.callback_data)?.decision;
};
expect(decisionForLabel('Approve')).toBe('a');
expect(decisionForLabel('Decline')).toBe('d');
});
});

View File

@ -33,11 +33,13 @@ describe('createMessage', () => {
label: 'Yes',
style: 'primary',
url: 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
approved: true,
},
{
label: 'No',
style: 'secondary',
url: 'http://localhost/waiting-webhook/nodeID?approved=false&signature=abc',
approved: false,
},
],
};
@ -80,6 +82,7 @@ describe('createMessage', () => {
label: 'Confirm',
style: '',
url: 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
approved: true,
},
],
};

View File

@ -127,6 +127,7 @@ describe('Send and Wait utils tests', () => {
label: 'Approve',
style: 'primary',
url: 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
approved: true,
},
],
});
@ -164,11 +165,13 @@ describe('Send and Wait utils tests', () => {
label: 'Reject',
style: 'secondary',
url: 'http://localhost/waiting-webhook/nodeID?approved=false&signature=abc',
approved: false,
},
{
label: 'Approve',
style: 'primary',
url: 'http://localhost/waiting-webhook/nodeID?approved=true&signature=abc',
approved: true,
},
]),
);

View File

@ -31,7 +31,7 @@ import type { IEmail } from './interfaces';
export type SendAndWaitConfig = {
title: string;
message: string;
options: Array<{ label: string; url: string; style: string }>;
options: Array<{ label: string; url: string; style: string; approved: boolean }>;
appendAttribution?: boolean;
};
@ -524,6 +524,7 @@ export function getSendAndWaitConfig(context: IExecuteFunctions): SendAndWaitCon
label,
url: approvedSignedResumeUrl,
style: 'primary',
approved: true,
});
} else if (approvalOptions.approvalType === 'double') {
const approveLabel = escapeHtml(approvalOptions.approveLabel || 'Approve');
@ -536,11 +537,13 @@ export function getSendAndWaitConfig(context: IExecuteFunctions): SendAndWaitCon
label: disapproveLabel,
url: disapprovedSignedResumeUrl,
style: buttonDisapprovalStyle,
approved: false,
});
config.options.push({
label: approveLabel,
url: approvedSignedResumeUrl,
style: buttonApprovalStyle,
approved: true,
});
} else {
const label = escapeHtml(approvalOptions.approveLabel || 'Approve');
@ -549,6 +552,7 @@ export function getSendAndWaitConfig(context: IExecuteFunctions): SendAndWaitCon
label,
url: approvedSignedResumeUrl,
style,
approved: true,
});
}