diff --git a/package.json b/package.json index 7cbec5bbe..0c339b3bc 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,9 @@ "build": "tsc", "build:clean": "shx rm -rf ./dist ./coverage ./.nyc_output", "lint": "tslint --project .", + "test-lint": "tslint \"src/**/*.spec.ts\" && tslint \"src/test-helpers.ts\"", "mocha": "nyc mocha --config .mocharc.json \"src/**/*.spec.ts\"", - "test": "npm run lint && npm run mocha && npm run test:integration", + "test": "npm run lint && npm run test-lint && npm run mocha && npm run test:integration", "test:integration": "cd integration-tests && npm install && npm test", "coverage": "codecov" }, diff --git a/src/App.spec.ts b/src/App.spec.ts index eac6497a4..a063c968d 100644 --- a/src/App.spec.ts +++ b/src/App.spec.ts @@ -9,6 +9,7 @@ import { ErrorCode } from './errors'; import { Receiver, ReceiverEvent, SayFn, NextMiddleware } from './types'; import { ConversationStore } from './conversation-store'; import { LogLevel } from '@slack/logger'; +import { ViewConstraints } from './App'; describe('App', () => { describe('constructor', () => { @@ -157,23 +158,25 @@ describe('App', () => { }); }); it('with clientOptions', async () => { - const fakeConstructor = sinon.fake() + const fakeConstructor = sinon.fake(); const overrides = mergeOverrides( withNoopAppMetadata(), { '@slack/web-api': { WebClient: class { constructor() { - fakeConstructor(...arguments) + fakeConstructor(...arguments); } }, - } - } - ) + }, + }, + ); + // tslint:disable-next-line: variable-name const App = await importApp(overrides); const clientOptions = { slackApiUrl: 'proxy.slack.com' }; - new App({ authorize: noopAuthorize, signingSecret: '', logLevel: LogLevel.ERROR, clientOptions }); + // tslint:disable-next-line: no-unused-expression + new App({ clientOptions, authorize: noopAuthorize, signingSecret: '', logLevel: LogLevel.ERROR }); assert.ok(fakeConstructor.called); @@ -181,7 +184,7 @@ describe('App', () => { assert.strictEqual(undefined, token, 'token should be undefined'); assert.strictEqual(clientOptions.slackApiUrl, options.slackApiUrl); assert.strictEqual(LogLevel.ERROR, options.logLevel, 'override logLevel'); - }) + }); // TODO: tests for ignoreSelf option // TODO: tests for logger and logLevel option // TODO: tests for providing botId and botUserId options @@ -322,7 +325,7 @@ describe('App', () => { const dummyChannelId = 'CHANNEL_ID'; let overrides: Override; - function buildOverrides(secondOverride: Override) { + function buildOverrides(secondOverride: Override): Override { fakeReceiver = createFakeReceiver(); fakeErrorHandler = sinon.fake(); dummyAuthorizationResult = { botToken: '', botId: '' }; @@ -330,7 +333,7 @@ describe('App', () => { withNoopAppMetadata(), secondOverride, withMemoryStore(sinon.fake()), - withConversationContext(sinon.fake.returns(noopMiddleware)) + withConversationContext(sinon.fake.returns(noopMiddleware)), ); return overrides; } @@ -357,7 +360,7 @@ describe('App', () => { body: { type: 'block_actions', actions: [{ - action_id: 'block_action_id' + action_id: 'block_action_id', }], channel: {}, user: {}, @@ -461,6 +464,22 @@ describe('App', () => { respond: noop, ack: noop, }, + { + body: { + type: 'event_callback', + token: 'XXYYZZ', + team_id: 'TXXXXXXXX', + api_app_id: 'AXXXXXXXXX', + event: { + type: 'message', + event_ts: '1234567890.123456', + user: 'UXXXXXXX1', + text: 'hello friends!', + }, + }, + respond: noop, + ack: noop, + }, ]; } @@ -475,11 +494,19 @@ describe('App', () => { const dummyReceiverEvents = createReceiverEvents(); // Act - const app = new App({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); + const fakeLogger = createFakeLogger(); + const app = new App({ + logger: fakeLogger, + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + app.use((_args) => { ackFn(); }); app.action('block_action_id', ({ }) => { actionFn(); }); app.action({ callback_id: 'message_action_callback_id' }, ({ }) => { actionFn(); }); - app.action({ type: 'message_action', callback_id: 'another_message_action_callback_id' }, ({ }) => { actionFn(); }); + app.action( + { type: 'message_action', callback_id: 'another_message_action_callback_id' }, + ({ }) => { actionFn(); }); app.action({ type: 'message_action', callback_id: 'does_not_exist' }, ({ }) => { actionFn(); }); app.action({ callback_id: 'interactive_message_callback_id' }, ({ }) => { actionFn(); }); app.action({ callback_id: 'dialog_submission_callback_id' }, ({ }) => { actionFn(); }); @@ -488,6 +515,29 @@ describe('App', () => { app.options('external_select_action_id', ({ }) => { optionsFn(); }); app.options({ callback_id: 'dialog_suggestion_callback_id' }, ({ }) => { optionsFn(); }); + app.event('app_home_opened', ({ }) => { /* noop */ }); + app.message('hello', ({ }) => { /* noop */ }); + app.command('/echo', ({ }) => { /* noop */ }); + + // invalid view constraints + const invalidViewConstraints1 = { + callback_id: 'foo', + type: 'view_submission', + unknown_key: 'should be detected', + } as any as ViewConstraints; + app.view(invalidViewConstraints1, ({ }) => { /* noop */ }); + assert.isTrue(fakeLogger.error.called); + + fakeLogger.error = sinon.fake(); + + const invalidViewConstraints2 = { + callback_id: 'foo', + type: undefined, + unknown_key: 'should be detected', + } as any as ViewConstraints; + app.view(invalidViewConstraints2, ({ }) => { /* noop */ }); + assert.isTrue(fakeLogger.error.called); + app.error(fakeErrorHandler); dummyReceiverEvents.forEach(dummyEvent => fakeReceiver.emit('message', dummyEvent)); await delay(); diff --git a/src/ExpressReceiver.spec.ts b/src/ExpressReceiver.spec.ts index c7a75e64d..3b3a58621 100644 --- a/src/ExpressReceiver.spec.ts +++ b/src/ExpressReceiver.spec.ts @@ -1,34 +1,145 @@ // tslint:disable:no-implicit-dependencies import 'mocha'; + +import { Logger, LogLevel } from '@slack/logger'; import { assert } from 'chai'; import { Request, Response } from 'express'; -import { verifySignatureAndParseBody } from './ExpressReceiver'; +import { Agent } from 'http'; import sinon, { SinonFakeTimers } from 'sinon'; import { Readable } from 'stream'; -import { Logger, LogLevel } from '@slack/logger'; + +import ExpressReceiver, { + respondToSslCheck, + respondToUrlVerification, + verifySignatureAndParseBody, +} from './ExpressReceiver'; describe('ExpressReceiver', () => { const noopLogger: Logger = { - debug(..._msg: any[]): void { }, - info(..._msg: any[]): void { }, - warn(..._msg: any[]): void { }, - error(..._msg: any[]): void { }, - setLevel(_level: LogLevel): void { }, + debug(..._msg: any[]): void { /* noop */ }, + info(..._msg: any[]): void { /* noop */ }, + warn(..._msg: any[]): void { /* noop */ }, + error(..._msg: any[]): void { /* noop */ }, + setLevel(_level: LogLevel): void { /* noop */ }, getLevel(): LogLevel { return LogLevel.DEBUG; }, - setName(_name: string): void { }, + setName(_name: string): void { /* noop */ }, }; + describe('constructor', () => { + it('should accept supported arguments', async () => { + const receiver = new ExpressReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + endpoints: { events: '/custom-endpoint' }, + agent: new Agent({ + maxSockets: 999, + }), + clientTls: undefined, + }); + assert.isNotNull(receiver); + }); + }); + + describe('start/stop', () => { + it('should be available', async () => { + const receiver = new ExpressReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + await receiver.start(9999); + await receiver.stop(); + }); + }); + + describe('built-in middleware', () => { + describe('ssl_check requset handler', () => { + it('should handle valid requests', async () => { + // Arrange + // tslint:disable-next-line: no-object-literal-type-assertion + const req = { body: { ssl_check: 1 } } as Request; + let sent = false; + // tslint:disable-next-line: no-object-literal-type-assertion + const resp = { send: () => { sent = true; } } as Response; + let errorResult: any; + const next = (error: any) => { errorResult = error; }; + + // Act + respondToSslCheck(req, resp, next); + + // Assert + assert.isTrue(sent); + assert.isUndefined(errorResult); + }); + + it('should work with other requests', async () => { + // Arrange + // tslint:disable-next-line: no-object-literal-type-assertion + const req = { body: { type: 'block_actions' } } as Request; + let sent = false; + // tslint:disable-next-line: no-object-literal-type-assertion + const resp = { send: () => { sent = true; } } as Response; + let errorResult: any; + const next = (error: any) => { errorResult = error; }; + + // Act + respondToSslCheck(req, resp, next); + + // Assert + assert.isFalse(sent); + assert.isUndefined(errorResult); + }); + }); + + describe('url_verification requset handler', () => { + it('should handle valid requests', async () => { + // Arrange + // tslint:disable-next-line: no-object-literal-type-assertion + const req = { body: { type: 'url_verification', challenge: 'this is it' } } as Request; + let sentBody = undefined; + // tslint:disable-next-line: no-object-literal-type-assertion + const resp = { json: (body) => { sentBody = body; } } as Response; + let errorResult: any; + const next = (error: any) => { errorResult = error; }; + + // Act + respondToUrlVerification(req, resp, next); + + // Assert + assert.equal(JSON.stringify({ challenge: 'this is it' }), JSON.stringify(sentBody)); + assert.isUndefined(errorResult); + }); + + it('should work with other requests', async () => { + // Arrange + // tslint:disable-next-line: no-object-literal-type-assertion + const req = { body: { ssl_check: 1 } } as Request; + let sentBody = undefined; + // tslint:disable-next-line: no-object-literal-type-assertion + const resp = { json: (body) => { sentBody = body; } } as Response; + let errorResult: any; + const next = (error: any) => { errorResult = error; }; + + // Act + respondToUrlVerification(req, resp, next); + + // Assert + assert.isUndefined(sentBody); + assert.isUndefined(errorResult); + }); + }); + }); + describe('verifySignatureAndParseBody', () => { let clock: SinonFakeTimers; - beforeEach(function () { + beforeEach(() => { // requestTimestamp = 1531420618 means this timestamp clock = sinon.useFakeTimers(new Date('Thu Jul 12 2018 11:36:58 GMT-0700').getTime()); }); - afterEach(function () { + afterEach(() => { clock.restore(); }); @@ -45,7 +156,8 @@ describe('ExpressReceiver', () => { reqAsStream.push(null); // indicate EOF (reqAsStream as { [key: string]: any }).headers = { 'x-slack-signature': signature, - 'x-slack-request-timestamp': requestTimestamp + 'x-slack-request-timestamp': requestTimestamp, + 'content-type': 'application/x-www-form-urlencoded', }; const req = reqAsStream as Request; return req; @@ -56,8 +168,9 @@ describe('ExpressReceiver', () => { rawBody: body, headers: { 'x-slack-signature': signature, - 'x-slack-request-timestamp': requestTimestamp - } + 'x-slack-request-timestamp': requestTimestamp, + 'content-type': 'application/x-www-form-urlencoded', + }, }; const req = untypedReq as Request; return req; @@ -66,29 +179,50 @@ describe('ExpressReceiver', () => { // ---------------------------- // runWithValidRequest - async function runWithValidRequest(req: Request, errorResult: any) { + async function runWithValidRequest(req: Request, state: any): Promise { // Arrange + // tslint:disable-next-line: no-object-literal-type-assertion const resp = {} as Response; - const next = (error: any) => { errorResult = error; }; + const next = (error: any) => { state.error = error; }; // Act const verifier = verifySignatureAndParseBody(noopLogger, signingSecret); await verifier(req, resp, next); - return errorResult; } it('should verify requests', async () => { - let errorResult: any; - runWithValidRequest(buildExpressRequest(), errorResult); + const state: any = {}; + await runWithValidRequest(buildExpressRequest(), state); // Assert - assert.isUndefined(errorResult); + assert.isUndefined(state.error); }); it('should verify requests on GCP', async () => { - let errorResult: any; - runWithValidRequest(buildGCPRequest(), errorResult); + const state: any = {}; + await runWithValidRequest(buildGCPRequest(), state); + // Assert + assert.isUndefined(state.error); + }); + + // ---------------------------- + // parse error + + it('should verify requests and then catch parse failures', async () => { + const state: any = {}; + const req = buildExpressRequest(); + req.headers['content-type'] = undefined; + await runWithValidRequest(req, state); // Assert - assert.isUndefined(errorResult); + assert.equal(state.error, 'SyntaxError: Unexpected token o in JSON at position 1'); + }); + + it('should verify requests on GCP and then catch parse failures', async () => { + const state: any = {}; + const req = buildGCPRequest(); + req.headers['content-type'] = undefined; + await runWithValidRequest(req, state); + // Assert + assert.equal(state.error, 'SyntaxError: Unexpected token o in JSON at position 1'); }); // ---------------------------- @@ -96,6 +230,7 @@ describe('ExpressReceiver', () => { function verifyMissingHeaderDetection(req: Request): Promise { // Arrange + // tslint:disable-next-line: no-object-literal-type-assertion const resp = {} as Response; let errorResult: any; const next = (error: any) => { errorResult = error; }; @@ -105,7 +240,7 @@ describe('ExpressReceiver', () => { return verifier(req, resp, next).then((_: any) => { // Assert assert.equal(errorResult, 'Error: Slack request signing verification failed. Some headers are missing.'); - }) + }); } it('should detect headers missing signature', async () => { @@ -114,7 +249,7 @@ describe('ExpressReceiver', () => { reqAsStream.push(null); // indicate EOF (reqAsStream as { [key: string]: any }).headers = { // 'x-slack-signature': signature , - 'x-slack-request-timestamp': requestTimestamp + 'x-slack-request-timestamp': requestTimestamp, }; await verifyMissingHeaderDetection(reqAsStream as Request); }); @@ -124,8 +259,8 @@ describe('ExpressReceiver', () => { reqAsStream.push(body); reqAsStream.push(null); // indicate EOF (reqAsStream as { [key: string]: any }).headers = { - 'x-slack-signature': signature /* , - 'x-slack-request-timestamp': requestTimestamp*/ + 'x-slack-signature': signature, + /*'x-slack-request-timestamp': requestTimestamp*/ }; await verifyMissingHeaderDetection(reqAsStream as Request); }); @@ -134,9 +269,9 @@ describe('ExpressReceiver', () => { const untypedReq: { [key: string]: any } = { rawBody: body, headers: { - 'x-slack-signature': signature /*, - 'x-slack-request-timestamp': requestTimestamp */ - } + 'x-slack-signature': signature, + /*'x-slack-request-timestamp': requestTimestamp */ + }, }; await verifyMissingHeaderDetection(untypedReq as Request); }); @@ -146,6 +281,7 @@ describe('ExpressReceiver', () => { function verifyInvalidTimestampError(req: Request): Promise { // Arrange + // tslint:disable-next-line: no-object-literal-type-assertion const resp = {} as Response; let errorResult: any; const next = (error: any) => { errorResult = error; }; @@ -177,6 +313,7 @@ describe('ExpressReceiver', () => { // restore the valid clock clock.restore(); + // tslint:disable-next-line: no-object-literal-type-assertion const resp = {} as Response; let errorResult: any; const next = (error: any) => { errorResult = error; }; @@ -202,6 +339,7 @@ describe('ExpressReceiver', () => { function verifySignatureMismatch(req: Request): Promise { // Arrange + // tslint:disable-next-line: no-object-literal-type-assertion const resp = {} as Response; let errorResult: any; const next = (error: any) => { errorResult = error; }; @@ -221,7 +359,7 @@ describe('ExpressReceiver', () => { reqAsStream.push(null); // indicate EOF (reqAsStream as { [key: string]: any }).headers = { 'x-slack-signature': signature, - 'x-slack-request-timestamp': requestTimestamp + 10 + 'x-slack-request-timestamp': requestTimestamp + 10, }; const req = reqAsStream as Request; await verifySignatureMismatch(req); @@ -232,8 +370,8 @@ describe('ExpressReceiver', () => { rawBody: body, headers: { 'x-slack-signature': signature, - 'x-slack-request-timestamp': requestTimestamp + 10 - } + 'x-slack-request-timestamp': requestTimestamp + 10, + }, }; const req = untypedReq as Request; await verifySignatureMismatch(req); @@ -241,4 +379,4 @@ describe('ExpressReceiver', () => { }); -}); \ No newline at end of file +}); diff --git a/src/ExpressReceiver.ts b/src/ExpressReceiver.ts index 33a541cdb..7a810ef3c 100644 --- a/src/ExpressReceiver.ts +++ b/src/ExpressReceiver.ts @@ -151,7 +151,7 @@ export default class ExpressReceiver extends EventEmitter implements Receiver { } } -const respondToSslCheck: RequestHandler = (req, res, next) => { +export const respondToSslCheck: RequestHandler = (req, res, next) => { if (req.body && req.body.ssl_check) { res.send(); return; @@ -159,7 +159,7 @@ const respondToSslCheck: RequestHandler = (req, res, next) => { next(); }; -const respondToUrlVerification: RequestHandler = (req, res, next) => { +export const respondToUrlVerification: RequestHandler = (req, res, next) => { if (req.body && req.body.type && req.body.type === 'url_verification') { res.json({ challenge: req.body.challenge }); return; diff --git a/src/conversation-store.spec.ts b/src/conversation-store.spec.ts index 9a046f9fa..e166909c6 100644 --- a/src/conversation-store.spec.ts +++ b/src/conversation-store.spec.ts @@ -221,9 +221,12 @@ function createFakeStore( ): FakeStore { return { // NOTE (Nov 2019): We had to convert to 'unknown' first due to the following error: - // src/conversation-store.spec.ts:223:10 - error TS2352: Conversion of type 'SinonSpy' to type 'SinonSpy<[string, any, (number | undefined)?], Promise>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. + // src/conversation-store.spec.ts:223:10 - error TS2352: Conversion of type 'SinonSpy' to + // type 'SinonSpy<[string, any, (number | undefined)?], Promise>' may be a mistake because neither type + // sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. // Types of property 'firstCall' are incompatible. - // Type 'SinonSpyCall' is not comparable to type 'SinonSpyCall<[string, any, (number | undefined)?], Promise>'. + // Type 'SinonSpyCall' is not comparable to type 'SinonSpyCall<[string, any, (number | undefined)?], + // Promise>'. // Type 'any[]' is not comparable to type '[string, any, (number | undefined)?]'. // 223 set: setSpy as SinonSpy, ReturnType>, // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/middleware/builtin.spec.ts b/src/middleware/builtin.spec.ts index d9a25595e..5ae6b875b 100644 --- a/src/middleware/builtin.spec.ts +++ b/src/middleware/builtin.spec.ts @@ -5,7 +5,17 @@ import sinon from 'sinon'; import { ErrorCode } from '../errors'; import { Override, delay, wrapToResolveOnFirstCall } from '../test-helpers'; import rewiremock from 'rewiremock'; -import { SlackEventMiddlewareArgs, NextMiddleware, Context, MessageEvent, ContextMissingPropertyError, SlackCommandMiddlewareArgs } from '../types'; +import { + SlackEventMiddlewareArgs, + NextMiddleware, + Context, + MessageEvent, + ContextMissingPropertyError, + SlackCommandMiddlewareArgs, +} from '../types'; +import { onlyCommands, onlyEvents, matchCommandName, matchEventType, subtype } from './builtin'; +import { SlashCommand } from '../types/command/index'; +import { SlackEvent, AppMentionEvent, BotMessageEvent } from '../types/events/base-events'; describe('matchMessage()', () => { function initializeTestCase(pattern: string | RegExp): Mocha.AsyncFunc { @@ -422,6 +432,177 @@ describe('ignoreSelf()', () => { }); }); +describe('onlyCommands', () => { + + it('should detect valid requests', async () => { + const payload: SlashCommand = { ...validCommandPayload }; + const fakeNext = sinon.fake(); + onlyCommands({ + payload, + command: payload, + body: payload, + say: () => { /* noop */ }, + respond: () => { /* noop */ }, + ack: () => { /* noop */ }, + next: fakeNext, + context: {}, + }); + assert.isTrue(fakeNext.called); + }); + + it('should skip other requests', async () => { + const payload: any = {}; + const fakeNext = sinon.fake(); + onlyCommands({ + payload, + action: payload, + command: undefined, + body: payload, + say: () => { /* noop */ }, + respond: () => { /* noop */ }, + ack: () => { /* noop */ }, + next: fakeNext, + context: {}, + }); + assert.isTrue(fakeNext.notCalled); + }); +}); + +describe('matchCommandName', () => { + function buildArgs(fakeNext: NextMiddleware): SlackCommandMiddlewareArgs & { next: any, context: any } { + const payload: SlashCommand = { ...validCommandPayload }; + return { + payload, + command: payload, + body: payload, + say: () => { /* noop */ }, + respond: () => { /* noop */ }, + ack: () => { /* noop */ }, + next: fakeNext, + context: {}, + }; + } + + it('should detect valid requests', async () => { + const fakeNext = sinon.fake(); + matchCommandName('/hi')(buildArgs(fakeNext)); + assert.isTrue(fakeNext.called); + }); + + it('should skip other requests', async () => { + const fakeNext = sinon.fake(); + matchCommandName('/hello')(buildArgs(fakeNext)); + assert.isTrue(fakeNext.notCalled); + }); +}); + +describe('onlyEvents', () => { + + it('should detect valid requests', async () => { + const fakeNext = sinon.fake(); + const args: SlackEventMiddlewareArgs<'app_mention'> & { event?: SlackEvent } = { + payload: appMentionEvent, + event: appMentionEvent, + message: null as never, // a bit hackey to sartisfy TS compiler + body: { + token: 'token-value', + team_id: 'T1234567', + api_app_id: 'A1234567', + event: appMentionEvent, + type: 'event_callback', + event_id: 'event-id-value', + event_time: 123, + authed_users: [], + }, + say: () => { /* noop */ }, + }; + onlyEvents({ next: fakeNext, context: {}, ...args }); + assert.isTrue(fakeNext.called); + }); + + it('should skip other requests', async () => { + const payload: SlashCommand = { ...validCommandPayload }; + const fakeNext = sinon.fake(); + onlyEvents({ + payload, + command: payload, + body: payload, + say: () => { /* noop */ }, + respond: () => { /* noop */ }, + ack: () => { /* noop */ }, + next: fakeNext, + context: {}, + }); + assert.isFalse(fakeNext.called); + }); +}); + +describe('matchEventType', () => { + function buildArgs(): SlackEventMiddlewareArgs<'app_mention'> & { event?: SlackEvent } { + return { + payload: appMentionEvent, + event: appMentionEvent, + message: null as never, // a bit hackey to sartisfy TS compiler + body: { + token: 'token-value', + team_id: 'T1234567', + api_app_id: 'A1234567', + event: appMentionEvent, + type: 'event_callback', + event_id: 'event-id-value', + event_time: 123, + authed_users: [], + }, + say: () => { /* noop */ }, + }; + } + + it('should detect valid requests', async () => { + const fakeNext = sinon.fake(); + matchEventType('app_mention')({ next: fakeNext, context: {}, ...buildArgs() }); + assert.isTrue(fakeNext.called); + }); + + it('should skip other requests', async () => { + const fakeNext = sinon.fake(); + matchEventType('app_home_opened')({ next: fakeNext, context: {}, ...buildArgs() }); + assert.isFalse(fakeNext.called); + }); +}); + +describe('subtype', () => { + function buildArgs(): SlackEventMiddlewareArgs<'message'> & { event?: SlackEvent } { + return { + payload: botMessageEvent, + event: botMessageEvent, + message: botMessageEvent, + body: { + token: 'token-value', + team_id: 'T1234567', + api_app_id: 'A1234567', + event: botMessageEvent, + type: 'event_callback', + event_id: 'event-id-value', + event_time: 123, + authed_users: [], + }, + say: () => { /* noop */ }, + }; + } + + it('should detect valid requests', async () => { + const fakeNext = sinon.fake(); + subtype('bot_message')({ next: fakeNext, context: {}, ...buildArgs() }); + assert.isTrue(fakeNext.called); + }); + + it('should skip other requests', async () => { + const fakeNext = sinon.fake(); + subtype('me_message')({ next: fakeNext, context: {}, ...buildArgs() }); + assert.isFalse(fakeNext.called); + }); +}); + /* Testing Harness */ interface DummyContext { @@ -429,7 +610,8 @@ interface DummyContext { } type MessageMiddlewareArgs = SlackEventMiddlewareArgs<'message'> & { next: NextMiddleware, context: Context }; -type TokensRevokedMiddlewareArgs = SlackEventMiddlewareArgs<'tokens_revoked'> & { next: NextMiddleware, context: Context }; +type TokensRevokedMiddlewareArgs = SlackEventMiddlewareArgs<'tokens_revoked'> + & { next: NextMiddleware, context: Context }; type MemberJoinedOrLeftChannelMiddlewareArgs = SlackEventMiddlewareArgs<'member_joined_channel' | 'member_left_channel'> & { next: NextMiddleware, context: Context }; @@ -456,3 +638,36 @@ function createFakeMessageEvent(content: string | MessageEvent['blocks'] = ''): } return event as MessageEvent; } + +const validCommandPayload: SlashCommand = { + token: 'token-value', + command: '/hi', + text: 'Steve!', + response_url: 'https://hooks.slack.com/foo/bar', + trigger_id: 'trigger-id-value', + user_id: 'U1234567', + user_name: 'steve', + team_id: 'T1234567', + team_domain: 'awesome-eng-team', + channel_id: 'C1234567', + channel_name: 'random', +}; + +const appMentionEvent: AppMentionEvent = { + type: 'app_mention', + user: 'U1234567', + text: 'this is my message', + ts: '123.123', + channel: 'C1234567', + event_ts: '123.123', +}; + +const botMessageEvent: BotMessageEvent & MessageEvent = { + type: 'message', + subtype: 'bot_message', + channel: 'C1234567', + user: 'U1234567', + ts: '123.123', + text: 'this is my message', + bot_id: 'B1234567', +}; diff --git a/src/test-helpers.ts b/src/test-helpers.ts index 6a1029bf3..f53e0f0ee 100644 --- a/src/test-helpers.ts +++ b/src/test-helpers.ts @@ -47,7 +47,9 @@ export function createFakeLogger(): FakeLogger { // NOTE: the two casts are because of a TypeScript inconsistency with tuple types and any[]. all tuple types // should be assignable to any[], but TypeScript doesn't think so. // UPDATE (Nov 2019): - // src/test-helpers.ts:49:15 - error TS2352: Conversion of type 'SinonSpy' to type 'SinonSpy<[LogLevel], void>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. + // src/test-helpers.ts:49:15 - error TS2352: Conversion of type 'SinonSpy' to type 'SinonSpy<[LogLevel], + // void>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, + // convert the expression to 'unknown' first. // Property '0' is missing in type 'any[]' but required in type '[LogLevel]'. // 49 setLevel: sinon.fake() as SinonSpy, ReturnType>, // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~