Files
upstream-rocketchat/apps/meteor/server/api/webhooks.spec.ts
upstream-mirror 30810eecd7
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

810 lines
29 KiB
TypeScript

import express from 'express';
import { WebApp } from 'meteor/webapp';
import request from 'supertest';
const mockIntegrations = {
findOneByIdAndToken: jest.fn(),
findOneByUrl: jest.fn(),
};
const mockUsers = {
findOneById: jest.fn(),
};
const mockHasPermissionAsync = jest.fn();
const mockProcessWebhookMessage = jest.fn();
const mockAddOutgoingIntegration = jest.fn();
const mockDeleteOutgoingIntegration = jest.fn();
const mockSettingsStore = new Map<string, unknown>([
['API_Enable_Rate_Limiter_Limit_Calls_Default', 1],
['API_Enable_Rate_Limiter_Limit_Time_Default', 60000],
['API_Enable_Rate_Limiter', false],
]);
const rateLimiterCounters = new Map<string, number>();
class MockRateLimiter {
private numRequestsAllowed = Number.POSITIVE_INFINITY;
private intervalTimeInMS = 0;
private counters = rateLimiterCounters;
addRule(_rule: unknown, numRequestsAllowed: number, intervalTimeInMS: number) {
this.numRequestsAllowed = numRequestsAllowed;
this.intervalTimeInMS = intervalTimeInMS;
}
increment(match: Record<string, unknown>) {
const key = JSON.stringify(match);
this.counters.set(key, (this.counters.get(key) ?? 0) + 1);
}
async check(match: Record<string, unknown>) {
const used = this.counters.get(JSON.stringify(match)) ?? 0;
return {
allowed: used <= this.numRequestsAllowed,
numInvocationsLeft: Math.max(0, this.numRequestsAllowed - used),
timeToReset: this.intervalTimeInMS,
};
}
}
jest.mock(
'meteor/meteor',
() => ({
Meteor: {
startup: (callback: () => void) => callback(),
Error: class MeteorError extends Error {
public details?: unknown;
constructor(
public error: string,
public reason?: string,
details?: unknown,
) {
super(reason ?? error);
this.details = details;
}
},
},
}),
{ virtual: true },
);
jest.mock('meteor/webapp', () => ({ WebApp: { rawConnectHandlers: { use: jest.fn() } } }), { virtual: true });
jest.mock('meteor/rate-limit', () => ({ RateLimiter: MockRateLimiter }), { virtual: true });
jest.mock(
'meteor/accounts-base',
() => ({ Accounts: { _hashLoginToken: (token: string) => `hashed-${token}`, _accountData: {} as Record<string, unknown> } }),
{ virtual: true },
);
jest.mock('meteor/ddp', () => ({ DDP: {} }), { virtual: true });
jest.mock('meteor/ddp-common', () => ({ DDPCommon: {} }), { virtual: true });
jest.mock('../../app/utils/rocketchat.info', () => ({ Info: { version: '0.0.0' } }));
jest.mock('../settings', () => ({
settings: {
get: (id: string) => mockSettingsStore.get(id),
watch: jest.fn(),
watchMultiple: jest.fn(),
watchByRegex: jest.fn(),
getByRegexp: jest.fn(() => []),
},
}));
jest.mock('meteor/facts-base', () => ({ Facts: { incrementServerFact: () => undefined } }), { virtual: true });
jest.mock('@rocket.chat/models', () => ({
Integrations: mockIntegrations,
Users: mockUsers,
}));
jest.mock('meteor/email', () => ({ Email: {} }), { virtual: true });
jest.mock('../lib/authorization/hasPermission', () => ({ hasPermissionAsync: mockHasPermissionAsync }));
jest.mock('../lib/messages/processWebhookMessage', () => ({ processWebhookMessage: mockProcessWebhookMessage }));
jest.mock('../meteor-methods/integrations/outgoing/addOutgoingIntegration', () => ({
addOutgoingIntegration: mockAddOutgoingIntegration,
}));
jest.mock('../meteor-methods/integrations/outgoing/deleteOutgoingIntegration', () => ({
deleteOutgoingIntegration: mockDeleteOutgoingIntegration,
}));
const INTEGRATION = {
_id: 'integration-id',
name: 'my-integration',
enabled: true,
userId: 'assigned-user-id',
channel: ['#general'],
alias: 'alias',
avatar: 'avatar-url',
emoji: ':ghost:',
scriptEnabled: false,
overrideDestinationChannelEnabled: false,
_updatedAt: new Date(0),
};
const ASSIGNED_USER = { _id: 'assigned-user-id', username: 'rocket.cat', name: 'Rocket Cat' };
const HOOK_URL = '/hooks/integration-id/the-token';
const LEGACY_HOOK_URL = '/hooks/integration-id/legacy-user-id/the-token';
jest.requireActual('./api');
jest.requireActual('./webhooks');
const app = express().use(jest.mocked(WebApp.rawConnectHandlers.use).mock.calls[0][0] as unknown as express.RequestHandler);
describe('Webhooks API', () => {
beforeEach(() => {
jest.clearAllMocks();
rateLimiterCounters.clear();
mockSettingsStore.set('API_Enable_Rate_Limiter', false);
process.env.NODE_ENV = 'test';
mockIntegrations.findOneByIdAndToken.mockResolvedValue({ ...INTEGRATION });
mockUsers.findOneById.mockResolvedValue({ ...ASSIGNED_USER });
mockHasPermissionAsync.mockResolvedValue(true);
mockProcessWebhookMessage.mockResolvedValue([{ _id: 'message-id' }]);
});
describe('authentication and routing', () => {
it('accepts a POST for a valid integration id and token', async () => {
const response = await request(app).post(HOOK_URL).send({ text: 'hello' });
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ success: true });
expect(mockIntegrations.findOneByIdAndToken).toHaveBeenCalledWith('integration-id', 'the-token');
});
it('accepts a GET for a valid integration id and token', async () => {
const response = await request(app).get(`${HOOK_URL}?text=hello`);
expect(response.statusCode).toBe(200);
expect(mockProcessWebhookMessage).not.toHaveBeenCalled();
});
it('accepts the legacy route carrying a userId segment', async () => {
const response = await request(app).post(LEGACY_HOOK_URL).send({ text: 'hello' });
expect(response.statusCode).toBe(200);
expect(mockIntegrations.findOneByIdAndToken).toHaveBeenCalledWith('integration-id', 'the-token');
expect(mockProcessWebhookMessage).toHaveBeenCalledTimes(1);
});
it('url-decodes the token before looking the integration up', async () => {
await request(app).post('/hooks/integration-id/token%2520with%2520spaces').send({ text: 'hello' });
expect(mockIntegrations.findOneByIdAndToken).toHaveBeenCalledWith('integration-id', 'token with spaces');
});
// 500 is the current behavior and it is wrong: authenticatedRoute throws instead of returning null, so this never reaches the 401 path.
it('rejects an unknown integration id or token without processing anything', async () => {
mockIntegrations.findOneByIdAndToken.mockResolvedValue(null);
const response = await request(app).post(HOOK_URL).send({ text: 'hello' });
expect(response.statusCode).toBe(500);
expect(mockProcessWebhookMessage).not.toHaveBeenCalled();
});
it('rejects the request when the integration has no assigned user', async () => {
mockUsers.findOneById.mockResolvedValue(null);
const response = await request(app).post(HOOK_URL).send({ text: 'hello' });
expect(response.statusCode).toBe(401);
expect(mockProcessWebhookMessage).not.toHaveBeenCalled();
});
it('impersonates the user assigned to the integration', async () => {
mockUsers.findOneById.mockResolvedValue({ _id: 'other-user', username: 'other', name: 'Other' });
await request(app).post(HOOK_URL).send({ text: 'hello' });
expect(mockUsers.findOneById).toHaveBeenCalledWith('assigned-user-id');
expect(mockProcessWebhookMessage).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ _id: 'other-user' }),
expect.anything(),
);
});
});
describe('request body handling', () => {
it('forwards a plain JSON body untouched', async () => {
await request(app)
.post(HOOK_URL)
.send({ text: 'hello', attachments: [{ title: 't' }] });
expect(mockProcessWebhookMessage).toHaveBeenCalledWith(
expect.objectContaining({ text: 'hello', attachments: [{ title: 't' }] }),
expect.anything(),
expect.anything(),
);
});
it('unwraps a url-encoded `payload` field holding JSON', async () => {
await request(app)
.post(HOOK_URL)
.type('form')
.send({ payload: JSON.stringify({ text: 'from payload' }) });
expect(mockProcessWebhookMessage).toHaveBeenCalledWith(
expect.objectContaining({ text: 'from payload' }),
expect.anything(),
expect.anything(),
);
});
it('keeps the original body when the url-encoded `payload` is not valid JSON', async () => {
await request(app).post(HOOK_URL).type('form').send({ payload: '{not json' });
expect(mockProcessWebhookMessage).toHaveBeenCalledWith(
expect.objectContaining({ payload: '{not json' }),
expect.anything(),
expect.anything(),
);
});
it.each([
['an array', JSON.stringify([{ text: 'hello' }])],
['a primitive', '42'],
])('rejects a url-encoded `payload` holding %s', async (_label, payload) => {
const response = await request(app).post(HOOK_URL).type('form').send({ payload });
expect(response.statusCode).toBe(400);
expect(response.body.error).toBe('Integration payload must be a JSON object, not an array or primitive');
expect(mockProcessWebhookMessage).not.toHaveBeenCalled();
});
it('does not unwrap `payload` when the url-encoded body carries other fields', async () => {
await request(app)
.post(HOOK_URL)
.type('form')
.send({ payload: JSON.stringify({ text: 'from payload' }), token: 'abc' });
expect(mockProcessWebhookMessage).toHaveBeenCalledWith(
expect.objectContaining({ payload: JSON.stringify({ text: 'from payload' }), token: 'abc' }),
expect.anything(),
expect.anything(),
);
});
it('does not unwrap `payload` when the content type is JSON', async () => {
await request(app)
.post(HOOK_URL)
.send({ payload: JSON.stringify({ text: 'from payload' }) });
expect(mockProcessWebhookMessage).toHaveBeenCalledWith(
expect.objectContaining({ payload: JSON.stringify({ text: 'from payload' }) }),
expect.anything(),
expect.anything(),
);
});
it('accepts an empty body without producing a message', async () => {
const response = await request(app).post(HOOK_URL);
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ success: true });
expect(mockProcessWebhookMessage).not.toHaveBeenCalled();
});
it('discards a JSON body that is not an object', async () => {
const response = await request(app)
.post(HOOK_URL)
.send([{ text: 'hello' }]);
expect(response.statusCode).toBe(200);
expect(mockProcessWebhookMessage).not.toHaveBeenCalled();
});
it('still processes an empty body when the integration has scripting enabled', async () => {
mockIntegrations.findOneByIdAndToken.mockResolvedValue({ ...INTEGRATION, scriptEnabled: true });
const response = await request(app).post(HOOK_URL);
expect(response.statusCode).toBe(200);
expect(mockProcessWebhookMessage).toHaveBeenCalledTimes(1);
});
});
describe('integration execution', () => {
it('reports the service as unavailable when the integration is disabled', async () => {
mockIntegrations.findOneByIdAndToken.mockResolvedValue({ ...INTEGRATION, enabled: false });
const response = await request(app).post(HOOK_URL).send({ text: 'hello' });
expect(response.statusCode).toBe(503);
expect(response.body).toEqual({ success: false, error: 'Service Unavailable' });
expect(mockProcessWebhookMessage).not.toHaveBeenCalled();
});
it('refuses to post when the assigned user lacks message-impersonate', async () => {
mockHasPermissionAsync.mockResolvedValue(false);
const response = await request(app).post(HOOK_URL).send({ text: 'hello' });
expect(response.statusCode).toBe(400);
expect(response.body.error).toBe('error-user-lacks-message-impersonate-permission');
expect(mockProcessWebhookMessage).not.toHaveBeenCalled();
});
it('sends the integration defaults and bot marker along with the message', async () => {
await request(app).post(HOOK_URL).send({ text: 'hello' });
expect(mockProcessWebhookMessage).toHaveBeenCalledWith(
expect.objectContaining({ text: 'hello', bot: { i: 'integration-id' } }),
expect.objectContaining({ _id: 'assigned-user-id' }),
{ channel: ['#general'], alias: 'alias', avatar: 'avatar-url', emoji: ':ghost:' },
);
});
it('falls back to empty defaults when the integration sets no alias, avatar or emoji', async () => {
mockIntegrations.findOneByIdAndToken.mockResolvedValue({
...INTEGRATION,
alias: undefined,
avatar: undefined,
emoji: undefined,
});
await request(app).post(HOOK_URL).send({ text: 'hello' });
expect(mockProcessWebhookMessage).toHaveBeenCalledWith(expect.anything(), expect.anything(), {
channel: ['#general'],
alias: '',
avatar: '',
emoji: '',
});
});
it('refuses a `channel` override when overriding is disabled', async () => {
const response = await request(app).post(HOOK_URL).send({ text: 'hello', channel: '#other' });
expect(response.statusCode).toBe(400);
expect(response.body.error).toBe('overriding destination channel is disabled for this integration');
expect(mockProcessWebhookMessage).not.toHaveBeenCalled();
});
it('refuses a `roomId` override when overriding is disabled', async () => {
const response = await request(app).post(HOOK_URL).send({ text: 'hello', roomId: 'room-id' });
expect(response.statusCode).toBe(400);
expect(response.body.error).toBe('overriding destination channel is disabled for this integration');
expect(mockProcessWebhookMessage).not.toHaveBeenCalled();
});
it('forwards a `channel` override when the integration allows it', async () => {
mockIntegrations.findOneByIdAndToken.mockResolvedValue({ ...INTEGRATION, overrideDestinationChannelEnabled: true });
const response = await request(app).post(HOOK_URL).send({ text: 'hello', channel: '#other' });
expect(response.statusCode).toBe(200);
expect(mockProcessWebhookMessage).toHaveBeenCalledWith(
expect.objectContaining({ channel: '#other' }),
expect.anything(),
expect.anything(),
);
});
it('fails when message processing produces no message', async () => {
mockProcessWebhookMessage.mockResolvedValue([]);
const response = await request(app).post(HOOK_URL).send({ text: 'hello' });
expect(response.statusCode).toBe(400);
expect(response.body.error).toBe('unknown-error');
});
it('surfaces the error code when message processing rejects with one', async () => {
mockProcessWebhookMessage.mockRejectedValue(Object.assign(new Error('boom'), { error: 'error-invalid-room' }));
const response = await request(app).post(HOOK_URL).send({ text: 'hello' });
expect(response.statusCode).toBe(400);
expect(response.body.error).toBe('error-invalid-room');
});
it('surfaces the message when message processing rejects without an error code', async () => {
mockProcessWebhookMessage.mockRejectedValue(new Error('something exploded'));
const response = await request(app).post(HOOK_URL).send({ text: 'hello' });
expect(response.statusCode).toBe(400);
expect(response.body.error).toBe('something exploded');
});
it('reports an unknown error when message processing rejects with no detail', async () => {
mockProcessWebhookMessage.mockRejectedValue({});
const response = await request(app).post(HOOK_URL).send({ text: 'hello' });
expect(response.statusCode).toBe(400);
expect(response.body.error).toBe('Unknown error');
});
});
// The real engine caches compiled scripts per integration id and _updatedAt; reusing a pair silently runs the previous script.
describe('integration scripts', () => {
let scriptSeq = 0;
const withScript = (body: string, overrides: Record<string, unknown> = {}): void => {
scriptSeq += 1;
mockIntegrations.findOneByIdAndToken.mockResolvedValue({
...INTEGRATION,
_id: `integration-${scriptSeq}`,
_updatedAt: new Date(scriptSeq * 1000),
scriptEnabled: true,
script: '',
scriptCompiled: `class Script { process_incoming_request({ request }) { ${body} } }`,
...overrides,
});
};
it('posts the payload the script produced instead of the original one', async () => {
withScript("return { content: { text: 'rewritten by script' } };");
const response = await request(app).post(HOOK_URL).send({ text: 'original' });
expect(response.statusCode).toBe(200);
expect(mockProcessWebhookMessage).toHaveBeenCalledWith(
expect.objectContaining({ text: 'rewritten by script' }),
expect.anything(),
expect.anything(),
);
});
it('hands the script the request payload, raw content and context', async () => {
withScript(
'return { content: { text: "x", seen: { content: request.content, content_raw: request.content_raw, url_params: request.url_params, pathname: request.url.pathname, query: request.url.query, host: request.headers.host, user: request.user } } };',
);
await request(app).post(`${HOOK_URL}?a=1`).set('host', 'example.com').send({ text: 'hello' });
expect(mockProcessWebhookMessage).toHaveBeenCalledWith(
expect.objectContaining({
seen: {
content: { text: 'hello' },
content_raw: JSON.stringify({ text: 'hello' }),
url_params: { integrationId: 'integration-id', token: 'the-token' },
pathname: HOOK_URL,
query: { a: '1' },
host: 'example.com',
user: { _id: 'assigned-user-id', name: 'Rocket Cat', username: 'rocket.cat' },
},
}),
expect.anything(),
expect.anything(),
);
});
it('leaves the payload alone when the integration has no compiled script', async () => {
withScript('return { content: { text: "rewritten by script" } };', { scriptCompiled: undefined });
await request(app).post(HOOK_URL).send({ text: 'original' });
expect(mockProcessWebhookMessage).toHaveBeenCalledWith(
expect.objectContaining({ text: 'original' }),
expect.anything(),
expect.anything(),
);
});
it('leaves the payload alone for a GET request, which carries no body', async () => {
withScript('return { content: { text: "rewritten by script" } };');
const response = await request(app).get(`${HOOK_URL}?text=hello`);
expect(response.statusCode).toBe(200);
expect(mockProcessWebhookMessage).toHaveBeenCalledWith(
{ bot: { i: `integration-${scriptSeq}` } },
expect.anything(),
expect.anything(),
);
});
it('accepts the request without posting when the script returns nothing', async () => {
withScript('return undefined;');
const response = await request(app).post(HOOK_URL).send({ text: 'hello' });
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ success: true });
expect(mockProcessWebhookMessage).not.toHaveBeenCalled();
});
it('propagates an error reported by the script', async () => {
withScript("return { error: 'script-said-no' };");
const response = await request(app).post(HOOK_URL).send({ text: 'hello' });
expect(response.statusCode).toBe(400);
expect(response.body.error).toBe('script-said-no');
expect(mockProcessWebhookMessage).not.toHaveBeenCalled();
});
it('fails when the script returns no usable payload', async () => {
withScript('return { content: undefined };');
const response = await request(app).post(HOOK_URL).send({ text: 'hello' });
expect(response.statusCode).toBe(400);
expect(response.body.error).toBe('error-running-script');
expect(mockProcessWebhookMessage).not.toHaveBeenCalled();
});
it('fails when running the script throws', async () => {
withScript("throw new Error('boom');");
const response = await request(app).post(HOOK_URL).send({ text: 'hello' });
expect(response.statusCode).toBe(400);
expect(response.body.error).toBe('error-running-script');
expect(mockProcessWebhookMessage).not.toHaveBeenCalled();
});
it('keeps the requested separateResponse when the script does not set one', async () => {
withScript("return { content: { text: 'from script' } };");
const response = await request(app).post(HOOK_URL).send({ text: 'original', separateResponse: true });
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ responses: [{ _id: 'message-id' }], success: true });
});
it('lets the script turn separateResponse off', async () => {
withScript("return { content: { text: 'from script', separateResponse: false } };");
const response = await request(app).post(HOOK_URL).send({ text: 'original', separateResponse: true });
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ success: true });
});
it('posts as the user the script returned', async () => {
withScript("return { content: { text: 'hello' }, user: { _id: 'script-user', username: 'script.user' } };");
await request(app).post(HOOK_URL).send({ text: 'hello' });
expect(mockProcessWebhookMessage).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ _id: 'script-user' }),
expect.anything(),
);
});
it('returns the custom response the script produced', async () => {
withScript("return { content: { text: 'hello' }, response: { custom: 'body' } };");
const response = await request(app).post(HOOK_URL).send({ text: 'hello' });
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ custom: 'body', success: true });
});
});
describe('separate responses', () => {
it('returns every delivery when at least one succeeded', async () => {
mockProcessWebhookMessage.mockResolvedValue([{ _id: 'ok' }, { error: 'error-invalid-room' }]);
const response = await request(app).post(HOOK_URL).send({ text: 'hello', separateResponse: true });
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ responses: [{ _id: 'ok' }, { error: 'error-invalid-room' }], success: true });
});
it('fails when every delivery failed', async () => {
mockProcessWebhookMessage.mockResolvedValue([{ error: 'error-invalid-room' }, { error: 'error-not-allowed' }]);
const response = await request(app).post(HOOK_URL).send({ text: 'hello', separateResponse: true });
expect(response.statusCode).toBe(400);
expect(response.body).toEqual({
responses: [{ error: 'error-invalid-room' }, { error: 'error-not-allowed' }],
success: false,
});
});
});
// Only the legacy userId routes are reachable: :integrationId/:userId/:token is registered first and swallows the two-segment variants.
describe('sample and info endpoints', () => {
it('returns sample messages', async () => {
const response = await request(app).get('/hooks/sample/integration-id/legacy-user-id/the-token');
expect(response.statusCode).toBe(200);
expect(response.body).toHaveLength(3);
expect(response.body[0]).toEqual(
expect.objectContaining({ channel_name: 'general', user_name: 'rocket.cat', text: 'Sample text 1', trigger_word: 'Sample' }),
);
});
it('reports integration info', async () => {
const response = await request(app).get('/hooks/info/integration-id/legacy-user-id/the-token');
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ success: true });
});
});
describe('creating outgoing integrations', () => {
beforeEach(() => {
mockAddOutgoingIntegration.mockResolvedValue({ _id: 'created-id' });
});
it('creates a channel integration, prefixing the channel name with #', async () => {
const response = await request(app)
.post('/hooks/add/integration-id/legacy-user-id/the-token')
.send({
event: 'newMessageOnChannel',
name: 'my hook',
target_url: 'https://example.com/hook',
data: { channel_name: 'general' },
});
expect(response.statusCode).toBe(200);
expect(mockAddOutgoingIntegration).toHaveBeenCalledWith(
'assigned-user-id',
expect.objectContaining({ channel: '#general', name: 'my hook', urls: ['https://example.com/hook'], enabled: true }),
);
});
it('keeps a channel name that already starts with #', async () => {
await request(app)
.post('/hooks/add/integration-id/legacy-user-id/the-token')
.send({
event: 'newMessageOnChannel',
name: 'my hook',
target_url: 'https://example.com/hook',
data: { channel_name: '#general' },
});
expect(mockAddOutgoingIntegration).toHaveBeenCalledWith('assigned-user-id', expect.objectContaining({ channel: '#general' }));
});
it('creates a channel integration with no channel when none was given', async () => {
await request(app)
.post('/hooks/add/integration-id/legacy-user-id/the-token')
.send({ event: 'newMessageOnChannel', name: 'my hook', target_url: 'https://example.com/hook' });
expect(mockAddOutgoingIntegration).toHaveBeenCalledWith('assigned-user-id', expect.objectContaining({ channel: undefined }));
});
it('creates a user integration, prefixing the username with @', async () => {
await request(app)
.post('/hooks/add/integration-id/legacy-user-id/the-token')
.send({
event: 'newMessageToUser',
name: 'my hook',
target_url: 'https://example.com/hook',
data: { username: 'rocket.cat' },
});
expect(mockAddOutgoingIntegration).toHaveBeenCalledWith('assigned-user-id', expect.objectContaining({ channel: '@rocket.cat' }));
});
it('keeps a username that already starts with @', async () => {
await request(app)
.post('/hooks/add/integration-id/legacy-user-id/the-token')
.send({
event: 'newMessageToUser',
name: 'my hook',
target_url: 'https://example.com/hook',
data: { username: '@rocket.cat' },
});
expect(mockAddOutgoingIntegration).toHaveBeenCalledWith('assigned-user-id', expect.objectContaining({ channel: '@rocket.cat' }));
});
it('refuses a user integration with no username', async () => {
const response = await request(app)
.post('/hooks/add/integration-id/legacy-user-id/the-token')
.send({ event: 'newMessageToUser', name: 'my hook', target_url: 'https://example.com/hook' });
expect(response.statusCode).toBe(400);
expect(response.body.error).toBe('username-required');
expect(mockAddOutgoingIntegration).not.toHaveBeenCalled();
});
it('refuses an unsupported event', async () => {
const response = await request(app)
.post('/hooks/add/integration-id/legacy-user-id/the-token')
.send({ event: 'somethingElse', name: 'my hook', target_url: 'https://example.com/hook' });
expect(response.statusCode).toBe(400);
expect(mockAddOutgoingIntegration).not.toHaveBeenCalled();
});
});
describe('removing outgoing integrations', () => {
it('removes the integration registered for the given url', async () => {
mockIntegrations.findOneByUrl.mockResolvedValue({ _id: 'outgoing-id' });
const response = await request(app)
.post('/hooks/remove/integration-id/legacy-user-id/the-token')
.send({ target_url: 'https://example.com/hook' });
expect(response.statusCode).toBe(200);
expect(mockIntegrations.findOneByUrl).toHaveBeenCalledWith('https://example.com/hook');
expect(mockDeleteOutgoingIntegration).toHaveBeenCalledWith('outgoing-id', 'assigned-user-id');
});
it('reports integration-not-found and deletes nothing when no integration matches the url', async () => {
mockIntegrations.findOneByUrl.mockResolvedValue(null);
const response = await request(app)
.post('/hooks/remove/integration-id/legacy-user-id/the-token')
.send({ target_url: 'https://example.com/missing' });
expect(JSON.stringify(response.body)).toContain('integration-not-found');
expect(mockDeleteOutgoingIntegration).not.toHaveBeenCalled();
});
});
describe('rate limiting', () => {
const enableRateLimiter = (overrides: Record<string, unknown> = {}): void => {
mockSettingsStore.set('API_Enable_Rate_Limiter', true);
Object.entries(overrides).forEach(([key, value]) => mockSettingsStore.set(key, value));
};
const post = (url: string, text: string) => request(app).post(url).send({ text });
it('rejects requests over the limit', async () => {
enableRateLimiter();
const first = await post(HOOK_URL, 'a');
const second = await post(HOOK_URL, 'b');
expect(first.statusCode).toBe(200);
expect(second.statusCode).toBe(429);
expect(second.body.error).toMatch(/too many requests/i);
});
it('does not limit requests while the rate limiter is disabled', async () => {
const first = await post(HOOK_URL, 'a');
const second = await post(HOOK_URL, 'b');
const third = await post(HOOK_URL, 'c');
expect(first.statusCode).toBe(200);
expect(second.statusCode).toBe(200);
expect(third.statusCode).toBe(200);
});
it('does not limit requests in development unless the dev rate limiter is enabled', async () => {
process.env.NODE_ENV = 'development';
enableRateLimiter({ API_Enable_Rate_Limiter_Dev: false });
const first = await post(HOOK_URL, 'a');
const second = await post(HOOK_URL, 'b');
expect(first.statusCode).toBe(200);
expect(second.statusCode).toBe(200);
});
it('limits requests in development when the dev rate limiter is enabled', async () => {
process.env.NODE_ENV = 'development';
enableRateLimiter({ API_Enable_Rate_Limiter_Dev: true });
const first = await post(HOOK_URL, 'a');
const second = await post(HOOK_URL, 'b');
expect(first.statusCode).toBe(200);
expect(second.statusCode).toBe(429);
});
it('gives each webhook url its own budget', async () => {
enableRateLimiter();
const firstOnA = await post('/hooks/integration-a/the-token', 'a');
const firstOnB = await post('/hooks/integration-b/the-token', 'b');
const firstOnLegacy = await post(LEGACY_HOOK_URL, 'c');
const secondOnA = await post('/hooks/integration-a/the-token', 'd');
expect(firstOnA.statusCode).toBe(200);
expect(firstOnB.statusCode).toBe(200);
expect(firstOnLegacy.statusCode).toBe(200);
expect(secondOnA.statusCode).toBe(429);
});
});
});