diff --git a/packages/blocks/microsoft-teams/src/lib/actions/request-action-message.ts b/packages/blocks/microsoft-teams/src/lib/actions/request-action-message.ts index f4abc03909..6990e5d71a 100644 --- a/packages/blocks/microsoft-teams/src/lib/actions/request-action-message.ts +++ b/packages/blocks/microsoft-teams/src/lib/actions/request-action-message.ts @@ -4,9 +4,9 @@ import { StoreScope, Validators, } from '@openops/blocks-framework'; +import { buildWrapperUrl } from '@openops/common'; import { networkUtls, SharedSystemProp, system } from '@openops/server-shared'; import { ExecutionType } from '@openops/shared'; -import { buildWrapperUrl } from '../common/build-wrapper-url'; import { chatExists } from '../common/chat-exists'; import { ChannelOption, ChatOption, ChatTypes } from '../common/chat-types'; import { chatsAndChannels } from '../common/chats-and-channels'; diff --git a/packages/blocks/slack/src/lib/actions/request-action-message.ts b/packages/blocks/slack/src/lib/actions/request-action-message.ts index 88e50d95a6..ddaa73746e 100644 --- a/packages/blocks/slack/src/lib/actions/request-action-message.ts +++ b/packages/blocks/slack/src/lib/actions/request-action-message.ts @@ -1,4 +1,5 @@ import { createAction, StoreScope } from '@openops/blocks-framework'; +import { buildWrapperUrl } from '@openops/common'; import { networkUtls, SharedSystemProp, system } from '@openops/server-shared'; import { assertNotNullOrUndefined, @@ -17,7 +18,7 @@ import { username, usersAndChannels, } from '../common/props'; -import { slackSendMessage } from '../common/utils'; +import { normalizeEmojiString, slackSendMessage } from '../common/utils'; import { onReceivedInteraction, waitForInteraction, @@ -111,31 +112,54 @@ const sendMessageAskingForAction = async ( const enableSlackInteractions = system.getBoolean(SharedSystemProp.SLACK_ENABLE_INTERACTIONS) ?? true; - if (!enableSlackInteractions) { + const buttonsNeedingUrl = actions.filter( + (action: SlackActionDefinition) => + !enableSlackInteractions || action.followUpQuestion, + ); + + if (buttonsNeedingUrl.length) { const apiUrl = await networkUtls.getPublicUrl(); const frontendUrl = system .getOrThrow(SharedSystemProp.FRONTEND_URL) .replace(/\/$/, ''); - actions.forEach((action: SlackActionDefinition) => { + buttonsNeedingUrl.forEach((action: SlackActionDefinition) => { const resumeUrl = context.generateResumeUrl( { queryParams: { executionCorrelationId: context.run.pauseId, actionClicked: JSON.stringify({ - value: action.buttonText, + value: normalizeEmojiString(action.buttonText), displayText: action.buttonText, }), }, }, apiUrl, ); - action.url = `${frontendUrl}/html/resume_execution.html?isTest=${ - context.run.isTest - }&redirectUrl=${encodeURIComponent(resumeUrl)}`; + action.url = buildWrapperUrl({ + frontendUrl, + isTest: context.run.isTest, + resumeUrl, + followUp: action.followUpQuestion + ? { + question: action.followUpQuestion, + answerFormat: action.answerFormat, + noAnswerOption: action.noAnswerOption, + title: headerText, + } + : undefined, + }); }); } + // Stored normalized because Slack interaction payloads return button text + // in emoji-shortcode form, which is what the interactions endpoint compares. + const followUpActions = actions + .filter((action: SlackActionDefinition) => action.followUpQuestion) + .map((action: SlackActionDefinition) => + normalizeEmojiString(action.buttonText), + ); + const blocks = createMessageBlocks(headerText, text, actions); return await slackSendMessage({ @@ -154,6 +178,7 @@ const sendMessageAskingForAction = async ( executionCorrelationId: context.run.pauseId, }, }), + followUpActions, }, }); }; @@ -183,6 +208,9 @@ interface SlackActionDefinition { buttonStyle: string; confirmationPrompt: boolean; confirmationPromptText: string; + followUpQuestion?: string; + answerFormat?: string; + noAnswerOption?: string; url?: string; } diff --git a/packages/blocks/slack/src/lib/common/props.ts b/packages/blocks/slack/src/lib/common/props.ts index 3bd14b09ad..d48a058f7a 100644 --- a/packages/blocks/slack/src/lib/common/props.ts +++ b/packages/blocks/slack/src/lib/common/props.ts @@ -158,6 +158,31 @@ export const actions = Property.Array({ description: '', required: false, }), + followUpQuestion: Property.ShortText({ + displayName: 'Follow-up question', + description: + 'Optionally ask a follow-up question in a popup window when this button is clicked.', + required: false, + }), + answerFormat: Property.StaticDropdown({ + displayName: 'Answer format', + description: 'Only used when a follow-up question is set.', + required: false, + defaultValue: 'text', + options: { + options: [ + { label: 'Text', value: 'text' }, + { label: 'Email', value: 'email' }, + { label: 'Number', value: 'number' }, + ], + }, + }), + noAnswerOption: Property.ShortText({ + displayName: 'No answer option', + description: + "Optional text for a secondary choice when the person can't answer.", + required: false, + }), }, }); diff --git a/packages/blocks/slack/src/lib/common/wait-for-interaction.ts b/packages/blocks/slack/src/lib/common/wait-for-interaction.ts index f7dbfc6bc2..6dfe68920c 100644 --- a/packages/blocks/slack/src/lib/common/wait-for-interaction.ts +++ b/packages/blocks/slack/src/lib/common/wait-for-interaction.ts @@ -20,8 +20,23 @@ export interface WaitForInteractionResult { message: MessageInfo; isExpired: boolean | undefined; userSelection: UserSelection | UserSelection[] | null; + parameters: Record; } +// Query params the resume mechanism uses for routing; everything else is +// user-provided data (e.g. from a form wrapper page) surfaced as `parameters`. +const EXCLUDED_RESUME_PARAMS = new Set([ + 'actionClicked', + 'actionType', + 'userName', + 'path', + 'executionCorrelationId', + 'isTest', + '__proto__', + 'constructor', + 'prototype', +]); + export async function waitForInteraction( messageObj: MessageInfo, timeoutInDays: number, @@ -50,6 +65,7 @@ export async function waitForInteraction( userSelection: null, isExpired: undefined, message: messageObj, + parameters: {}, }; } @@ -72,6 +88,7 @@ export async function onReceivedInteraction( userSelection: null, isExpired: true, message: updatedMessage, + parameters: {}, }; } @@ -113,9 +130,16 @@ export async function onReceivedInteraction( userSelection: null, isExpired: undefined, message: messageObj, + parameters: {}, }; } + const parameters = Object.fromEntries( + Object.entries(resumePayload as unknown as Record).filter( + ([key]) => !EXCLUDED_RESUME_PARAMS.has(key), + ), + ); + const updatedMessage = await actionReceived( context, messageObj, @@ -131,6 +155,7 @@ export async function onReceivedInteraction( message: updatedMessage, userSelection, isExpired: false, + parameters, }; } diff --git a/packages/blocks/slack/test/request-action-message.test.ts b/packages/blocks/slack/test/request-action-message.test.ts index 9d9639cbc5..5c26a21e21 100644 --- a/packages/blocks/slack/test/request-action-message.test.ts +++ b/packages/blocks/slack/test/request-action-message.test.ts @@ -218,6 +218,7 @@ describe('requestActionMessageAction', () => { domain: mockContextWithHeader.server.publicUrl, resumeUrl: undefined, interactionsDisabled: false, + followUpActions: [], }, }); @@ -342,6 +343,104 @@ describe('requestActionMessageAction', () => { ); }); + describe('follow-up question buttons', () => { + beforeEach(() => { + jest.clearAllMocks(); + waitForInteractionMock.mockImplementation(async (messageObj: any) => + Promise.resolve({ ...messageObj }), + ); + slackSendMessageMock.mockImplementation(async (message: any) => + Promise.resolve(message), + ); + }); + + function buildContextWithFollowUpAction(): any { + const mockContext = buildMockContext('Header Text', true); + mockContext.propsValue.actions.push({ + buttonText: "I'm not the owner", + buttonStyle: '', + followUpQuestion: "What is the correct owner's email?", + answerFormat: 'email', + noAnswerOption: "I don't know the owner", + }); + mockContext.run.isTest = false; + mockContext.generateResumeUrl.mockImplementation( + ({ queryParams }: any) => { + const query = new URLSearchParams(queryParams).toString(); + return `https://example.com/resume?${query}`; + }, + ); + return mockContext; + } + + test('should give only the follow-up button a collect_input url in interactions mode', async () => { + getBooleanMock.mockReturnValueOnce(true); + + const mockContext = buildContextWithFollowUpAction(); + const result = (await requestActionMessageAction.run(mockContext)) as any; + + const actionBlock = result.blocks.find((b: any) => b.type === 'actions'); + const [plainButton, followUpButton] = actionBlock.elements; + + expect(plainButton.url).not.toBeDefined(); + + expect(followUpButton.url).toContain('/html/collect_input.html?'); + const params = new URL(followUpButton.url).searchParams; + expect(params.get('question')).toBe("What is the correct owner's email?"); + expect(params.get('format')).toBe('email'); + expect(params.get('noAnswerOption')).toBe("I don't know the owner"); + expect(params.get('title')).toBe('Header Text'); + expect(params.get('redirectUrl')).toContain( + 'executionCorrelationId=pause_123', + ); + expect(params.get('redirectUrl')).toContain('actionClicked='); + expect(result.eventPayload.interactionsDisabled).toBe(false); + }); + + test('should wrap all buttons in non-interaction mode and use collect_input for the follow-up one', async () => { + getBooleanMock.mockReturnValueOnce(false); + + const mockContext = buildContextWithFollowUpAction(); + const result = (await requestActionMessageAction.run(mockContext)) as any; + + const actionBlock = result.blocks.find((b: any) => b.type === 'actions'); + const [plainButton, followUpButton] = actionBlock.elements; + + expect(plainButton.url).toContain('/html/resume_execution.html?'); + expect(followUpButton.url).toContain('/html/collect_input.html?'); + expect(result.eventPayload.interactionsDisabled).toBe(true); + }); + + test('should list exactly the follow-up button texts in eventPayload.followUpActions', async () => { + getBooleanMock.mockReturnValueOnce(true); + + const mockContext = buildContextWithFollowUpAction(); + const result = (await requestActionMessageAction.run(mockContext)) as any; + + expect(result.eventPayload.followUpActions).toEqual([ + "I'm not the owner", + ]); + }); + + test('should store emoji-shortcode form in followUpActions and the actionClicked value', async () => { + getBooleanMock.mockReturnValueOnce(true); + + const mockContext = buildContextWithFollowUpAction(); + mockContext.propsValue.actions[1].buttonText = '🎲 Reroll'; + const result = (await requestActionMessageAction.run(mockContext)) as any; + + expect(result.eventPayload.followUpActions).toEqual([ + ':game_die: Reroll', + ]); + + const { queryParams } = mockContext.generateResumeUrl.mock.calls[0][0]; + expect(JSON.parse(queryParams.actionClicked)).toEqual({ + value: ':game_die: Reroll', + displayText: '🎲 Reroll', + }); + }); + }); + describe('RESUME execution', () => { test('should throw an error when there is no message stored in the store', async () => { const mockContextWithHeader = buildMockContext('This is a header', true); diff --git a/packages/blocks/slack/test/wait-for-action.test.ts b/packages/blocks/slack/test/wait-for-action.test.ts index 4b5fd42478..a1d519f59c 100644 --- a/packages/blocks/slack/test/wait-for-action.test.ts +++ b/packages/blocks/slack/test/wait-for-action.test.ts @@ -62,6 +62,7 @@ describe('waitForAction', () => { isExpired: undefined, message: context.propsValue.message, userSelection: null, + parameters: {}, }); expect(pauseMock).toHaveBeenCalledTimes(1); @@ -89,6 +90,7 @@ describe('waitForAction', () => { isExpired: true, message: 'some updated message', userSelection: null, + parameters: {}, }); expect(slackUpdateMessageMock).toHaveBeenCalledTimes(1); @@ -146,6 +148,7 @@ describe('waitForAction', () => { displayText: actionText, value: actionValue, }, + parameters: {}, }); expect(slackUpdateMessageMock).toHaveBeenCalledTimes(1); @@ -213,6 +216,7 @@ describe('waitForAction', () => { displayText: 'text 2', }, ], + parameters: {}, }); expect(slackUpdateMessageMock).toHaveBeenCalledTimes(1); @@ -269,6 +273,7 @@ describe('waitForAction', () => { displayText: 'some action', value: 'some action', }, + parameters: {}, }); expect(slackUpdateMessageMock).toHaveBeenCalledTimes(1); @@ -319,6 +324,7 @@ describe('waitForAction', () => { isExpired: undefined, message: context.propsValue.message, userSelection: null, + parameters: {}, }); expect(pauseMock).toHaveBeenCalledTimes(1); expect(context.store.get).toHaveBeenCalledTimes(1); @@ -355,6 +361,7 @@ describe('waitForAction', () => { isExpired: undefined, message: context.propsValue.message, userSelection: null, + parameters: {}, }); expect(pauseMock).toHaveBeenCalledTimes(1); expect(context.store.get).toHaveBeenCalledTimes(1); diff --git a/packages/blocks/slack/test/wait-for-interaction.test.ts b/packages/blocks/slack/test/wait-for-interaction.test.ts index 4911c73c4a..1bcd2c9940 100644 --- a/packages/blocks/slack/test/wait-for-interaction.test.ts +++ b/packages/blocks/slack/test/wait-for-interaction.test.ts @@ -34,6 +34,7 @@ describe('wait-for-interaction', () => { userSelection: null, isExpired: undefined, message: messageObj, + parameters: {}, }); expect(pauseMock).toHaveBeenCalledTimes(1); @@ -72,6 +73,7 @@ describe('wait-for-interaction', () => { userSelection: null, isExpired: true, message: 'updated message', + parameters: {}, }); expect(slackUpdateMessageMock).toHaveBeenCalledTimes(1); @@ -316,6 +318,7 @@ describe('wait-for-interaction', () => { userSelection: null, isExpired: undefined, message: messageObj, + parameters: {}, }); expect(pauseMock).toHaveBeenCalledTimes(1); @@ -361,6 +364,7 @@ describe('wait-for-interaction', () => { userSelection: null, isExpired: undefined, message: messageObj, + parameters: {}, }); expect(pauseMock).toHaveBeenCalledTimes(1); @@ -392,6 +396,118 @@ describe('wait-for-interaction', () => { }); }); + describe('resume parameters', () => { + test('should surface extra resume query params as parameters on a matched resume', async () => { + const messageObj: MessageInfo = createMockMessage(); + const context = createMockContext({ + resumePayload: { + queryParams: { + userName: 'test_user', + actionType: 'button', + actionClicked: JSON.stringify({ + value: "I'm not the owner", + displayText: "I'm not the owner", + }), + path: 'step_1', + executionCorrelationId: 'corr-1', + isTest: 'false', + answer: 'someone@example.com', + }, + }, + store: { + get: jest.fn().mockResolvedValue({ + executionCorrelationId: 'pause_123', + resumeDateTime: new Date().toISOString(), + }), + }, + }); + + const result = await onReceivedInteraction( + messageObj, + ["I'm not the owner"], + context, + 'step_1', + ); + + expect(result.isExpired).toBe(false); + expect(result.parameters).toEqual({ answer: 'someone@example.com' }); + }); + + test('should not include prototype-pollution keys in parameters', async () => { + const messageObj: MessageInfo = createMockMessage(); + const queryParams = Object.create(null) as Record; + queryParams['userName'] = 'test_user'; + queryParams['actionType'] = 'button'; + queryParams['actionClicked'] = JSON.stringify({ + value: 'Approve', + displayText: 'Approve', + }); + queryParams['path'] = 'step_1'; + Object.defineProperty(queryParams, '__proto__', { + value: 'polluted', + enumerable: true, + configurable: true, + writable: true, + }); + queryParams['constructor'] = 'polluted'; + queryParams['prototype'] = 'polluted'; + queryParams['answer'] = 'someone@example.com'; + + const context = createMockContext({ + resumePayload: { queryParams }, + store: { + get: jest.fn().mockResolvedValue({ + executionCorrelationId: 'pause_123', + resumeDateTime: new Date().toISOString(), + }), + }, + }); + + const result = await onReceivedInteraction( + messageObj, + ['Approve'], + context, + 'step_1', + ); + + expect(result.parameters).toEqual({ answer: 'someone@example.com' }); + }); + + test('should return empty parameters when only routing params are present', async () => { + const messageObj: MessageInfo = createMockMessage(); + const context = createMockContext({ + resumePayload: { + queryParams: { + userName: 'test_user', + actionType: 'button', + actionClicked: JSON.stringify({ + value: 'Approve', + displayText: 'Approve', + }), + path: 'step_1', + executionCorrelationId: 'corr-1', + isTest: 'true', + }, + }, + store: { + get: jest.fn().mockResolvedValue({ + executionCorrelationId: 'pause_123', + resumeDateTime: new Date().toISOString(), + }), + }, + }); + + const result = await onReceivedInteraction( + messageObj, + ['Approve'], + context, + 'step_1', + ); + + expect(result.parameters).toEqual({}); + }); + }); + describe('message updates', () => { test('should update message with action received block', async () => { const messageObj: MessageInfo = createMockMessage(); @@ -446,12 +562,7 @@ describe('wait-for-interaction', () => { function createMockContext(params?: { run?: { pause?: jest.Mock; pauseId?: string }; resumePayload?: { - queryParams?: { - userName?: string; - actionType?: string; - actionClicked?: string; - path?: string; - }; + queryParams?: Record; }; store?: { get?: jest.Mock; diff --git a/packages/openops/src/index.ts b/packages/openops/src/index.ts index af3a4c2614..7fcaf47a96 100644 --- a/packages/openops/src/index.ts +++ b/packages/openops/src/index.ts @@ -68,6 +68,7 @@ export * from './lib/azure/subscription/get-subscription-dropdown'; export * from './lib/azure/subscription/get-subscriptions'; export * from './lib/axios-wrapper'; +export * from './lib/build-wrapper-url'; export * from './lib/cloud-cli-common'; export * from './lib/dry-run-property'; diff --git a/packages/blocks/microsoft-teams/src/lib/common/build-wrapper-url.ts b/packages/openops/src/lib/build-wrapper-url.ts similarity index 100% rename from packages/blocks/microsoft-teams/src/lib/common/build-wrapper-url.ts rename to packages/openops/src/lib/build-wrapper-url.ts diff --git a/packages/blocks/microsoft-teams/test/build-wrapper-url.test.ts b/packages/openops/test/build-wrapper-url.test.ts similarity index 97% rename from packages/blocks/microsoft-teams/test/build-wrapper-url.test.ts rename to packages/openops/test/build-wrapper-url.test.ts index a36e93c3e0..061cf553f6 100644 --- a/packages/blocks/microsoft-teams/test/build-wrapper-url.test.ts +++ b/packages/openops/test/build-wrapper-url.test.ts @@ -1,4 +1,4 @@ -import { buildWrapperUrl } from '../src/lib/common/build-wrapper-url'; +import { buildWrapperUrl } from '../src/lib/build-wrapper-url'; const baseParams = { frontendUrl: 'https://app.openops.com', diff --git a/packages/server/api/src/app/slack/is-follow-up-action.ts b/packages/server/api/src/app/slack/is-follow-up-action.ts new file mode 100644 index 0000000000..4ec8840514 --- /dev/null +++ b/packages/server/api/src/app/slack/is-follow-up-action.ts @@ -0,0 +1,16 @@ +type UserSelection = { value: string; displayText: string }; + +// Follow-up buttons are link buttons that open a browser form which owns the +// resume; Slack still posts an interaction payload for them, so the endpoint +// must not resume the flow. Messages sent by older block versions have no +// followUpActions metadata, which is treated as an empty list. +export function isFollowUpAction( + userSelection: UserSelection | UserSelection[], + followUpActions: unknown, +): boolean { + if (Array.isArray(userSelection) || !Array.isArray(followUpActions)) { + return false; + } + + return followUpActions.includes(userSelection.value); +} diff --git a/packages/server/api/src/app/slack/slack-interaction-module.ts b/packages/server/api/src/app/slack/slack-interaction-module.ts index 6d4a1f5d5a..f9208b2d43 100644 --- a/packages/server/api/src/app/slack/slack-interaction-module.ts +++ b/packages/server/api/src/app/slack/slack-interaction-module.ts @@ -10,6 +10,7 @@ import axios from 'axios'; import { FastifyReply, FastifyRequest } from 'fastify'; import { sendEphemeralMessage } from './ephemeral-message'; import { getUserSelection } from './get-user-selection'; +import { isFollowUpAction } from './is-follow-up-action'; import { verifySignature } from './slack-token-verifier'; export const CreateSlackInteractionRequest = Type.Object({ @@ -140,6 +141,20 @@ async function evaluateUserInteraction(payload: any, reply: FastifyReply) { return reply.code(200).send({ text: 'Finished sending ephemeral' }); } + const followUpActions: unknown = + payload.message.metadata.event_payload.followUpActions; + + if (isFollowUpAction(userSelection, followUpActions)) { + logger.debug( + 'Ignoring a Slack interaction: follow-up action resumes via the browser form', + { userSelection }, + ); + + return reply + .code(200) + .send({ text: 'Follow-up action is handled in the browser' }); + } + const resumeUrl: string = payload.message.metadata.event_payload.resumeUrl; if (resumeUrl) { diff --git a/packages/server/api/test/integration/cloud/slack-integration/slack-interaction.test.ts b/packages/server/api/test/integration/cloud/slack-integration/slack-interaction.test.ts index 80a41d39cb..345d2e7328 100644 --- a/packages/server/api/test/integration/cloud/slack-integration/slack-interaction.test.ts +++ b/packages/server/api/test/integration/cloud/slack-integration/slack-interaction.test.ts @@ -257,6 +257,91 @@ describe('Slack API', () => { expect(axiosGetMock).not.toHaveBeenCalled(); }); + test('should return 200 without resuming when the clicked button is a follow-up action', async () => { + verifySignatureMock.mockReturnValueOnce(true); + + const payload = JSON.stringify( + buildButtonClickPayload("I'm not the owner", { + isTest: false, + resumeUrl: 'http://some-resume-url.com?test=1', + followUpActions: ["I'm not the owner"], + }), + ); + + const response = await makeRequest(payload); + + expect(response?.statusCode).toBe(StatusCodes.OK); + expect(response?.json()).toEqual({ + text: 'Follow-up action is handled in the browser', + }); + expect(axiosGetMock).not.toHaveBeenCalled(); + expect(sendEphemeralMessageMock).not.toHaveBeenCalled(); + }); + + test('should resume when the clicked button is not a follow-up action on a message that has some', async () => { + verifySignatureMock.mockReturnValueOnce(true); + axiosGetMock.mockResolvedValueOnce({}); + + const payload = JSON.stringify( + buildButtonClickPayload('Approve', { + isTest: false, + resumeUrl: 'http://some-resume-url.com?test=1', + followUpActions: ["I'm not the owner"], + }), + ); + + const response = await makeRequest(payload); + + expect(response?.statusCode).toBe(StatusCodes.OK); + expect(axiosGetMock).toHaveBeenCalledTimes(1); + expect(axiosGetMock.mock.calls[0][0]).toContain( + 'http://some-resume-url.com/?test=1', + ); + }); + + test('should resume when the message has no followUpActions metadata', async () => { + verifySignatureMock.mockReturnValueOnce(true); + axiosGetMock.mockResolvedValueOnce({}); + + const payload = JSON.stringify( + buildButtonClickPayload('Approve', { + isTest: false, + resumeUrl: 'http://some-resume-url.com?test=1', + }), + ); + + const response = await makeRequest(payload); + + expect(response?.statusCode).toBe(StatusCodes.OK); + expect(axiosGetMock).toHaveBeenCalledTimes(1); + }); + + function buildButtonClickPayload( + buttonText: string, + eventPayload: Record, + ): Record { + return { + actions: [ + { + type: 'button', + action_id: 'some_id', + text: { type: 'plain_text', text: buttonText }, + }, + ], + user: { + id: 'some_user_id', + name: 'some_user_name', + }, + message: { + metadata: { + event_payload: eventPayload, + }, + }, + response_url: + 'https://hooks.slack.com/actions/XXXXXXXX/XXXXXXXXX/XXXXXXXXX', + }; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any async function makeRequest(payload: string): Promise { const request: CreateSlackInteractionRequest = { diff --git a/packages/server/api/test/unit/slack/is-follow-up-action.test.ts b/packages/server/api/test/unit/slack/is-follow-up-action.test.ts new file mode 100644 index 0000000000..21d8a9c597 --- /dev/null +++ b/packages/server/api/test/unit/slack/is-follow-up-action.test.ts @@ -0,0 +1,54 @@ +import { isFollowUpAction } from '../../../src/app/slack/is-follow-up-action'; + +describe('isFollowUpAction', () => { + test('should return true when the selected value is listed in followUpActions', () => { + const result = isFollowUpAction( + { value: "I'm not the owner", displayText: "I'm not the owner" }, + ["I'm not the owner"], + ); + + expect(result).toBe(true); + }); + + test('should return false when the selected value is not listed', () => { + const result = isFollowUpAction( + { value: 'Approve', displayText: 'Approve' }, + ["I'm not the owner"], + ); + + expect(result).toBe(false); + }); + + test.each([undefined, null, 'not-an-array', 42, {}])( + 'should return false when followUpActions is %p', + (followUpActions) => { + const result = isFollowUpAction( + { value: "I'm not the owner", displayText: "I'm not the owner" }, + followUpActions, + ); + + expect(result).toBe(false); + }, + ); + + test('should return false when followUpActions is an empty array', () => { + const result = isFollowUpAction( + { value: "I'm not the owner", displayText: "I'm not the owner" }, + [], + ); + + expect(result).toBe(false); + }); + + test('should return false for multi-select selections', () => { + const result = isFollowUpAction( + [ + { value: "I'm not the owner", displayText: "I'm not the owner" }, + { value: 'Approve', displayText: 'Approve' }, + ], + ["I'm not the owner"], + ); + + expect(result).toBe(false); + }); +}); diff --git a/packages/server/worker/src/lib/engine/engine-pool.ts b/packages/server/worker/src/lib/engine/engine-pool.ts index d119d9c34e..e9e8a9ff3e 100644 --- a/packages/server/worker/src/lib/engine/engine-pool.ts +++ b/packages/server/worker/src/lib/engine/engine-pool.ts @@ -244,6 +244,7 @@ const ENGINE_ALLOWED_ENV_KEYS = new Set([ 'OPS_ENCRYPTION_KEY', 'OPS_CONTAINER_TYPE', 'OPS_SERVER_API_URL', + 'OPS_INTERNAL_BACKEND_PUBLIC_URL', 'OPS_QUEUE_MODE',