From 1d1bea4a57ed274dd532d248506ebe550934d137 Mon Sep 17 00:00:00 2001 From: secret104278 Date: Mon, 28 Apr 2025 17:28:39 +0800 Subject: [PATCH] feat: add CloudflareWorkerReceiver implementation and tests - Introduced a new receiver for handling Slack events in Cloudflare Workers. - Implemented signature verification and request handling logic. - Added unit tests to validate functionality and edge cases. - Updated package.json to include @cloudflare/workers-types as a dependency. --- package.json | 1 + src/receivers/CloudflareWorkerReceiver.ts | 351 ++++++++++++++++ .../CloudflareWorkerReceiver.spec.ts | 387 ++++++++++++++++++ 3 files changed, 739 insertions(+) create mode 100644 src/receivers/CloudflareWorkerReceiver.ts create mode 100644 test/unit/receivers/CloudflareWorkerReceiver.spec.ts diff --git a/package.json b/package.json index 96babc06f..02b0c5894 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ }, "devDependencies": { "@biomejs/biome": "^1.9.0", + "@cloudflare/workers-types": "^4.20250428.0", "@tsconfig/node18": "^18.2.4", "@types/chai": "^4.1.7", "@types/mocha": "^10.0.1", diff --git a/src/receivers/CloudflareWorkerReceiver.ts b/src/receivers/CloudflareWorkerReceiver.ts new file mode 100644 index 000000000..3fcc77385 --- /dev/null +++ b/src/receivers/CloudflareWorkerReceiver.ts @@ -0,0 +1,351 @@ +import crypto from 'node:crypto'; +import querystring from 'node:querystring'; +import type { ExecutionContext } from '@cloudflare/workers-types'; +import { ConsoleLogger, LogLevel, type Logger } from '@slack/logger'; +import type App from '../App'; +import { ReceiverMultipleAckError } from '../errors'; +import type { Receiver, ReceiverEvent } from '../types/receiver'; +import type { StringIndexed } from '../types/utilities'; + +function bufferEqual(a: Buffer, b: Buffer) { + if (a.length !== b.length) { + return false; + } + if (crypto.timingSafeEqual) { + return crypto.timingSafeEqual(a, b); + } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +} + +function timeSafeCompare(a: string | number, b: string | number) { + const sa = String(a); + const sb = String(b); + const randomBytes = new Uint8Array(32); + + // Fill the array with cryptographically secure random values + const key = crypto.getRandomValues(randomBytes); + const ah = crypto.createHmac('sha256', key).update(sa).digest(); + const bh = crypto.createHmac('sha256', key).update(sb).digest(); + + return bufferEqual(ah, bh) && a === b; +} + +export interface ReceiverInvalidRequestSignatureHandlerArgs { + rawBody: string; + signature: string; + ts: number; + request: Request; + response: Promise; +} + +export interface CloudflareWorkerReceiverOptions { + /** + * The Slack Signing secret to be used as an input to signature verification to ensure that requests are coming from + * Slack. + * + * If the {@link signatureVerification} flag is set to `false`, this can be set to any value as signature verification + * using this secret will not be performed. + * + * @see {@link https://api.slack.com/authentication/verifying-requests-from-slack#about} for details about signing secrets + */ + signingSecret: string; + /** + * The {@link Logger} for the receiver + * + * @default ConsoleLogger + */ + logger?: Logger; + /** + * The {@link LogLevel} to be used for the logger. + * + * @default LogLevel.INFO + */ + logLevel?: LogLevel; + /** + * Flag that determines whether Bolt should {@link https://api.slack.com/authentication/verifying-requests-from-slack|verify Slack's signature on incoming requests}. + * + * @default true + */ + signatureVerification?: boolean; + /** + * Optional `function` that can extract custom properties from an incoming receiver event + * @param request The API Gateway event {@link Request} + * @returns An object containing custom properties + * + * @default noop + */ + customPropertiesExtractor?: (request: Request) => StringIndexed; + invalidRequestSignatureHandler?: (args: ReceiverInvalidRequestSignatureHandlerArgs) => void; + unhandledRequestTimeoutMillis?: number; + processBeforeResponse?: boolean; +} + +/* + * Receiver implementation for Cloudflare Workers + */ +export default class CloudflareWorkerReceiver implements Receiver { + private signingSecret: string; + + private app?: App; + + private _logger: Logger; + + get logger() { + return this._logger; + } + + private signatureVerification: boolean; + + private customPropertiesExtractor: (request: Request) => StringIndexed; + + private invalidRequestSignatureHandler: (args: ReceiverInvalidRequestSignatureHandlerArgs) => void; + + private unhandledRequestTimeoutMillis: number; + + private processBeforeResponse: boolean; + + public constructor({ + signingSecret, + logger = undefined, + logLevel = LogLevel.INFO, + signatureVerification = true, + customPropertiesExtractor = (_) => ({}), + invalidRequestSignatureHandler, + unhandledRequestTimeoutMillis = 3001, + processBeforeResponse = false, + }: CloudflareWorkerReceiverOptions) { + // Initialize instance variables, substituting defaults for each value + this.signingSecret = signingSecret; + this.signatureVerification = signatureVerification; + this.unhandledRequestTimeoutMillis = unhandledRequestTimeoutMillis; + this.processBeforeResponse = processBeforeResponse; + this._logger = + logger ?? + (() => { + const defaultLogger = new ConsoleLogger(); + defaultLogger.setLevel(logLevel); + return defaultLogger; + })(); + this.customPropertiesExtractor = customPropertiesExtractor; + if (invalidRequestSignatureHandler) { + this.invalidRequestSignatureHandler = invalidRequestSignatureHandler; + } else { + this.invalidRequestSignatureHandler = this.defaultInvalidRequestSignatureHandler; + } + } + + public init(app: App): void { + this.app = app; + } + + // biome-ignore lint/suspicious/noExplicitAny: TODO: what should the REceiver interface here be? probably needs work + public start(..._args: any[]): Promise> { + return new Promise((resolve, reject) => { + try { + const handler = this.toHandler(); + resolve(handler); + } catch (error) { + reject(error); + } + }); + } + + // biome-ignore lint/suspicious/noExplicitAny: TODO: what should the REceiver interface here be? probably needs work + public stop(..._args: any[]): Promise { + return new Promise((resolve, _reject) => { + resolve(); + }); + } + + public toHandler() { + return async (request: Request, _env: unknown, ctx: ExecutionContext): Promise => { + this.logger.debug(`Cloudflare request: ${JSON.stringify(request, null, 2)}`); + + const rawBody = await request.text(); + + // biome-ignore lint/suspicious/noExplicitAny: request bodies can be anything + const body: any = this.parseRequestBody(rawBody, request.headers.get('Content-Type') ?? undefined, this.logger); + + // ssl_check (for Slash Commands) + if ( + typeof body !== 'undefined' && + body != null && + typeof body.ssl_check !== 'undefined' && + body.ssl_check != null + ) { + return Promise.resolve(new Response(null, { status: 200 })); + } + + if (this.signatureVerification) { + // request signature verification + const signature = request.headers.get('X-Slack-Signature') as string; + const ts = Number(request.headers.get('X-Slack-Request-Timestamp')); + if (!this.isValidRequestSignature(this.signingSecret, rawBody, signature, ts)) { + const response = Promise.resolve(new Response(null, { status: 401 })); + this.invalidRequestSignatureHandler({ + rawBody, + signature, + ts, + request, + response, + }); + return response; + } + } + + // url_verification (Events API) + if ( + typeof body !== 'undefined' && + body != null && + typeof body.type !== 'undefined' && + body.type != null && + body.type === 'url_verification' + ) { + return Promise.resolve( + new Response(JSON.stringify({ challenge: body.challenge }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + } + + // Setup ack timeout warning + let isAcknowledged = false; + const noAckTimeoutId = setTimeout(() => { + if (!isAcknowledged) { + this.logger.error( + `An incoming event was not acknowledged within ${this.unhandledRequestTimeoutMillis} ms. Ensure that the ack() argument is called in a listener.`, + ); + } + }, this.unhandledRequestTimeoutMillis); + + let ackResolve: (() => void) | undefined; + const ackPromise = new Promise((resolve) => { + ackResolve = resolve; + }); + + // Structure the ReceiverEvent + // biome-ignore lint/suspicious/noExplicitAny: request responses can be anything + let storedResponse: any; + const retryNum = request.headers.get('X-Slack-Retry-Num'); + const retryReason = request.headers.get('X-Slack-Retry-Reason'); + const event: ReceiverEvent = { + body, + ack: async (response) => { + if (isAcknowledged) { + throw new ReceiverMultipleAckError(); + } + isAcknowledged = true; + clearTimeout(noAckTimeoutId); + if (typeof response === 'undefined' || response == null) { + storedResponse = ''; + } else { + storedResponse = response; + } + if (!this.processBeforeResponse) { + ackResolve?.(); + } + }, + retryNum: retryNum ? Number(retryNum) : undefined, + retryReason: retryReason ?? undefined, + customProperties: this.customPropertiesExtractor(request), + }; + + // Send the event to the app for processing + try { + if (this.processBeforeResponse) { + await this.app?.processEvent(event); + } else { + const processEventPromise = this.app?.processEvent(event); + await Promise.race([processEventPromise, ackPromise]); + if (processEventPromise) { + ctx.waitUntil(processEventPromise); + } + } + + if (storedResponse !== undefined) { + if (typeof storedResponse === 'string') { + return new Response(storedResponse); + } + return new Response(JSON.stringify(storedResponse)); + } + } catch (err) { + this.logger.error('An unhandled error occurred while Bolt processed an event'); + this.logger.debug(`Error details: ${err}, storedResponse: ${storedResponse}`); + return new Response('Internal server error', { status: 500 }); + } + // No matching handler; clear ack warning timeout and return a 404. + clearTimeout(noAckTimeoutId); + this.logger.info(`No request handler matched the request: ${request.url}`); + return new Response('', { status: 404 }); + }; + } + + private parseRequestBody( + stringBody: string, + contentType: string | undefined, + logger: Logger, + // biome-ignore lint/suspicious/noExplicitAny: request bodies can be anything + ): any { + if (contentType === 'application/x-www-form-urlencoded') { + const parsedBody = querystring.parse(stringBody); + if (typeof parsedBody.payload === 'string') { + return JSON.parse(parsedBody.payload); + } + return parsedBody; + } + if (contentType === 'application/json') { + return JSON.parse(stringBody); + } + + logger.warn(`Unexpected content-type detected: ${contentType}`); + try { + // Parse this body anyway + return JSON.parse(stringBody); + } catch (e) { + logger.error(`Failed to parse body as JSON data for content-type: ${contentType}`); + throw e; + } + } + + private isValidRequestSignature( + signingSecret: string, + body: string, + signature: string, + requestTimestamp: number, + ): boolean { + if (!signature || !requestTimestamp) { + return false; + } + // Divide current date to match Slack ts format + // Subtract 5 minutes from current time + const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 60 * 5; + if (requestTimestamp < fiveMinutesAgo) { + return false; + } + + const hmac = crypto.createHmac('sha256', signingSecret); + const [version, hash] = signature.split('='); + hmac.update(`${version}:${requestTimestamp}:${body}`); + const computedHash = hmac.digest('hex'); + + if (!timeSafeCompare(hash, computedHash)) { + return false; + } + + return true; + } + + private defaultInvalidRequestSignatureHandler(args: ReceiverInvalidRequestSignatureHandlerArgs): void { + const { signature, ts } = args; + + this.logger.info( + `Invalid request signature detected (X-Slack-Signature: ${signature}, X-Slack-Request-Timestamp: ${ts})`, + ); + } +} diff --git a/test/unit/receivers/CloudflareWorkerReceiver.spec.ts b/test/unit/receivers/CloudflareWorkerReceiver.spec.ts new file mode 100644 index 000000000..040f619e4 --- /dev/null +++ b/test/unit/receivers/CloudflareWorkerReceiver.spec.ts @@ -0,0 +1,387 @@ +import * as crypto from 'node:crypto'; +import type { ExecutionContext } from '@cloudflare/workers-types'; +import { assert } from 'chai'; +import sinon from 'sinon'; +import CloudflareReceiver from '../../../src/receivers/CloudflareWorkerReceiver'; +import { + createDummyAppMentionEventMiddlewareArgs, + createDummyCommandMiddlewareArgs, + createFakeLogger, + importApp, + mergeOverrides, + noopVoid, + withNoopAppMetadata, + withNoopWebClient, +} from '../helpers'; + +// Helper function to create a dummy Cloudflare Request object +function createDummyCloudflareRequest( + body: string, + timestamp: number, + signingSecret: string, + headers: Record = {}, + method = 'POST', + url = 'https://example.com/slack/events', +): Request { + const signature = crypto.createHmac('sha256', signingSecret).update(`v0:${timestamp}:${body}`).digest('hex'); + const defaultHeaders: Record = { + 'content-type': 'application/json', + 'x-slack-request-timestamp': String(timestamp), + 'x-slack-signature': `v0=${signature}`, + ...headers, + }; + return new Request(url, { + method, + headers: defaultHeaders, + body: body, + }); +} + +const fakeAuthTestResponse = { + ok: true, + enterprise_id: 'E111', + team_id: 'T111', + bot_id: 'B111', + user_id: 'W111', +}; +const appOverrides = mergeOverrides(withNoopAppMetadata(), withNoopWebClient(fakeAuthTestResponse)); + +describe('CloudflareReceiver', () => { + let sandbox: sinon.SinonSandbox; + let mockExecutionContext: ExecutionContext; + const signingSecret = 'my-secret'; + const noopLogger = createFakeLogger(); + + beforeEach(() => { + sandbox = sinon.createSandbox(); + mockExecutionContext = { + waitUntil: sandbox.stub(), + passThroughOnException: sandbox.stub(), + props: {}, + }; + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should instantiate with default logger', async () => { + const cfReceiver = new CloudflareReceiver({ + signingSecret, + logger: noopLogger, + }); + assert.isNotNull(cfReceiver); + }); + + it('should have start method', async () => { + const cfReceiver = new CloudflareReceiver({ + signingSecret, + logger: noopLogger, + }); + const startedHandler = await cfReceiver.start(); + assert.isNotNull(startedHandler); + }); + + it('should have stop method', async () => { + const cfReceiver = new CloudflareReceiver({ + signingSecret, + logger: noopLogger, + }); + await cfReceiver.start(); + await cfReceiver.stop(); + }); + + it('should return a 404 if app has no registered handlers, and 200 if it does', async () => { + const cfReceiver = new CloudflareReceiver({ + signingSecret, + logger: noopLogger, + }); + const handler = cfReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000); + const args = createDummyAppMentionEventMiddlewareArgs(); + const body = JSON.stringify(args.body); + const request1 = createDummyCloudflareRequest(body, timestamp, signingSecret); + const response1 = await handler(request1, {}, mockExecutionContext); + assert.equal(response1.status, 404); + + const App = await importApp(appOverrides); + const app = new App({ + token: 'xoxb-', + receiver: cfReceiver, + }); + app.event('app_mention', noopVoid); + const request2 = createDummyCloudflareRequest(body, timestamp, signingSecret); + const response2 = await handler(request2, {}, mockExecutionContext); + assert.equal(response2.status, 200); + }); + + it('should accept interactivity requests as form-encoded payload', async () => { + const cfReceiver = new CloudflareReceiver({ + signingSecret, + logger: noopLogger, + }); + const handler = cfReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000); + const body = + 'payload=%7B%22type%22%3A%22shortcut%22%2C%22token%22%3A%22fixed-value%22%2C%22action_ts%22%3A%221612879511.716075%22%2C%22team%22%3A%7B%22id%22%3A%22T111%22%2C%22domain%22%3A%22domain-value%22%2C%22enterprise_id%22%3A%22E111%22%2C%22enterprise_name%22%3A%22Sandbox+Org%22%7D%2C%22user%22%3A%7B%22id%22%3A%22W111%22%2C%22username%22%3A%22primary-owner%22%2C%22team_id%22%3A%22T111%22%7D%2C%22is_enterprise_install%22%3Afalse%2C%22enterprise%22%3A%7B%22id%22%3A%22E111%22%2C%22name%22%3A%22Kaz+SDK+Sandbox+Org%22%7D%2C%22callback_id%22%3A%22bolt-js-cloudflare-shortcut%22%2C%22trigger_id%22%3A%22111.222.xxx%22%7D'; + const headers = { + Accept: 'application/json,*/*', + 'content-type': 'application/x-www-form-urlencoded', + 'User-Agent': 'Slackbot 1.0 (+https://api.slack.com/robots)', + }; + const request1 = createDummyCloudflareRequest(body, timestamp, signingSecret, headers); + const response1 = await handler(request1, {}, mockExecutionContext); + assert.equal(response1.status, 404); + + const App = await importApp(appOverrides); + const app = new App({ + token: 'xoxb-', + receiver: cfReceiver, + }); + app.shortcut('bolt-js-cloudflare-shortcut', async ({ ack }) => { + await ack(); + }); + const request2 = createDummyCloudflareRequest(body, timestamp, signingSecret, headers); + const response2 = await handler(request2, {}, mockExecutionContext); + assert.equal(response2.status, 200); + }); + + it('should accept slash commands with form-encoded body', async () => { + const cfReceiver = new CloudflareReceiver({ + signingSecret, + logger: noopLogger, + }); + const handler = cfReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000); + const body = + 'token=fixed-value&team_id=T111&team_domain=domain-value&channel_id=C111&channel_name=random&user_id=W111&user_name=primary-owner&command=%2Fhello-bolt-js-cloudflare&text=&api_app_id=A111&is_enterprise_install=false&enterprise_id=E111&enterprise_name=Sandbox+Org&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxx&trigger_id=111.222.xxx'; + const headers = { + Accept: 'application/json,*/*', + 'content-type': 'application/x-www-form-urlencoded', + 'User-Agent': 'Slackbot 1.0 (+https://api.slack.com/robots)', + }; + const request1 = createDummyCloudflareRequest(body, timestamp, signingSecret, headers); + const response1 = await handler(request1, {}, mockExecutionContext); + assert.equal(response1.status, 404); + + const App = await importApp(appOverrides); + const app = new App({ + token: 'xoxb-', + receiver: cfReceiver, + }); + app.command('/hello-bolt-js-cloudflare', async ({ ack }) => { + await ack(); + }); + const request2 = createDummyCloudflareRequest(body, timestamp, signingSecret, headers); + const response2 = await handler(request2, {}, mockExecutionContext); + assert.equal(response2.status, 200); + }); + + it('should accept ssl_check requests', async () => { + const cfReceiver = new CloudflareReceiver({ + signingSecret, + logger: noopLogger, + }); + const handler = cfReceiver.toHandler(); + const body = 'ssl_check=1&token=legacy-fixed-token'; + const timestamp = Math.floor(Date.now() / 1000); + const headers = { + Accept: 'application/json,*/*', + 'content-type': 'application/x-www-form-urlencoded', + 'User-Agent': 'Slackbot 1.0 (+https://api.slack.com/robots)', + }; + const request = createDummyCloudflareRequest(body, timestamp, signingSecret, headers); + const response = await handler(request, {}, mockExecutionContext); + assert.equal(response.status, 200); + }); + + const urlVerificationBody = JSON.stringify({ + token: 'Jhj5dZrVaK7ZwHHjRyZWjbDl', + challenge: '3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P', + type: 'url_verification', + }); + + it('should accept url_verification requests', async () => { + const timestamp = Math.floor(Date.now() / 1000); + const cfReceiver = new CloudflareReceiver({ + signingSecret, + logger: noopLogger, + }); + const handler = cfReceiver.toHandler(); + const request = createDummyCloudflareRequest(urlVerificationBody, timestamp, signingSecret, { + 'content-type': 'application/json', + }); + const response = await handler(request, {}, mockExecutionContext); + assert.equal(response.status, 200); + const responseBody = await response.json(); + assert.deepEqual(responseBody, { challenge: '3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P' }); + }); + + it('should detect invalid signature', async () => { + const spy = sinon.spy(); + const cfReceiver = new CloudflareReceiver({ + signingSecret, + logger: noopLogger, + invalidRequestSignatureHandler: spy, + }); + const handler = cfReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000); + const signature = crypto + .createHmac('sha256', 'my-secret') + .update(`v0:${timestamp}:${urlVerificationBody}`) + .digest('hex'); + const headers = { + Accept: 'application/json,*/*', + 'Content-Type': 'application/json', + Host: 'xxx.execute-api.ap-northeast-1.amazonaws.com', + 'User-Agent': 'Slackbot 1.0 (+https://api.slack.com/robots)', + 'X-Slack-Request-Timestamp': `${timestamp}`, + 'X-Slack-Signature': `v0=${signature}XXXXXXXX`, // invalid signature + }; + const request = new Request('https://example.com/slack/events', { + method: 'POST', + headers: headers, + body: urlVerificationBody, + }); + const response = await handler(request, {}, mockExecutionContext); + assert.equal(response.status, 401); + assert(spy.calledOnce); + }); + + it('should detect too old request timestamp', async () => { + const cfReceiver = new CloudflareReceiver({ + signingSecret, + logger: noopLogger, + }); + const handler = cfReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000) - 600; // 10 minutes ago + const request = createDummyCloudflareRequest(urlVerificationBody, timestamp, signingSecret, { + 'content-type': 'application/json', + }); + const response = await handler(request, {}, mockExecutionContext); + assert.equal(response.status, 401); + }); + + it('does not perform signature verification if signature verification flag is set to false', async () => { + const cfReceiver = new CloudflareReceiver({ + signingSecret: '', // Provide empty secret + signatureVerification: false, + logger: noopLogger, + }); + const handler = cfReceiver.toHandler(); + // Create request without valid signature headers + const request = new Request('https://example.com/slack/events', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: urlVerificationBody, + }); + const response = await handler(request, {}, mockExecutionContext); + assert.equal(response.status, 200); // Expect 200 because verification is off + const responseBody = await response.json(); + assert.deepEqual(responseBody, { challenge: '3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P' }); + }); + + it('should not log an error regarding ack timeout if app has no handlers registered', async () => { + const delay = 10; + const cfReceiver = new CloudflareReceiver({ + signingSecret: '', + signatureVerification: false, + logger: noopLogger, + unhandledRequestTimeoutMillis: delay, + }); + const handler = cfReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000); + const args = createDummyAppMentionEventMiddlewareArgs(); + const body = JSON.stringify(args.body); + const request = createDummyCloudflareRequest(body, timestamp, '', { 'content-type': 'application/json' }); // No signing secret needed + const response = await handler(request, {}, mockExecutionContext); + assert.equal(response.status, 404); // No handler registered + + await new Promise((res) => { + setTimeout(res, delay + 2); + }); + + sinon.assert.notCalled(cfReceiver.logger.error as sinon.SinonSpy); + }); + + describe('processBeforeResponse=true', () => { + it('should acknowledge the request immediately', async () => { + const cfReceiver = new CloudflareReceiver({ + signingSecret, + logger: noopLogger, + processBeforeResponse: true, + }); + const handler = cfReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000); + const args = createDummyCommandMiddlewareArgs(); + const body = JSON.stringify(args.body); + const request = createDummyCloudflareRequest(body, timestamp, signingSecret); + + const App = await importApp(appOverrides); + const app = new App({ + token: 'xoxb-', + receiver: cfReceiver, + }); + + let ackCalled = false; + app.command('/slash', async ({ ack }) => { + await ack(); // ack() is called + ackCalled = true; + }); + + const response = await handler(request, {}, mockExecutionContext); + assert.equal(response.status, 200); + assert.isTrue(ackCalled, 'ack() should have been called'); + assert.isFalse( + (mockExecutionContext.waitUntil as sinon.SinonStub).called, + 'waitUntil should not be called when processBeforeResponse=true', + ); + }); + }); + + describe('processBeforeResponse=false (default)', () => { + it('should acknowledge the request and process remaining logic via waitUntil', async () => { + const cfReceiver = new CloudflareReceiver({ + signingSecret, + logger: noopLogger, + processBeforeResponse: false, // default + }); + const handler = cfReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000); + const args = createDummyCommandMiddlewareArgs(); + const body = JSON.stringify(args.body); + const request = createDummyCloudflareRequest(body, timestamp, signingSecret); + + const App = await importApp(appOverrides); + const app = new App({ + token: 'xoxb-', + receiver: cfReceiver, + }); + + let ackCalled = false; + let handlerFinished = false; + app.command('/slash', async ({ ack }) => { + await ack(); // ack() is called first + ackCalled = true; + // Simulate some async work after ack + await new Promise((resolve) => setTimeout(resolve, 10)); + handlerFinished = true; + }); + + const response = await handler(request, {}, mockExecutionContext); + assert.equal(response.status, 200); + assert.isTrue(ackCalled, 'ack() should have been called before handler returned'); + // Since the handler returns upon ack(), handlerFinished should still be false + assert.isFalse(handlerFinished, 'handler logic after ack should not block the response'); + assert.isTrue( + (mockExecutionContext.waitUntil as sinon.SinonStub).calledOnce, + 'waitUntil should be called with the remaining promise', + ); + + // Ensure the full handler eventually completes + const waitedPromise = (mockExecutionContext.waitUntil as sinon.SinonStub).firstCall.args[0]; + await waitedPromise; + assert.isTrue(handlerFinished, 'Full handler should complete via waitUntil'); + }); + }); +});