From 1b793c03805895bda3bd4040da22b4d42d89cdf6 Mon Sep 17 00:00:00 2001 From: Fil Maj Date: Wed, 2 Oct 2024 15:56:33 -0400 Subject: [PATCH] ci/tests: move to biome, v4 linter fixes, audit tests and reorganize (#2259) --- .c8rc.json | 7 + .eslintignore | 1 - .eslintrc.js | 304 ---- .github/workflows/ci-build.yml | 3 - .github/workflows/samples.yml | 1 + .nycrc.json | 15 - .vscode/launch.json | 12 +- biome.json | 38 + docs/.gitignore | 4 +- docs/Gemfile.lock | 285 ++++ docs/docusaurus.config.js | 68 +- docs/package.json | 14 +- docs/sidebars.js | 10 +- docs/src/theme/NotFound/Content/index.js | 24 +- docs/src/theme/NotFound/index.js | 6 +- examples/custom-properties/express.js | 8 +- examples/custom-properties/http.js | 8 +- examples/custom-properties/package.json | 2 +- examples/custom-properties/socket-mode.js | 10 +- examples/custom-receiver/package.json | 26 +- .../custom-receiver/src/FastifyReceiver.ts | 102 +- examples/custom-receiver/src/KoaReceiver.ts | 96 +- examples/custom-receiver/src/fastify-main.ts | 18 +- examples/custom-receiver/src/koa-main.ts | 18 +- examples/custom-receiver/tsconfig.eslint.json | 13 - examples/custom-receiver/tsconfig.json | 20 +- examples/deploy-aws-lambda/app.js | 30 +- examples/deploy-aws-lambda/package.json | 2 +- examples/deploy-heroku/app.js | 30 +- examples/deploy-heroku/package.json | 2 +- .../getting-started-typescript/src/app.ts | 9 +- .../getting-started-typescript/src/basic.ts | 54 +- .../src/utils/env.ts | 2 +- .../tsconfig.eslint.json | 13 - .../getting-started-typescript/tsconfig.json | 4 +- examples/hubot-example/README.md | 39 - examples/hubot-example/script.js | 297 ---- examples/message-metadata/app-manifest.json | 72 +- examples/message-metadata/app.js | 47 +- examples/oauth-express-receiver/app.js | 2 +- examples/oauth/app.js | 2 +- examples/socket-mode-oauth/package.json | 2 +- examples/socket-mode/app.js | 117 +- package.json | 29 +- src/App-basic-features.spec.ts | 1329 ----------------- src/App-built-in-middleware.spec.ts | 609 -------- src/App-context-types.spec.ts | 887 ----------- src/App-routes.spec.ts | 1090 -------------- src/App-workflow-steps.spec.ts | 89 -- src/App.ts | 488 +++--- src/CustomFunction.ts | 74 +- src/WorkflowStep.ts | 119 +- src/conversation-store.ts | 12 +- src/errors.ts | 4 +- src/helpers.ts | 32 +- src/index.ts | 4 +- src/middleware/builtin.spec.ts | 902 ----------- src/middleware/builtin.ts | 53 +- src/middleware/process.ts | 6 +- src/receivers/AwsLambdaReceiver.spec.ts | 623 -------- src/receivers/AwsLambdaReceiver.ts | 35 +- src/receivers/BufferedIncomingMessage.ts | 4 +- src/receivers/ExpressReceiver.ts | 149 +- src/receivers/HTTPModuleFunctions.ts | 394 +++-- src/receivers/HTTPReceiver.ts | 134 +- src/receivers/HTTPResponseAck.ts | 24 +- src/receivers/ParamsIncomingMessage.ts | 4 +- src/receivers/SocketModeFunctions.ts | 44 +- src/receivers/SocketModeReceiver.ts | 67 +- src/receivers/custom-routes.ts | 23 +- src/receivers/verify-redirect-opts.ts | 16 +- src/receivers/verify-request.ts | 15 +- src/test-helpers.ts | 71 - src/types/actions/block-action.spec.ts | 181 --- src/types/actions/block-action.ts | 12 +- src/types/actions/index.ts | 34 +- src/types/actions/interactive-message.spec.ts | 52 - src/types/actions/workflow-step-edit.ts | 11 +- src/types/command/index.ts | 2 +- src/types/events/index.ts | 27 +- src/types/middleware.ts | 26 +- src/types/options/index.spec.ts | 129 -- src/types/options/index.ts | 26 +- src/types/receiver.ts | 11 +- src/types/shortcuts/global-shortcut.ts | 1 + src/types/shortcuts/index.ts | 16 +- src/types/shortcuts/message-shortcut.ts | 3 +- src/types/utilities.spec.ts | 26 - src/types/utilities.ts | 34 +- src/types/view/index.ts | 11 +- test/types/action.test-d.ts | 42 + test/types/command.test-d.ts | 23 + {types-tests => test/types}/error.test-d.ts | 18 +- test/types/event.test-d.ts | 69 + {types-tests => test/types}/message.test-d.ts | 43 +- test/types/options.test-d.ts | 102 ++ test/types/shortcut.test-d.ts | 62 + test/types/use.test-d.ts | 31 + test/types/view.test-d.ts | 63 + .mocharc.json => test/unit/.mocharc.json | 1 + test/unit/App/basic.spec.ts | 377 +++++ test/unit/App/middleware.spec.ts | 1085 ++++++++++++++ test/unit/App/routing-action.spec.ts | 81 + test/unit/App/routing-command.spec.ts | 63 + test/unit/App/routing-event.spec.ts | 70 + test/unit/App/routing-message.spec.ts | 63 + test/unit/App/routing-options.spec.ts | 76 + test/unit/App/routing-shortcut.spec.ts | 88 ++ test/unit/App/routing-view.spec.ts | 101 ++ {src => test/unit}/CustomFunction.spec.ts | 50 +- {src => test/unit}/WorkflowStep.spec.ts | 81 +- {src => test/unit}/conversation-store.spec.ts | 110 +- {src => test/unit}/errors.spec.ts | 12 +- {src => test/unit}/helpers.spec.ts | 52 +- test/unit/helpers/app.ts | 138 ++ test/unit/helpers/events.ts | 450 ++++++ test/unit/helpers/index.ts | 34 + test/unit/helpers/receivers.ts | 102 ++ test/unit/middleware/builtin.spec.ts | 414 +++++ test/unit/receivers/AwsLambdaReceiver.spec.ts | 285 ++++ .../unit}/receivers/ExpressReceiver.spec.ts | 867 +++++------ .../receivers/HTTPModuleFunctions.spec.ts | 133 +- .../unit}/receivers/HTTPReceiver.spec.ts | 426 ++---- .../unit}/receivers/HTTPResponseAck.spec.ts | 26 +- .../receivers/SocketModeFunctions.spec.ts | 16 +- .../receivers/SocketModeReceiver.spec.ts | 490 +++--- .../unit}/receivers/verify-request.spec.ts | 67 +- tsconfig.eslint.json | 26 - tsconfig.json | 3 +- types-tests/action.test-d.ts | 24 - types-tests/command.test-d.ts | 9 - types-tests/event.test-d.ts | 82 - types-tests/middleware.test-d.ts | 19 - types-tests/options.test-d.ts | 111 -- types-tests/shortcut.test-d.ts | 38 - types-tests/utilities.test-d.ts | 20 - types-tests/view.test-d.ts | 100 -- 137 files changed, 6464 insertions(+), 9948 deletions(-) create mode 100644 .c8rc.json delete mode 100644 .eslintignore delete mode 100644 .eslintrc.js delete mode 100644 .nycrc.json create mode 100644 biome.json create mode 100644 docs/Gemfile.lock delete mode 100644 examples/custom-receiver/tsconfig.eslint.json delete mode 100644 examples/getting-started-typescript/tsconfig.eslint.json delete mode 100644 examples/hubot-example/README.md delete mode 100644 examples/hubot-example/script.js delete mode 100644 src/App-basic-features.spec.ts delete mode 100644 src/App-built-in-middleware.spec.ts delete mode 100644 src/App-context-types.spec.ts delete mode 100644 src/App-routes.spec.ts delete mode 100644 src/App-workflow-steps.spec.ts delete mode 100644 src/middleware/builtin.spec.ts delete mode 100644 src/receivers/AwsLambdaReceiver.spec.ts delete mode 100644 src/test-helpers.ts delete mode 100644 src/types/actions/block-action.spec.ts delete mode 100644 src/types/actions/interactive-message.spec.ts delete mode 100644 src/types/options/index.spec.ts delete mode 100644 src/types/utilities.spec.ts create mode 100644 test/types/action.test-d.ts create mode 100644 test/types/command.test-d.ts rename {types-tests => test/types}/error.test-d.ts (69%) create mode 100644 test/types/event.test-d.ts rename {types-tests => test/types}/message.test-d.ts (80%) create mode 100644 test/types/options.test-d.ts create mode 100644 test/types/shortcut.test-d.ts create mode 100644 test/types/use.test-d.ts create mode 100644 test/types/view.test-d.ts rename .mocharc.json => test/unit/.mocharc.json (69%) create mode 100644 test/unit/App/basic.spec.ts create mode 100644 test/unit/App/middleware.spec.ts create mode 100644 test/unit/App/routing-action.spec.ts create mode 100644 test/unit/App/routing-command.spec.ts create mode 100644 test/unit/App/routing-event.spec.ts create mode 100644 test/unit/App/routing-message.spec.ts create mode 100644 test/unit/App/routing-options.spec.ts create mode 100644 test/unit/App/routing-shortcut.spec.ts create mode 100644 test/unit/App/routing-view.spec.ts rename {src => test/unit}/CustomFunction.spec.ts (89%) rename {src => test/unit}/WorkflowStep.spec.ts (86%) rename {src => test/unit}/conversation-store.spec.ts (81%) rename {src => test/unit}/errors.spec.ts (91%) rename {src => test/unit}/helpers.spec.ts (86%) create mode 100644 test/unit/helpers/app.ts create mode 100644 test/unit/helpers/events.ts create mode 100644 test/unit/helpers/index.ts create mode 100644 test/unit/helpers/receivers.ts create mode 100644 test/unit/middleware/builtin.spec.ts create mode 100644 test/unit/receivers/AwsLambdaReceiver.spec.ts rename {src => test/unit}/receivers/ExpressReceiver.spec.ts (51%) rename {src => test/unit}/receivers/HTTPModuleFunctions.spec.ts (68%) rename {src => test/unit}/receivers/HTTPReceiver.spec.ts (60%) rename {src => test/unit}/receivers/HTTPResponseAck.spec.ts (87%) rename {src => test/unit}/receivers/SocketModeFunctions.spec.ts (68%) rename {src => test/unit}/receivers/SocketModeReceiver.spec.ts (64%) rename {src => test/unit}/receivers/verify-request.spec.ts (68%) delete mode 100644 tsconfig.eslint.json delete mode 100644 types-tests/action.test-d.ts delete mode 100644 types-tests/command.test-d.ts delete mode 100644 types-tests/event.test-d.ts delete mode 100644 types-tests/middleware.test-d.ts delete mode 100644 types-tests/options.test-d.ts delete mode 100644 types-tests/shortcut.test-d.ts delete mode 100644 types-tests/utilities.test-d.ts delete mode 100644 types-tests/view.test-d.ts diff --git a/.c8rc.json b/.c8rc.json new file mode 100644 index 000000000..7df66f95b --- /dev/null +++ b/.c8rc.json @@ -0,0 +1,7 @@ +{ + "include": ["src/**/*.ts"], + "exclude": ["**/*.spec.ts"], + "reporter": ["lcov", "text"], + "all": false, + "cache": true +} diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index b512c09d4..000000000 --- a/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -node_modules \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index c59dfd582..000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,304 +0,0 @@ -// -// SlackAPI JavaScript and TypeScript style -// --- -// This style helps maintainers enforce safe and consistent programming practices in this project. It is not meant to be -// comprehensive on its own or vastly different from existing styles. The goal is to inherit and aggregate as many of -// the communities' recommended styles for the technologies used as we can. When, and only when, we have a stated need -// to differentiate, we add more rules (or modify options). Therefore, the fewer rules directly defined in this file, -// the better. - -const jsDocPlugin = require('eslint-plugin-jsdoc'); - -const jsDocRecommendedRulesOff = Object.assign( - ...Object.keys(jsDocPlugin.configs.recommended.rules).map((rule) => ({ [rule]: 'off' })), -); - -module.exports = { - // This is a root of the project, ESLint should not look through parent directories to find more config - root: true, - - ignorePatterns: [ - // Ignore all build outputs and artifacts (node_modules, dotfiles, and dot directories are implicitly ignored) - '/dist', - '/coverage', - ], - - // These environments contain lists of global variables which are allowed to be accessed - env: { - // According to https://node.green, the target node version (v10) supports all important ES2018 features. But es2018 - // is not an option since it presumably doesn't introduce any new globals over ES2017. - es2017: true, - node: true, - }, - - extends: [ - // ESLint's recommended built-in rules: https://eslint.org/docs/rules/ - 'eslint:recommended', - - // Node plugin's recommended rules: https://github.com/mysticatea/eslint-plugin-node - 'plugin:node/recommended', - - // AirBnB style guide (without React) rules: https://github.com/airbnb/javascript. - 'airbnb-base', - - // JSDoc plugin's recommended rules - 'plugin:jsdoc/recommended', - ], - - rules: { - // JavaScript rules - // --- - // The top level of this configuration contains rules which apply to JavaScript (and will also be inherited for - // TypeScript). This section does not contain rules meant to override options or disable rules in the base - // configurations (ESLint, Node, AirBnb). Those rules are added in the final override. - - // Eliminate tabs to standardize on spaces for indentation. If you want to use tabs for something other than - // indentation, you may need to turn this rule off using an inline config comments. - 'no-tabs': 'error', - - // Bans use of comma as an operator because it can obscure side effects and is often an accident. - 'no-sequences': 'error', - - // Disallow the use of process.exit() - 'node/no-process-exit': 'error', - - // Allow safe references to functions before the declaration. Overrides AirBnB config. Not located in the override - // section below because a distinct override is necessary in TypeScript files. - 'no-use-before-define': ['error', 'nofunc'], - }, - - overrides: [ - { - files: ['**/*.ts'], - // Allow ESLint to understand TypeScript syntax - parser: '@typescript-eslint/parser', - parserOptions: { - // The following option makes it possible to use rules that require type information - project: './tsconfig.eslint.json', - }, - // Allow ESLint to load rules from the TypeScript plugin - plugins: ['@typescript-eslint'], - extends: [ - // TypeScript plugin's recommended rules - 'plugin:@typescript-eslint/recommended', - - // AirBnB style guide (without React), modified for TypeScript rules: https://github.com/iamturns/eslint-config-airbnb-typescript. - 'airbnb-typescript/base', - ], - - rules: { - // TypeScript rules - // --- - // This level of this configuration contains rules which apply only to TypeScript. It also contains rules that - // are meant to override options or disable rules in the base configurations (there are no more base - // configurations in the subsequent overrides). - 'max-classes-per-file': 'off', - - // Disallow invocations of require(). This will help make imports more consistent and ensures a smoother - // transition to the best future syntax. And since this rule affects TypeScript, which is compiled, there's - // no reason we cannot adopt this syntax now. - // NOTE: The `@typescript-eslint/no-require-imports` rule can also achieve the same effect, but it is less - // configurable and only built to provide a migration path from TSLint. - 'import/no-commonjs': ['error', { - allowConditionalRequire: false, - }], - - // Don't verify that all named imports are part of the set of named exports for the referenced module. The - // TypeScript compiler will already perform this check, so it is redundant. - // NOTE: Consider contributing this to the `airbnb-typescript` config. - 'import/named': 'off', - 'node/no-missing-import': 'off', - - // Prefer an interface declaration over a type alias because interfaces can be extended, implemented, and merged - '@typescript-eslint/consistent-type-definitions': ['error', 'interface'], - - // Require class properties and methods to explicitly use accessibility modifiers (public, private, protected) - '@typescript-eslint/explicit-member-accessibility': 'error', - - // Forbids an object literal to appear in a type assertion expression unless its used as a parameter (we violate - // this rule for test code, to allow for looser property matching for objects - more in the test-specific rules - // section below). This allows the typechecker to perform validation on the value as an assignment, instead of - // allowing the type assertion to always win. - // Requires use of `as Type` instead of `` for type assertion. Consistency. - '@typescript-eslint/consistent-type-assertions': ['error', { - assertionStyle: 'as', - objectLiteralTypeAssertions: 'allow-as-parameter', - }], - - // Ensure that the values returned from a module are of the expected type - '@typescript-eslint/explicit-module-boundary-types': ['error', { - allowArgumentsExplicitlyTypedAsAny: true, - }], - - // Turns off all JSDoc plugin rules because they don't work consistently in TypeScript contexts. For example, - // it's not an error to export interfaces and types that don't have JSDoc on them without these contexts. Also, - // satisfying some of these rules would require redundant type information in the JSDoc comments, so its in - // conflict with the next rule. - // TODO: track progress on this issue https://github.com/gajus/eslint-plugin-jsdoc/issues/615 - ...jsDocRecommendedRulesOff, - - // No types in JSDoc for @param or @returns. TypeScript will provide this type information, so it would be - // redundant, and possibly conflicting. - 'jsdoc/no-types': 'error', - - // Allow use of import and export syntax, despite it not being supported in the node versions. Since this - // project is transpiled, the ignore option is used. Overrides node/recommended. - // 'node/no-unsupported-features/es-syntax': ['error', { ignores: ['modules'] }], - // TODO: The node plugin's ignore option doesn't work in order to suppress this error. - 'node/no-unsupported-features/es-syntax': 'off', - - // Allow safe references to functions before the declaration. Overrides AirBnB config. Not located in the - // override section below because a distinct override is necessary in JavaScript files. - 'no-use-before-define': 'off', - '@typescript-eslint/no-use-before-define': ['error', 'nofunc'], - // Turn off no-inferrable-types. While it may be obvious what the type of something is by its default - // value, being explicit is good, especially for newcomers. - '@typescript-eslint/no-inferrable-types': 'off', - - 'operator-linebreak': ['error', 'after', { overrides: { - '=': 'none' - }}], - }, - }, - { - files: ['**/*.js', '**/*.ts'], - rules: { - // Override rules - // --- - // This level of this configuration contains rules which override options or disable rules in the base - // configurations in both JavaScript and TypeScript. - - // Increase the max line length to 120. The rest of this setting is copied from the AirBnB config. - 'max-len': ['error', 120, 2, { - ignoreUrls: true, - ignoreComments: false, - ignoreRegExpLiterals: true, - ignoreStrings: true, - ignoreTemplateLiterals: true, - }], - - // Restrict the use of backticks to declare a normal string. Template literals should only be used when the - // template string contains placeholders. The rest of this setting is copied from the AirBnb config. - quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }], - - // the server side Slack API uses snake_case for parameters often - // for mocking and override support, we need to allow snake_case - // Allow leading underscores for parameter names, which is used to acknowledge unused variables in TypeScript. - // Also, enforce camelCase naming for variables. Ideally, the leading underscore could be restricted to only - // unused parameter names, but this rule isn't capable of knowing when a variable is unused. The camelcase and - // no-underscore-dangle rules are replaced with the naming-convention rule because this single rule can serve - // both purposes, and it works fine on non-TypeScript code. - camelcase: 'off', - 'no-underscore-dangle': 'off', - '@typescript-eslint/no-unused-vars': [ - 'error', - { - varsIgnorePattern: '^_', - argsIgnorePattern: '^_' - } - ], - '@typescript-eslint/naming-convention': [ - 'error', - { - selector: 'default', - format: ['camelCase'], - leadingUnderscore: 'allow', - }, - { - selector: 'variable', - // PascalCase for variables is added to allow exporting a singleton, function library, or bare object as in - // section 23.8 of the AirBnB style guide - format: ['camelCase', 'PascalCase', 'UPPER_CASE', 'snake_case'], - leadingUnderscore: 'allow', - }, - { - selector: 'parameter', - format: ['camelCase'], - leadingUnderscore: 'allow', - }, - { - selector: 'typeLike', - format: ['PascalCase', 'camelCase'], - leadingUnderscore: 'allow', - }, - { - selector: 'typeProperty', - format: ['snake_case', 'camelCase'], - }, - { - 'selector': 'objectLiteralProperty', - format: ['camelCase', 'snake_case', 'PascalCase'], - }, - { - selector: ['enumMember'], - format: ['PascalCase'], - }, - ], - - // Allow cyclical imports. Turning this rule on is mainly a way to manage the performance concern for linting - // time. Our projects are not large enough to warrant this. Overrides AirBnB styles. - 'import/no-cycle': 'off', - - // Prevent importing submodules of other modules. Using the internal structure of a module exposes - // implementation details that can potentially change in breaking ways. Overrides AirBnB styles. - 'import/no-internal-modules': ['error', { - // Use the following option to set a list of allowable globs in this project. - allow: [ - '**/middleware/*', // the src/middleware directory doesn't export a module, it's just a namespace. - '**/receivers/*', // the src/receivers directory doesn't export a module, it's just a namespace. - '**/types/**/*', - '**/types/*', // type hierarchies should be used however one wants - ], - }], - - // Remove the minProperties option for enforcing line breaks between braces. The AirBnB config sets this to 4, - // which is arbitrary and not backed by anything specific in the style guide. If we just remove it, we can - // rely on the max-len rule to determine if the line is too long and then enforce line breaks. Overrides AirBnB - // styles. - 'object-curly-newline': ['error', { multiline: true, consistent: true }], - - }, - }, - { - files: ['src/**/*.spec.ts'], - rules: { - // Test-specific rules - // --- - // Rules that only apply to Typescript _test_ source files - - // With Mocha as a test framework, it is sometimes helpful to assign - // shared state to Mocha's Context object, for example in setup and - // teardown test methods. Assigning stub/mock objects to the Context - // object via `this` is a common pattern in Mocha. As such, using - // `function` over the arrow notation binds `this` appropriately and - // should be used in tests. So: we turn off the prefer-arrow-callback - // rule. - // See https://github.com/slackapi/bolt-js/pull/1012#pullrequestreview-711232738 - // for a case of arrow-vs-function syntax coming up for the team - 'prefer-arrow-callback': 'off', - - // Unlike non-test-code, where we require use of `as Type` instead of `` for type assertion, - // in test code using the looser `as Type` syntax leads to easier test writing, since only required - // properties must be adhered to using the `as Type` syntax. - '@typescript-eslint/consistent-type-assertions': ['error', { - assertionStyle: 'as', - objectLiteralTypeAssertions: 'allow', - }], - // Using any types is so useful for mock objects, we are fine with disabling this rule - '@typescript-eslint/no-explicit-any': 'off', - // Some parts in Bolt (e.g., listener arguments) are unnecessarily optional. - // It's okay to omit this validation in tests. - '@typescript-eslint/no-non-null-assertion': 'off', - // Using ununamed functions (e.g., null logger) in tests is fine - 'func-names': 'off', - // In tests, don't force constructing a Symbol with a descriptor, as - // it's probably just for tests - 'symbol-description': 'off', - }, - }, - ], -}; - -// Test files globs -// '**/*.spec.ts', -// 'src/test-helpers.ts' diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 388476d93..eed7de872 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -25,9 +25,6 @@ jobs: with: node-version: ${{ matrix.node-version }} - run: npm install - - name: Print eslint version - run: ./node_modules/.bin/eslint -v - - run: npm run build - run: npm test - name: Upload coverage to Codecov if: matrix.node-version == '22.x' diff --git a/.github/workflows/samples.yml b/.github/workflows/samples.yml index 024108ef3..9a6d34f0c 100644 --- a/.github/workflows/samples.yml +++ b/.github/workflows/samples.yml @@ -14,6 +14,7 @@ jobs: node-version: [18.x, 20.x, 22.x] example: - examples/getting-started-typescript + - examples/custom-receiver steps: - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 diff --git a/.nycrc.json b/.nycrc.json deleted file mode 100644 index bf6690f0a..000000000 --- a/.nycrc.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "include": [ - "src/**/*.ts" - ], - "exclude": [ - "**/*.spec.ts", - "src/test-helpers.ts" - ], - "reporter": ["lcov"], - "extension": [ - ".ts" - ], - "all": false, - "cache": true -} diff --git a/.vscode/launch.json b/.vscode/launch.json index f2805e79d..9b9055530 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,22 +10,14 @@ "name": "Spec tests", "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", "stopOnEntry": false, - "args": [ - "--config", - ".mocharc.json", - "--no-timeouts", - "src/*.spec.ts", - "src/**/*.spec.ts" - ], + "args": ["--config", ".mocharc.json", "--no-timeouts", "src/*.spec.ts", "src/**/*.spec.ts"], "cwd": "${workspaceFolder}", "runtimeExecutable": null, "env": { "NODE_ENV": "testing", "TS_NODE_PROJECT": "tsconfig.test.json" }, - "skipFiles": [ - "/**" - ] + "skipFiles": ["/**"] } ] } diff --git a/biome.json b/biome.json new file mode 100644 index 000000000..73f39e3c0 --- /dev/null +++ b/biome.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", + "files": { + "ignore": [ + "docs/_site", + "examples/**/dist" + ] + }, + "formatter": { + "enabled": true, + "formatWithErrors": false, + "ignore": [], + "attributePosition": "auto", + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 120, + "lineEnding": "lf" + }, + "javascript": { + "formatter": { + "quoteStyle": "single" + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "organizeImports": { + "enabled": true + }, + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + } +} diff --git a/docs/.gitignore b/docs/.gitignore index 49ba8fa7c..7f541635e 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -2,4 +2,6 @@ node_modules/ .docusaurus .DS_Store build/ -.stylelintrc.json \ No newline at end of file +.stylelintrc.json +_site +Gemfile.lock diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock new file mode 100644 index 000000000..5d66e4afa --- /dev/null +++ b/docs/Gemfile.lock @@ -0,0 +1,285 @@ +GEM + remote: https://rubygems.org/ + specs: + activesupport (6.0.4.7) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 0.7, < 2) + minitest (~> 5.1) + tzinfo (~> 1.1) + zeitwerk (~> 2.2, >= 2.2.2) + addressable (2.8.0) + public_suffix (>= 2.0.2, < 5.0) + coffee-script (2.4.1) + coffee-script-source + execjs + coffee-script-source (1.11.1) + colorator (1.1.0) + commonmarker (0.23.4) + concurrent-ruby (1.1.10) + dnsruby (1.61.9) + simpleidn (~> 0.1) + dotenv (2.7.6) + em-websocket (0.5.3) + eventmachine (>= 0.12.9) + http_parser.rb (~> 0) + ethon (0.15.0) + ffi (>= 1.15.0) + eventmachine (1.2.7) + execjs (2.8.1) + faraday (1.10.0) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.3) + multipart-post (>= 1.2, < 3) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + ffi (1.15.5) + forwardable-extended (2.6.0) + gemoji (3.0.1) + github-pages (225) + github-pages-health-check (= 1.17.9) + jekyll (= 3.9.0) + jekyll-avatar (= 0.7.0) + jekyll-coffeescript (= 1.1.1) + jekyll-commonmark-ghpages (= 0.2.0) + jekyll-default-layout (= 0.1.4) + jekyll-feed (= 0.15.1) + jekyll-gist (= 1.5.0) + jekyll-github-metadata (= 2.13.0) + jekyll-include-cache (= 0.2.1) + jekyll-mentions (= 1.6.0) + jekyll-optional-front-matter (= 0.3.2) + jekyll-paginate (= 1.1.0) + jekyll-readme-index (= 0.3.0) + jekyll-redirect-from (= 0.16.0) + jekyll-relative-links (= 0.6.1) + jekyll-remote-theme (= 0.4.3) + jekyll-sass-converter (= 1.5.2) + jekyll-seo-tag (= 2.8.0) + jekyll-sitemap (= 1.4.0) + jekyll-swiss (= 1.0.0) + jekyll-theme-architect (= 0.2.0) + jekyll-theme-cayman (= 0.2.0) + jekyll-theme-dinky (= 0.2.0) + jekyll-theme-hacker (= 0.2.0) + jekyll-theme-leap-day (= 0.2.0) + jekyll-theme-merlot (= 0.2.0) + jekyll-theme-midnight (= 0.2.0) + jekyll-theme-minimal (= 0.2.0) + jekyll-theme-modernist (= 0.2.0) + jekyll-theme-primer (= 0.6.0) + jekyll-theme-slate (= 0.2.0) + jekyll-theme-tactile (= 0.2.0) + jekyll-theme-time-machine (= 0.2.0) + jekyll-titles-from-headings (= 0.5.3) + jemoji (= 0.12.0) + kramdown (= 2.3.1) + kramdown-parser-gfm (= 1.1.0) + liquid (= 4.0.3) + mercenary (~> 0.3) + minima (= 2.5.1) + nokogiri (>= 1.12.5, < 2.0) + rouge (= 3.26.0) + terminal-table (~> 1.4) + github-pages-health-check (1.17.9) + addressable (~> 2.3) + dnsruby (~> 1.60) + octokit (~> 4.0) + public_suffix (>= 3.0, < 5.0) + typhoeus (~> 1.3) + html-pipeline (2.14.1) + activesupport (>= 2) + nokogiri (>= 1.4) + http_parser.rb (0.8.0) + i18n (0.9.5) + concurrent-ruby (~> 1.0) + jekyll (3.9.0) + addressable (~> 2.4) + colorator (~> 1.0) + em-websocket (~> 0.5) + i18n (~> 0.7) + jekyll-sass-converter (~> 1.0) + jekyll-watch (~> 2.0) + kramdown (>= 1.17, < 3) + liquid (~> 4.0) + mercenary (~> 0.3.3) + pathutil (~> 0.9) + rouge (>= 1.7, < 4) + safe_yaml (~> 1.0) + jekyll-avatar (0.7.0) + jekyll (>= 3.0, < 5.0) + jekyll-coffeescript (1.1.1) + coffee-script (~> 2.2) + coffee-script-source (~> 1.11.1) + jekyll-commonmark (1.4.0) + commonmarker (~> 0.22) + jekyll-commonmark-ghpages (0.2.0) + commonmarker (~> 0.23.4) + jekyll (~> 3.9.0) + jekyll-commonmark (~> 1.4.0) + rouge (>= 2.0, < 4.0) + jekyll-default-layout (0.1.4) + jekyll (~> 3.0) + jekyll-feed (0.15.1) + jekyll (>= 3.7, < 5.0) + jekyll-gist (1.5.0) + octokit (~> 4.2) + jekyll-github-metadata (2.13.0) + jekyll (>= 3.4, < 5.0) + octokit (~> 4.0, != 4.4.0) + jekyll-include-cache (0.2.1) + jekyll (>= 3.7, < 5.0) + jekyll-mentions (1.6.0) + html-pipeline (~> 2.3) + jekyll (>= 3.7, < 5.0) + jekyll-optional-front-matter (0.3.2) + jekyll (>= 3.0, < 5.0) + jekyll-paginate (1.1.0) + jekyll-readme-index (0.3.0) + jekyll (>= 3.0, < 5.0) + jekyll-redirect-from (0.16.0) + jekyll (>= 3.3, < 5.0) + jekyll-relative-links (0.6.1) + jekyll (>= 3.3, < 5.0) + jekyll-remote-theme (0.4.3) + addressable (~> 2.0) + jekyll (>= 3.5, < 5.0) + jekyll-sass-converter (>= 1.0, <= 3.0.0, != 2.0.0) + rubyzip (>= 1.3.0, < 3.0) + jekyll-sass-converter (1.5.2) + sass (~> 3.4) + jekyll-seo-tag (2.8.0) + jekyll (>= 3.8, < 5.0) + jekyll-sitemap (1.4.0) + jekyll (>= 3.7, < 5.0) + jekyll-swiss (1.0.0) + jekyll-theme-architect (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-cayman (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-dinky (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-hacker (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-leap-day (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-merlot (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-midnight (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-minimal (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-modernist (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-primer (0.6.0) + jekyll (> 3.5, < 5.0) + jekyll-github-metadata (~> 2.9) + jekyll-seo-tag (~> 2.0) + jekyll-theme-slate (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-tactile (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-time-machine (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-titles-from-headings (0.5.3) + jekyll (>= 3.3, < 5.0) + jekyll-watch (2.2.1) + listen (~> 3.0) + jemoji (0.12.0) + gemoji (~> 3.0) + html-pipeline (~> 2.2) + jekyll (>= 3.0, < 5.0) + kramdown (2.3.1) + rexml + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) + liquid (4.0.3) + listen (3.7.1) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + mercenary (0.3.6) + mini_portile2 (2.8.0) + minima (2.5.1) + jekyll (>= 3.5, < 5.0) + jekyll-feed (~> 0.9) + jekyll-seo-tag (~> 2.1) + minitest (5.15.0) + multipart-post (2.1.1) + nokogiri (1.13.4) + mini_portile2 (~> 2.8.0) + racc (~> 1.4) + octokit (4.22.0) + faraday (>= 0.9) + sawyer (~> 0.8.0, >= 0.5.3) + pathutil (0.16.2) + forwardable-extended (~> 2.6) + public_suffix (4.0.7) + racc (1.6.0) + rb-fsevent (0.11.1) + rb-inotify (0.10.1) + ffi (~> 1.0) + rexml (3.2.5) + rouge (3.26.0) + ruby2_keywords (0.0.5) + rubyzip (2.3.2) + safe_yaml (1.0.5) + sass (3.7.4) + sass-listen (~> 4.0.0) + sass-listen (4.0.0) + rb-fsevent (~> 0.9, >= 0.9.4) + rb-inotify (~> 0.9, >= 0.9.7) + sawyer (0.8.2) + addressable (>= 2.3.5) + faraday (> 0.8, < 2.0) + simpleidn (0.2.1) + unf (~> 0.1.4) + terminal-table (1.8.0) + unicode-display_width (~> 1.1, >= 1.1.1) + thread_safe (0.3.6) + typhoeus (1.4.0) + ethon (>= 0.9.0) + tzinfo (1.2.9) + thread_safe (~> 0.1) + unf (0.1.4) + unf_ext + unf_ext (0.0.8.1) + unicode-display_width (1.8.0) + zeitwerk (2.5.4) + +PLATFORMS + ruby + +DEPENDENCIES + dotenv + github-pages + +BUNDLED WITH + 2.1.4 diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 0a2058be9..81c195376 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -4,7 +4,7 @@ // There are various equivalent ways to declare your Docusaurus config. // See: https://docusaurus.io/docs/api/docusaurus-config -import {themes as prismThemes} from 'prism-react-renderer'; +import { themes as prismThemes } from 'prism-react-renderer'; /** @type {import('@docusaurus/types').Config} */ const config = { @@ -23,7 +23,7 @@ const config = { i18n: { defaultLocale: 'en', - locales: ['en','ja-jp'], + locales: ['en', 'ja-jp'], }, presets: [ @@ -49,26 +49,24 @@ const config = { ], ], -plugins: -['docusaurus-theme-github-codeblock', - [ - '@docusaurus/plugin-client-redirects', - { - redirects: [ - { - to: '/getting-started', - from: ['/tutorial/getting-started'], - }, - { - to: '/', - from: ['/concepts','/concepts/advanced','/concepts/basic'], - }, - ], - }, + plugins: [ + 'docusaurus-theme-github-codeblock', + [ + '@docusaurus/plugin-client-redirects', + { + redirects: [ + { + to: '/getting-started', + from: ['/tutorial/getting-started'], + }, + { + to: '/', + from: ['/concepts', '/concepts/advanced', '/concepts/basic'], + }, + ], + }, + ], ], - -], - themeConfig: /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ @@ -82,12 +80,12 @@ plugins: }, }, navbar: { - title: "Slack Developer Tools", + title: 'Slack Developer Tools', logo: { alt: 'Slack logo', src: 'img/slack-logo.svg', href: 'https://tools.slack.dev', - target : '_self' + target: '_self', }, items: [ { @@ -110,7 +108,7 @@ plugins: to: 'https://tools.slack.dev/bolt-python', target: '_self', }, - ] + ], }, { type: 'dropdown', @@ -137,7 +135,7 @@ plugins: to: 'https://api.slack.com/automation/quickstart', target: '_self', }, - ] + ], }, { type: 'dropdown', @@ -154,7 +152,7 @@ plugins: to: 'https://slackcommunity.com/', target: '_self', }, - ] + ], }, { to: 'https://api.slack.com/docs', @@ -167,25 +165,25 @@ plugins: }, { 'aria-label': 'GitHub Repository', - 'className': 'navbar-github-link', - 'href': 'https://github.com/slackapi/bolt-js', - 'position': 'right', + className: 'navbar-github-link', + href: 'https://github.com/slackapi/bolt-js', + position: 'right', target: '_self', }, ], }, footer: { - copyright: `

Made with ♡ by Slack and pals like you

`, + copyright: '

Made with ♡ by Slack and pals like you

', }, prism: { // switch to alucard when available in prism? - theme: prismThemes.github, + theme: prismThemes.github, darkTheme: prismThemes.dracula, }, - codeblock: { - showGithubLink: true, - githubLinkLabel: 'View on GitHub', - }, + codeblock: { + showGithubLink: true, + githubLinkLabel: 'View on GitHub', + }, // announcementBar: { // id: `announcementBar`, // content: `🎉️ Version 2.26.0 of the developer tools for the Slack automations platform is here! 🎉️ `, diff --git a/docs/package.json b/docs/package.json index fa1cd46e7..0d07ca10a 100644 --- a/docs/package.json +++ b/docs/package.json @@ -32,18 +32,10 @@ "stylelint-config-standard": "^36.0.1" }, "browserslist": { - "production": [ - ">0.5%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 3 chrome version", - "last 3 firefox version", - "last 5 safari version" - ] + "production": [">0.5%", "not dead", "not op_mini all"], + "development": ["last 3 chrome version", "last 3 firefox version", "last 5 safari version"] }, "engines": { "node": ">=20.0" } -} \ No newline at end of file +} diff --git a/docs/sidebars.js b/docs/sidebars.js index 54625e6f4..ca27739ac 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -39,7 +39,7 @@ const sidebars = { 'basic/custom-steps', 'basic/options', 'basic/authenticating-oauth', - 'basic/socket-mode' + 'basic/socket-mode', ], }, { @@ -62,10 +62,7 @@ const sidebars = { { type: 'category', label: 'Deployments', - items: [ - 'deployments/aws-lambda', - 'deployments/heroku' - ], + items: ['deployments/aws-lambda', 'deployments/heroku'], }, { type: 'category', @@ -86,7 +83,7 @@ const sidebars = { 'tutorial/getting-started-http', 'tutorial/hubot-migration', 'tutorial/migration-v2', - 'tutorial/migration-v3' + 'tutorial/migration-v3', ], }, { type: 'html', value: '


' }, @@ -107,7 +104,6 @@ const sidebars = { label: 'Contributors Guide', href: 'https://github.com/SlackAPI/bolt-js/blob/main/.github/contributing.md', }, - ], }; diff --git a/docs/src/theme/NotFound/Content/index.js b/docs/src/theme/NotFound/Content/index.js index c122bc039..fbd861df6 100644 --- a/docs/src/theme/NotFound/Content/index.js +++ b/docs/src/theme/NotFound/Content/index.js @@ -1,32 +1,26 @@ -import React from 'react'; -import clsx from 'clsx'; import Translate from '@docusaurus/Translate'; import Heading from '@theme/Heading'; -export default function NotFoundContent({className}) { +import clsx from 'clsx'; +import React from 'react'; +export default function NotFoundContent({ className }) { return (
- + Oh no! There's nothing here.

- - If we've led you astray, please let us know. We'll do our best to get things in order. - + + If we've led you astray, please let us know. We'll do our best to get things in order.

- - For now, we suggest heading back to the beginning to get your bearings. May your next journey have clear skies to guide you true. + + For now, we suggest heading back to the beginning to get your bearings. May your next journey have clear + skies to guide you true.

diff --git a/docs/src/theme/NotFound/index.js b/docs/src/theme/NotFound/index.js index 3b551f9e4..7c82b024f 100644 --- a/docs/src/theme/NotFound/index.js +++ b/docs/src/theme/NotFound/index.js @@ -1,8 +1,8 @@ -import React from 'react'; -import {translate} from '@docusaurus/Translate'; -import {PageMetadata} from '@docusaurus/theme-common'; +import { translate } from '@docusaurus/Translate'; +import { PageMetadata } from '@docusaurus/theme-common'; import Layout from '@theme/Layout'; import NotFoundContent from '@theme/NotFound/Content'; +import React from 'react'; export default function Index() { const title = translate({ id: 'theme.NotFound.title', diff --git a/examples/custom-properties/express.js b/examples/custom-properties/express.js index 7ab3138f2..163b70b87 100644 --- a/examples/custom-properties/express.js +++ b/examples/custom-properties/express.js @@ -6,10 +6,10 @@ const app = new App({ signingSecret: process.env.SLACK_SIGNING_SECRET, customPropertiesExtractor: (req) => { return { - "headers": req.headers, - "foo": "bar", + headers: req.headers, + foo: 'bar', }; - } + }, }), }); @@ -23,4 +23,4 @@ app.use(async ({ logger, context, next }) => { await app.start(process.env.PORT || 3000); console.log('⚡️ Bolt app is running!'); -})(); \ No newline at end of file +})(); diff --git a/examples/custom-properties/http.js b/examples/custom-properties/http.js index 0e6e9ba8b..16bb79a93 100644 --- a/examples/custom-properties/http.js +++ b/examples/custom-properties/http.js @@ -6,15 +6,15 @@ const app = new App({ signingSecret: process.env.SLACK_SIGNING_SECRET, customPropertiesExtractor: (req) => { return { - "headers": req.headers, - "foo": "bar", + headers: req.headers, + foo: 'bar', }; }, // other custom handlers dispatchErrorHandler: ({ error, logger, response }) => { logger.error(`dispatch error: ${error}`); response.writeHead(404); - response.write("Something is wrong!"); + response.write('Something is wrong!'); response.end(); }, processEventErrorHandler: ({ error, logger, response }) => { @@ -51,4 +51,4 @@ app.use(async ({ logger, context, next }) => { process.exit(255); } console.log('⚡️ Bolt app is running!'); -})(); \ No newline at end of file +})(); diff --git a/examples/custom-properties/package.json b/examples/custom-properties/package.json index ec2af1efa..f38030765 100644 --- a/examples/custom-properties/package.json +++ b/examples/custom-properties/package.json @@ -10,6 +10,6 @@ }, "license": "MIT", "dependencies": { - "@slack/socket-mode": "^1.2.0" + "@slack/bolt": "^3" } } diff --git a/examples/custom-properties/socket-mode.js b/examples/custom-properties/socket-mode.js index be822416f..345a50edf 100644 --- a/examples/custom-properties/socket-mode.js +++ b/examples/custom-properties/socket-mode.js @@ -6,11 +6,11 @@ const app = new App({ appToken: process.env.SLACK_APP_TOKEN, customPropertiesExtractor: ({ type, body }) => { return { - "socket_mode_payload_type": type, - "socket_mode_payload": body, - "foo": "bar", + socket_mode_payload_type: type, + socket_mode_payload: body, + foo: 'bar', }; - } + }, }), }); @@ -24,4 +24,4 @@ app.use(async ({ logger, context, next }) => { await app.start(); console.log('⚡️ Bolt app is running!'); -})(); \ No newline at end of file +})(); diff --git a/examples/custom-receiver/package.json b/examples/custom-receiver/package.json index 6507886c7..f087771f5 100644 --- a/examples/custom-receiver/package.json +++ b/examples/custom-receiver/package.json @@ -4,25 +4,25 @@ "description": "Example app using OAuth", "main": "app.js", "scripts": { - "lint": "eslint --fix --ext .ts src", - "build": "npm run lint && tsc -p .", - "build:watch": "npm run lint && tsc -w -p .", + "build": "tsc -p .", + "build:watch": "tsc -w -p .", "koa": "npm run build && node dist/koa-main.js", "fastify": "npm run build && node dist/fastify-main.js" }, "license": "MIT", "dependencies": { - "@koa/router": "^10.1.1", - "@slack/logger": "^3.0.0", - "@slack/oauth": "^2.5.0", - "dotenv": "^8.2.0", - "fastify": "^3.27.4", - "koa": "^2.13.4" + "@koa/router": "^13", + "@slack/bolt": "^3", + "@slack/logger": "^4.0.0", + "@slack/oauth": "^3", + "dotenv": "^16", + "fastify": "^5", + "koa": "^2" }, "devDependencies": { - "@types/koa__router": "^8.0.11", - "@types/node": "^14.14.35", - "ts-node": "^9.1.1", - "typescript": "^4.2.3" + "@types/koa__router": "^12", + "@types/node": "^18", + "ts-node": "^10", + "typescript": "5.3.3" } } diff --git a/examples/custom-receiver/src/FastifyReceiver.ts b/examples/custom-receiver/src/FastifyReceiver.ts index 1bfffda93..4ae2e647e 100644 --- a/examples/custom-receiver/src/FastifyReceiver.ts +++ b/examples/custom-receiver/src/FastifyReceiver.ts @@ -1,23 +1,26 @@ -/* eslint-disable node/no-extraneous-import */ -/* eslint-disable import/no-extraneous-dependencies */ -import { InstallProvider, CallbackOptions, InstallPathOptions } from '@slack/oauth'; -import { ConsoleLogger, LogLevel, Logger } from '@slack/logger'; -import Fastify, { FastifyInstance } from 'fastify'; -import { Server } from 'http'; +import type { Server } from 'node:http'; import { - App, - CodedError, - Receiver, - ReceiverEvent, + type App, + type BufferedIncomingMessage, + type CodedError, + HTTPResponseAck, + type InstallProviderOptions, + type InstallURLOptions, + type Receiver, + type ReceiverEvent, ReceiverInconsistentStateError, + type ReceiverProcessEventErrorHandlerArgs, + type ReceiverUnhandledRequestHandlerArgs, HTTPModuleFunctions as httpFunc, - HTTPResponseAck, - InstallProviderOptions, - InstallURLOptions, - BufferedIncomingMessage, - ReceiverProcessEventErrorHandlerArgs, - ReceiverUnhandledRequestHandlerArgs, } from '@slack/bolt'; +import { ConsoleLogger, type LogLevel, type Logger } from '@slack/logger'; +import { type CallbackOptions, type InstallPathOptions, InstallProvider } from '@slack/oauth'; +import Fastify, { type FastifyInstance } from 'fastify'; + +type CustomPropertiesExtractor = ( + request: BufferedIncomingMessage, + // biome-ignore lint/suspicious/noExplicitAny: custom properties can be anything +) => Record; export interface InstallerOptions { stateStore?: InstallProviderOptions['stateStore']; // default ClearStateStore @@ -50,13 +53,8 @@ export interface FastifyReceiverOptions { scopes?: InstallURLOptions['scopes']; installerOptions?: InstallerOptions; fastify?: FastifyInstance; - customPropertiesExtractor?: ( - request: BufferedIncomingMessage - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ) => Record; - processEventErrorHandler?: ( - args: ReceiverProcessEventErrorHandlerArgs - ) => Promise; + customPropertiesExtractor?: CustomPropertiesExtractor; + processEventErrorHandler?: (args: ReceiverProcessEventErrorHandlerArgs) => Promise; // NOTE: As we use setTimeout under the hood, this cannot be async unhandledRequestHandler?: (args: ReceiverUnhandledRequestHandlerArgs) => void; unhandledRequestTimeoutMillis?: number; @@ -77,10 +75,7 @@ export default class FastifyReceiver implements Receiver { private unhandledRequestTimeoutMillis: number; - private customPropertiesExtractor: ( - request: BufferedIncomingMessage - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ) => Record; + private customPropertiesExtractor: CustomPropertiesExtractor; private processEventErrorHandler: (args: ReceiverProcessEventErrorHandlerArgs) => Promise; @@ -99,12 +94,12 @@ export default class FastifyReceiver implements Receiver { public constructor(options: FastifyReceiverOptions) { this.signatureVerification = options.signatureVerification ?? true; this.signingSecretProvider = options.signingSecret; - this.customPropertiesExtractor = options.customPropertiesExtractor !== undefined ? - options.customPropertiesExtractor : - (_) => ({}); + this.customPropertiesExtractor = + options.customPropertiesExtractor !== undefined ? options.customPropertiesExtractor : (_) => ({}); this.path = options.path ?? '/slack/events'; this.unhandledRequestTimeoutMillis = options.unhandledRequestTimeoutMillis ?? 3001; - this.logger = options.logger ?? + this.logger = + options.logger ?? (() => { const defaultLogger = new ConsoleLogger(); if (options.logLevel) { @@ -115,7 +110,9 @@ export default class FastifyReceiver implements Receiver { this.fastify = options.fastify ?? Fastify({ logger: true }); if (options.fastify) { - this.logger.info('This Receiver replaces content type parsers in the given fastify instance. Other POST endpoints may no longer work as you expect.'); + this.logger.info( + 'This Receiver replaces content type parsers in the given fastify instance. Other POST endpoints may no longer work as you expect.', + ); } // To do the request signature validation, bolt-js needs access to the as-is text request body const contentTypes = ['application/json', 'application/x-www-form-urlencoded']; @@ -127,16 +124,10 @@ export default class FastifyReceiver implements Receiver { this.unhandledRequestHandler = options.unhandledRequestHandler ?? httpFunc.defaultUnhandledRequestHandler; this.installerOptions = options.installerOptions; - if ( - this.installerOptions && - this.installerOptions.installPath === undefined - ) { + if (this.installerOptions && this.installerOptions.installPath === undefined) { this.installerOptions.installPath = '/slack/install'; } - if ( - this.installerOptions && - this.installerOptions.redirectUriPath === undefined - ) { + if (this.installerOptions && this.installerOptions.redirectUriPath === undefined) { this.installerOptions.redirectUriPath = '/slack/oauth_redirect'; } if (options.clientId && options.clientSecret) { @@ -162,9 +153,10 @@ export default class FastifyReceiver implements Receiver { private async signingSecret(): Promise { if (this._signingSecret === undefined) { - this._signingSecret = typeof this.signingSecretProvider === 'string' ? - this.signingSecretProvider : - await this.signingSecretProvider(); + this._signingSecret = + typeof this.signingSecretProvider === 'string' + ? this.signingSecretProvider + : await this.signingSecretProvider(); } return this._signingSecret; } @@ -178,18 +170,10 @@ export default class FastifyReceiver implements Receiver { this.installerOptions.redirectUriPath ) { this.fastify.get(this.installerOptions.installPath, async (req, res) => { - await this.installer?.handleInstallPath( - req.raw, - res.raw, - this.installerOptions?.installPathOptions, - ); + await this.installer?.handleInstallPath(req.raw, res.raw, this.installerOptions?.installPathOptions); }); this.fastify.get(this.installerOptions.redirectUriPath, async (req, res) => { - await this.installer?.handleCallback( - req.raw, - res.raw, - this.installerOptions?.callbackOptions, - ); + await this.installer?.handleCallback(req.raw, res.raw, this.installerOptions?.callbackOptions); }); } @@ -197,7 +181,7 @@ export default class FastifyReceiver implements Receiver { const req = request.raw; const res = response.raw; - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: request bodies can be anything (req as any).rawBody = Buffer.from(request.body as string); // Verify authenticity let bufferedReq: BufferedIncomingMessage; @@ -211,8 +195,7 @@ export default class FastifyReceiver implements Receiver { req, ); } catch (err) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const e = err as any; + const e = err as Error; if (this.signatureVerification) { this.logger.warn(`Failed to parse and verify the request data: ${e.message}`); } else { @@ -223,13 +206,12 @@ export default class FastifyReceiver implements Receiver { } // Parse request body - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: request bodies can be anything let body: any; try { body = httpFunc.parseHTTPRequestBody(bufferedReq); } catch (err) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const e = err as any; + const e = err as Error; this.logger.warn(`Malformed request body: ${e.message}`); httpFunc.buildNoBodyResponse(res, 400); return; @@ -289,7 +271,7 @@ export default class FastifyReceiver implements Receiver { }); } - public start(port: number = 3000): Promise { + public start(port = 3000): Promise { if (this.server !== undefined) { return Promise.reject( new ReceiverInconsistentStateError('The receiver cannot be started because it was already started.'), diff --git a/examples/custom-receiver/src/KoaReceiver.ts b/examples/custom-receiver/src/KoaReceiver.ts index 692e7ce87..5dfeee8ef 100644 --- a/examples/custom-receiver/src/KoaReceiver.ts +++ b/examples/custom-receiver/src/KoaReceiver.ts @@ -1,24 +1,22 @@ -/* eslint-disable node/no-extraneous-import */ -/* eslint-disable import/no-extraneous-dependencies */ -import { InstallProvider, CallbackOptions, InstallPathOptions } from '@slack/oauth'; -import { ConsoleLogger, LogLevel, Logger } from '@slack/logger'; +import { type Server, createServer } from 'node:http'; import Router from '@koa/router'; -import Koa from 'koa'; -import { Server, createServer } from 'http'; import { - App, - CodedError, - Receiver, - ReceiverEvent, + type App, + type BufferedIncomingMessage, + type CodedError, + HTTPResponseAck, + type InstallProviderOptions, + type InstallURLOptions, + type Receiver, + type ReceiverEvent, ReceiverInconsistentStateError, + type ReceiverProcessEventErrorHandlerArgs, + type ReceiverUnhandledRequestHandlerArgs, HTTPModuleFunctions as httpFunc, - HTTPResponseAck, - InstallProviderOptions, - InstallURLOptions, - BufferedIncomingMessage, - ReceiverProcessEventErrorHandlerArgs, - ReceiverUnhandledRequestHandlerArgs, } from '@slack/bolt'; +import { ConsoleLogger, type LogLevel, type Logger } from '@slack/logger'; +import { type CallbackOptions, type InstallPathOptions, InstallProvider } from '@slack/oauth'; +import Koa from 'koa'; export interface InstallerOptions { stateStore?: InstallProviderOptions['stateStore']; // default ClearStateStore @@ -36,6 +34,11 @@ export interface InstallerOptions { authorizationUrl?: InstallProviderOptions['authorizationUrl']; } +type CustomPropertiesExtractor = ( + request: BufferedIncomingMessage, + // biome-ignore lint/suspicious/noExplicitAny: custom app properties can be anything +) => Record; + export interface KoaReceiverOptions { signingSecret: string | (() => PromiseLike); logger?: Logger; @@ -52,13 +55,8 @@ export interface KoaReceiverOptions { installerOptions?: InstallerOptions; koa?: Koa; router?: Router; - customPropertiesExtractor?: ( - request: BufferedIncomingMessage - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ) => Record; - processEventErrorHandler?: ( - args: ReceiverProcessEventErrorHandlerArgs - ) => Promise; + customPropertiesExtractor?: CustomPropertiesExtractor; + processEventErrorHandler?: (args: ReceiverProcessEventErrorHandlerArgs) => Promise; // NOTE: As we use setTimeout under the hood, this cannot be async unhandledRequestHandler?: (args: ReceiverUnhandledRequestHandlerArgs) => void; unhandledRequestTimeoutMillis?: number; @@ -79,10 +77,7 @@ export default class KoaReceiver implements Receiver { private unhandledRequestTimeoutMillis: number; - private customPropertiesExtractor: ( - request: BufferedIncomingMessage - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ) => Record; + private customPropertiesExtractor: CustomPropertiesExtractor; private processEventErrorHandler: (args: ReceiverProcessEventErrorHandlerArgs) => Promise; @@ -103,15 +98,15 @@ export default class KoaReceiver implements Receiver { public constructor(options: KoaReceiverOptions) { this.signatureVerification = options.signatureVerification ?? true; this.signingSecretProvider = options.signingSecret; - this.customPropertiesExtractor = options.customPropertiesExtractor !== undefined ? - options.customPropertiesExtractor : - (_) => ({}); + this.customPropertiesExtractor = + options.customPropertiesExtractor !== undefined ? options.customPropertiesExtractor : (_) => ({}); this.path = options.path ?? '/slack/events'; this.unhandledRequestTimeoutMillis = options.unhandledRequestTimeoutMillis ?? 3001; this.koa = options.koa ?? new Koa(); this.router = options.router ?? new Router(); - this.logger = options.logger ?? + this.logger = + options.logger ?? (() => { const defaultLogger = new ConsoleLogger(); if (options.logLevel) { @@ -124,16 +119,10 @@ export default class KoaReceiver implements Receiver { this.unhandledRequestHandler = options.unhandledRequestHandler ?? httpFunc.defaultUnhandledRequestHandler; this.installerOptions = options.installerOptions; - if ( - this.installerOptions && - this.installerOptions.installPath === undefined - ) { + if (this.installerOptions && this.installerOptions.installPath === undefined) { this.installerOptions.installPath = '/slack/install'; } - if ( - this.installerOptions && - this.installerOptions.redirectUriPath === undefined - ) { + if (this.installerOptions && this.installerOptions.redirectUriPath === undefined) { this.installerOptions.redirectUriPath = '/slack/oauth_redirect'; } if (options.clientId && options.clientSecret) { @@ -159,9 +148,10 @@ export default class KoaReceiver implements Receiver { private async signingSecret(): Promise { if (this._signingSecret === undefined) { - this._signingSecret = typeof this.signingSecretProvider === 'string' ? - this.signingSecretProvider : - await this.signingSecretProvider(); + this._signingSecret = + typeof this.signingSecretProvider === 'string' + ? this.signingSecretProvider + : await this.signingSecretProvider(); } return this._signingSecret; } @@ -175,18 +165,10 @@ export default class KoaReceiver implements Receiver { this.installerOptions.redirectUriPath ) { this.router.get(this.installerOptions.installPath, async (ctx) => { - await this.installer?.handleInstallPath( - ctx.req, - ctx.res, - this.installerOptions?.installPathOptions, - ); + await this.installer?.handleInstallPath(ctx.req, ctx.res, this.installerOptions?.installPathOptions); }); this.router.get(this.installerOptions.redirectUriPath, async (ctx) => { - await this.installer?.handleCallback( - ctx.req, - ctx.res, - this.installerOptions?.callbackOptions, - ); + await this.installer?.handleCallback(ctx.req, ctx.res, this.installerOptions?.callbackOptions); }); } @@ -204,8 +186,7 @@ export default class KoaReceiver implements Receiver { req, ); } catch (err) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const e = err as any; + const e = err as Error; if (this.signatureVerification) { this.logger.warn(`Failed to parse and verify the request data: ${e.message}`); } else { @@ -216,13 +197,12 @@ export default class KoaReceiver implements Receiver { } // Parse request body - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: request bodies can be anything let body: any; try { body = httpFunc.parseHTTPRequestBody(bufferedReq); } catch (err) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const e = err as any; + const e = err as Error; this.logger.warn(`Malformed request body: ${e.message}`); httpFunc.buildNoBodyResponse(res, 400); return; @@ -282,7 +262,7 @@ export default class KoaReceiver implements Receiver { }); } - public start(port: number = 3000): Promise { + public start(port = 3000): Promise { // Enable routes this.koa.use(this.router.routes()).use(this.router.allowedMethods()); diff --git a/examples/custom-receiver/src/fastify-main.ts b/examples/custom-receiver/src/fastify-main.ts index 6449e275b..f4a6ccbfa 100644 --- a/examples/custom-receiver/src/fastify-main.ts +++ b/examples/custom-receiver/src/fastify-main.ts @@ -1,16 +1,13 @@ -/* eslint-disable no-param-reassign */ -/* eslint-disable import/no-internal-modules */ -/* eslint-disable import/no-extraneous-dependencies */ -/* eslint-disable import/extensions */ -/* eslint-disable node/no-extraneous-import */ -/* eslint-disable import/no-extraneous-dependencies */ - -import Fastify from 'fastify'; import { App, FileInstallationStore } from '@slack/bolt'; -import { FileStateStore } from '@slack/oauth'; import { ConsoleLogger, LogLevel } from '@slack/logger'; +import { FileStateStore } from '@slack/oauth'; +import Fastify from 'fastify'; import FastifyReceiver from './FastifyReceiver'; +if (!process.env.SLACK_SIGNING_SECRET) { + throw new Error('SLACK_SIGNING_SECRET environment variable not found!'); +} + const logger = new ConsoleLogger(); logger.setLevel(LogLevel.DEBUG); @@ -21,8 +18,7 @@ fastify.get('/', async (_, res) => { }); const receiver = new FastifyReceiver({ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - signingSecret: process.env.SLACK_SIGNING_SECRET!, + signingSecret: process.env.SLACK_SIGNING_SECRET, clientId: process.env.SLACK_CLIENT_ID, clientSecret: process.env.SLACK_CLIENT_SECRET, scopes: ['commands', 'chat:write', 'app_mentions:read'], diff --git a/examples/custom-receiver/src/koa-main.ts b/examples/custom-receiver/src/koa-main.ts index 0f87bc1a4..8855b8703 100644 --- a/examples/custom-receiver/src/koa-main.ts +++ b/examples/custom-receiver/src/koa-main.ts @@ -1,17 +1,14 @@ -/* eslint-disable no-param-reassign */ -/* eslint-disable import/no-internal-modules */ -/* eslint-disable import/no-extraneous-dependencies */ -/* eslint-disable import/extensions */ -/* eslint-disable node/no-extraneous-import */ -/* eslint-disable import/no-extraneous-dependencies */ - import Router from '@koa/router'; -import Koa from 'koa'; import { App, FileInstallationStore } from '@slack/bolt'; -import { FileStateStore } from '@slack/oauth'; import { ConsoleLogger, LogLevel } from '@slack/logger'; +import { FileStateStore } from '@slack/oauth'; +import Koa from 'koa'; import KoaReceiver from './KoaReceiver'; +if (!process.env.SLACK_SIGNING_SECRET) { + throw new Error('SLACK_SIGNING_SECRET environment variable not found!'); +} + const logger = new ConsoleLogger(); logger.setLevel(LogLevel.DEBUG); const koa = new Koa(); @@ -22,8 +19,7 @@ router.get('/', async (ctx) => { }); const receiver = new KoaReceiver({ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - signingSecret: process.env.SLACK_SIGNING_SECRET!, + signingSecret: process.env.SLACK_SIGNING_SECRET, clientId: process.env.SLACK_CLIENT_ID, clientSecret: process.env.SLACK_CLIENT_SECRET, scopes: ['commands', 'chat:write', 'app_mentions:read'], diff --git a/examples/custom-receiver/tsconfig.eslint.json b/examples/custom-receiver/tsconfig.eslint.json deleted file mode 100644 index d19325c71..000000000 --- a/examples/custom-receiver/tsconfig.eslint.json +++ /dev/null @@ -1,13 +0,0 @@ -// This config is only used to allow ESLint to use a different include / exclude setting than the actual build -{ - // extend the build config to share compilerOptions - "extends": "./tsconfig.json", - "compilerOptions": { - // Setting "noEmit" prevents misuses of this config such as using it to produce a build - "noEmit": true - }, - "include": [ - // Since extending a config overwrites the entire value for "include", those value are copied here - "src/**/*", - ] -} diff --git a/examples/custom-receiver/tsconfig.json b/examples/custom-receiver/tsconfig.json index 9bb02861c..1e3120461 100644 --- a/examples/custom-receiver/tsconfig.json +++ b/examples/custom-receiver/tsconfig.json @@ -1,18 +1,18 @@ { + "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { - "target": "es6", - "module": "commonjs", - "moduleResolution": "node", + "allowJs": true, + "allowSyntheticDefaultImports": true, "esModuleInterop": true, + "module": "CommonJS", + "moduleResolution": "node", "resolveJsonModule": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "allowJs": true, - "sourceMap": true, "rootDir": "src", + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "target": "es6", "outDir": "dist" }, - "include": [ - "src/**/*" - ] + "include": ["src/**/*"] } diff --git a/examples/deploy-aws-lambda/app.js b/examples/deploy-aws-lambda/app.js index 0e01d0d4f..11a81931a 100644 --- a/examples/deploy-aws-lambda/app.js +++ b/examples/deploy-aws-lambda/app.js @@ -25,29 +25,29 @@ app.message('hello', async ({ message, say }) => { await say({ blocks: [ { - "type": "section", - "text": { - "type": "mrkdwn", - "text": `Hey there <@${message.user}>!` + type: 'section', + text: { + type: 'mrkdwn', + text: `Hey there <@${message.user}>!`, }, - "accessory": { - "type": "button", - "text": { - "type": "plain_text", - "text": "Click Me" + accessory: { + type: 'button', + text: { + type: 'plain_text', + text: 'Click Me', }, - "action_id": "button_click" - } - } + action_id: 'button_click', + }, + }, ], - text: `Hey there <@${message.user}>!` + text: `Hey there <@${message.user}>!`, }); }); // Listens for an action from a button click app.action('button_click', async ({ body, ack, say }) => { await ack(); - + await say(`<@${body.user.id}> clicked the button`); }); @@ -61,4 +61,4 @@ app.message('goodbye', async ({ message, say }) => { module.exports.handler = async (event, context, callback) => { const handler = await awsLambdaReceiver.start(); return handler(event, context, callback); -} +}; diff --git a/examples/deploy-aws-lambda/package.json b/examples/deploy-aws-lambda/package.json index 15645cdab..a5268d8ad 100644 --- a/examples/deploy-aws-lambda/package.json +++ b/examples/deploy-aws-lambda/package.json @@ -9,7 +9,7 @@ }, "license": "MIT", "dependencies": { - "@slack/bolt": "^3.2.0" + "@slack/bolt": "^3" }, "devDependencies": { "serverless": "^2.13.0", diff --git a/examples/deploy-heroku/app.js b/examples/deploy-heroku/app.js index 16f8e5109..bc38cf450 100644 --- a/examples/deploy-heroku/app.js +++ b/examples/deploy-heroku/app.js @@ -3,7 +3,7 @@ const { App } = require('@slack/bolt'); // Initializes your app with your bot token and signing secret const app = new App({ token: process.env.SLACK_BOT_TOKEN, - signingSecret: process.env.SLACK_SIGNING_SECRET + signingSecret: process.env.SLACK_SIGNING_SECRET, }); // Listens to incoming messages that contain "hello" @@ -12,22 +12,22 @@ app.message('hello', async ({ message, say }) => { await say({ blocks: [ { - "type": "section", - "text": { - "type": "mrkdwn", - "text": `Hey there <@${message.user}>!` + type: 'section', + text: { + type: 'mrkdwn', + text: `Hey there <@${message.user}>!`, }, - "accessory": { - "type": "button", - "text": { - "type": "plain_text", - "text": "Click Me" + accessory: { + type: 'button', + text: { + type: 'plain_text', + text: 'Click Me', }, - "action_id": "button_click" - } - } + action_id: 'button_click', + }, + }, ], - text: `Hey there <@${message.user}>!` + text: `Hey there <@${message.user}>!`, }); }); @@ -48,4 +48,4 @@ app.message('goodbye', async ({ message, say }) => { await app.start(process.env.PORT || 3000); console.log('⚡️ Bolt app is running!'); -})(); \ No newline at end of file +})(); diff --git a/examples/deploy-heroku/package.json b/examples/deploy-heroku/package.json index d7fceaad7..043311b27 100644 --- a/examples/deploy-heroku/package.json +++ b/examples/deploy-heroku/package.json @@ -10,6 +10,6 @@ }, "license": "MIT", "dependencies": { - "@slack/bolt": "^3.2.0" + "@slack/bolt": "^3" } } diff --git a/examples/getting-started-typescript/src/app.ts b/examples/getting-started-typescript/src/app.ts index e5f944b65..f78d351e8 100644 --- a/examples/getting-started-typescript/src/app.ts +++ b/examples/getting-started-typescript/src/app.ts @@ -1,7 +1,5 @@ -/* eslint-disable no-console */ -/* eslint-disable import/no-internal-modules */ import './utils/env'; -import { App, LogLevel } from '@slack/bolt'; +import { App, type BlockButtonAction, LogLevel } from '@slack/bolt'; const app = new App({ token: process.env.SLACK_BOT_TOKEN, @@ -41,12 +39,11 @@ app.message('hello', async ({ message, say }) => { } }); -app.action('button_click', async ({ body, ack, say }) => { +app.action('button_click', async ({ body, ack, say }) => { // Acknowledge the action await ack(); // we know that this event comes from a button click from a message in a channel, so `say` will be available. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await say!(`<@${body.user.id}> clicked the button`); + await say(`<@${body.user.id}> clicked the button`); }); (async () => { diff --git a/examples/getting-started-typescript/src/basic.ts b/examples/getting-started-typescript/src/basic.ts index 97ab400dc..925e6af0e 100644 --- a/examples/getting-started-typescript/src/basic.ts +++ b/examples/getting-started-typescript/src/basic.ts @@ -1,8 +1,5 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -/* eslint-disable no-console */ -/* eslint-disable import/no-internal-modules */ import './utils/env'; -import { App, LogLevel, subtype, BotMessageEvent, BlockAction } from '@slack/bolt'; +import { App, type BlockAction, type types, LogLevel, subtype } from '@slack/bolt'; const app = new App({ token: process.env.SLACK_BOT_TOKEN, @@ -25,7 +22,7 @@ app.message(':wave:', async ({ message, say }) => { */ // Listens for messages containing "knock knock" and responds with an italicized "who's there?" app.message('knock knock', async ({ say }) => { - await say('_Who\'s there?_'); + await say("_Who's there?_"); }); // Sends a section block with datepicker when someone reacts with a 📅 emoji @@ -35,22 +32,24 @@ app.event('reaction_added', async ({ event, client }) => { await client.chat.postMessage({ text: 'Pick a reminder date', channel: event.item.channel, - blocks: [{ - type: 'section', - text: { - type: 'mrkdwn', - text: 'Pick a date for me to remind you', - }, - accessory: { - type: 'datepicker', - action_id: 'datepicker_remind', - initial_date: '2019-04-28', - placeholder: { - type: 'plain_text', - text: 'Select a date', + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: 'Pick a date for me to remind you', + }, + accessory: { + type: 'datepicker', + action_id: 'datepicker_remind', + initial_date: '2019-04-28', + placeholder: { + type: 'plain_text', + text: 'Select a date', + }, }, }, - }], + ], }); } }); @@ -75,7 +74,8 @@ app.event('team_join', async ({ event, client, logger }) => { }); app.message(subtype('bot_message'), async ({ message, logger }) => { - const botMessage = (message as BotMessageEvent); + // TODO: the need to cast here is due to https://github.com/slackapi/bolt-js/issues/796 + const botMessage = message as types.BotMessageEvent; logger.info(`The bot user ${botMessage.user} said ${botMessage.text}`); }); @@ -110,7 +110,8 @@ app.action('approve_button', async ({ ack }) => { // Your listener function will only be called when the action_id matches 'select_user' // AND the block_id matches 'assign_ticket' -app.action({ action_id: 'select_user', block_id: 'assign_ticket' }, +app.action( + { action_id: 'select_user', block_id: 'assign_ticket' }, async ({ body, client, ack, logger }) => { await ack(); try { @@ -125,17 +126,14 @@ app.action({ action_id: 'select_user', block_id: 'assign_ticket' }, } catch (error) { logger.error(error); } - }); + }, +); // Your middleware will be called every time an interactive component with the action_id “approve_button” is triggered -app.action('approve_button', async ({ ack, say }) => { +app.action('approve_button', async ({ ack, say }) => { // Acknowledge action request await ack(); - // `say` is possibly undefined because an action could come from a surface where we cannot post message, e.g. a view. - // we will use a non-null assertion (!) to tell TypeScript to ignore the fact it may be undefined, - // but take care about the originating surface for these events when using these utilities! - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await say!('Request approved 👍'); + await say('Request approved 👍'); }); (async () => { diff --git a/examples/getting-started-typescript/src/utils/env.ts b/examples/getting-started-typescript/src/utils/env.ts index ed0f69d75..56a77a528 100644 --- a/examples/getting-started-typescript/src/utils/env.ts +++ b/examples/getting-started-typescript/src/utils/env.ts @@ -1,5 +1,5 @@ // for details see https://github.com/motdotla/dotenv/blob/master/examples/typescript/ -import { resolve } from 'path'; +import { resolve } from 'node:path'; import { config } from 'dotenv'; const pathToConfig = '../../.env'; diff --git a/examples/getting-started-typescript/tsconfig.eslint.json b/examples/getting-started-typescript/tsconfig.eslint.json deleted file mode 100644 index d19325c71..000000000 --- a/examples/getting-started-typescript/tsconfig.eslint.json +++ /dev/null @@ -1,13 +0,0 @@ -// This config is only used to allow ESLint to use a different include / exclude setting than the actual build -{ - // extend the build config to share compilerOptions - "extends": "./tsconfig.json", - "compilerOptions": { - // Setting "noEmit" prevents misuses of this config such as using it to produce a build - "noEmit": true - }, - "include": [ - // Since extending a config overwrites the entire value for "include", those value are copied here - "src/**/*", - ] -} diff --git a/examples/getting-started-typescript/tsconfig.json b/examples/getting-started-typescript/tsconfig.json index 2347fd08d..e6a379f01 100644 --- a/examples/getting-started-typescript/tsconfig.json +++ b/examples/getting-started-typescript/tsconfig.json @@ -8,7 +8,5 @@ "rootDir": "src", "outDir": "dist" }, - "include": [ - "src/**/*" - ] + "include": ["src/**/*"] } diff --git a/examples/hubot-example/README.md b/examples/hubot-example/README.md deleted file mode 100644 index 3a7cf933a..000000000 --- a/examples/hubot-example/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Hubot example - -The [Hubot](https://hubot.github.com/) bot framework introduces you to its capabilities using a helpful -[example script](https://github.com/hubotio/generator-hubot/blob/master/generators/app/templates/scripts/example.js). -Bolt has many of the same capabilities. Whether you're migrating your Hubot to Bolt, or are looking for examples on -how Bolt might handle some common tasks, this example helps you understand Bolt a little better. - -## Set up - -Before running this example app, you'll need to [create a Slack app](https://api.slack.com/apps?new_app=1), configure -it, and install it into a development workspace. - -1. **Add a Bot user**. Choose any display name and user name you like. -2. **Enable the Events API**. Input a Request URL, wait for it to be verified, and save it. This step may require -[getting a public URL that can be used for development](https://slack.dev/node-slack-sdk/tutorials/local-development). -The app will be listening on the path `/slack/events`. -3. **Subscribe to Bot Events**: `app_mention`, `member_joined_channel`, `member_left_channel`, `message.channels`, -`message.groups`, `message.im`, `message.mpim`. -4. **Install the app to the development workspace**. - -Once these steps are complete, you should have a Bot User access token and the Signing Secret. These values will be -used below. - -## Run the app - -1. Clone this repository: `git clone https://github.com/slackapi/bolt.git` - -2. Install dependencies and build: `cd bolt; npm install` - -3. Start the app, substituting your own values into the environment variables: - -```shell -$ SLACK_SIGNING_SECRET= SLACK_BOT_TOKEN= HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING=42 node examples/hubot-example/script.js -``` - -## Try it out - -Read through the various examples in `script.js` in this directory. Try messaging the bot user to test how each listener -behaves. diff --git a/examples/hubot-example/script.js b/examples/hubot-example/script.js deleted file mode 100644 index 86c2e51e0..000000000 --- a/examples/hubot-example/script.js +++ /dev/null @@ -1,297 +0,0 @@ -// Exercise: port the Hubot example script to the Bolt API. -// -// The commented sections are from the Hubot example script, while the uncommented sections below them are the -// same functionality using Bolt. - -// module.exports = (robot) => { - -// ===================================== -// === Variable Declarations === -// ===================================== - -// Create a constant with Greetings you can add more to this if you like by putting more in if you wish -// Just follow the syntax below -const enterReplies = ['Hi', 'Target Acquired', 'Firing', 'Hello friend.', 'Gotcha', 'I see you'] -// Create a constant with Leave Replies you can add more to this if you like by putting more in if you wish -// Just follow the syntax below -const leaveReplies = ['Are you still there?', 'Target lost', 'Searching'] - -// LOL responses again you can add more following this convention below -const lulz = ['lol', 'rofl', 'lmao']; - -// Grab a random LOL value -const randomLulz = () => lulz[Math.floor(Math.random() * lulz.length)]; -// Grab a random greeting from above -const randomEnterReply = () => enterReplies[Math.floor(Math.random() * enterReplies.length)]; -// Grab a random Leave Reply -const randomLeaveReply = () => leaveReplies[Math.floor(Math.random() * leaveReplies.length)]; - -// This is pulled from your .env file so if you can change the answer in this file - -const answer = process.env.HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING - -// Let annoyIntervalId be null to start - -let annoyIntervalId = null - -//Pull in the .env file for use in this file -require('dotenv').config() - -// Create a constant with App and Direct mention methods - -const { App, directMention } = require('../../dist'); -const app = new App({ - signingSecret: process.env.SLACK_SIGNING_SECRET, - token: process.env.SLACK_BOT_TOKEN, -}); - -(async () => { - //Start up the app - const server = await app.start(process.env.PORT || 3000); - console.log('⚡️ Bolt app is running!', server.address()); -})(); - - // robot.hear(/badger/i, (res) => { - // res.send('Badgers? BADGERS? WE DON’T NEED NO STINKIN BADGERS') - // }) - -// If someone says badgers the bot responds with Badgers? BADGERS? WE DON’T NEED NO STINKIN BADGERS -app.message('badger', async ({ say }) => { await say('Badgers? BADGERS? WE DON’T NEED NO STINKIN BADGERS'); }); - - // robot.respond(/open the (.*) doors/i, (res) => { - // const doorType = res.match[1] - // - // if (doorType === 'pod bay') { - // res.reply('I’m afraid I can’t let you do that.') - // return - // } - // - // res.reply('Opening #{doorType} doors') - // }) - -// I never go this one to work maybe you need to say open the pod bay? - -app.message(/open the (.*) doors/i, async ({ say, context }) => { - const doorType = context.matches[1]; - - const text = (doorType === 'pod bay') ? - 'I’m afraid I can’t let you do that.' : - `Opening ${doorType} doors`; - - await say(text); -}); - - // robot.hear(/I like pie/i, (res) => { - // res.emote('makes a freshly baked pie') - // }) - -// If you say I like pie the bot responds with pie emoji -app.message('I like pie', async ({ message, context }) => { - try { - await app.client.reactions.add({ - token: context.botToken, - name: 'pie', - channel: message.channel, - timestamp: message.ts, - }); - } catch (error) { - console.error(error); - } -}); - - - - // robot.respond(`/${lulz.join('|')}/i`, (res) => { - // res.send(res.random(lulz)) - // }) - - - -// If someone says lol it will respond with a random response. You could change this to directMention -// This would make the bot only respond if you @botname lol perhaps this is annoying - -app.event('app_mention', ({ say }) => say(randomLulz())); -// OR -// app.message(directMention(), ({ say }) => say(randomLulz())); - - // robot.topic((res) => { - // res.send(`${res.message.text}? That’s a Paddlin`) - // }) - - // 🚫 there's no Events API event type for channel topic changed. - - - // robot.enter((res) => { - // res.send(res.random(enterReplies)) - // }) - // robot.leave((res) => { - // res.send(res.random(leaveReplies)) - // }) - - -// If a new user enters the chat respond with a random greeting -app.event('member_joined_channel', async ({ say }) => { await say(randomEnterReply()); }); - -// If a user leaves respond with a random Leave reply -app.event('member_left_channel', async ({ say }) => { await say(randomLeaveReply()); }); - - - // robot.respond(/what is the answer to the ultimate question of life/, (res) => { - // if (answer) { - // res.send(`${answer}, but what is the question?`) - // return - // } - // - // res.send('Missing HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING in environment: please set and try again') - // }) - -// If you ask what is the answer to the ultimate question of life it will respond with what is in your .env file -app.message( - directMention(), - 'what is the answer to the ultimate question of life', - async ({ say }) => { - if (answer) { await say(`${answer}, but what is the question?`); } - }); - - // robot.respond(/you are a little slow/, (res) => { - // setTimeout(() => res.send('Who you calling "slow"?'), 60 * 1000) - // }) - -// If you are a little slow it will respond in 60 * 1000 seconds with Who you calling "slow"? -app.message('you are a little slow', async ({ say, context }) => { - setTimeout(async function() { await say(`Who you calling "_slow_"`) }, 60 * 1000); -}); - -//end listening for someone to say the bot is slow - // robot.respond(/annoy me/, (res) => { - // if (annoyIntervalId) { - // res.send('AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH') - // return - // } - // - // res.send('Hey, want to hear the most annoying sound in the world?') - // annoyIntervalId = setInterval(() => res.send('AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH'), 1000) - // }) - // - // robot.respond(/unannoy me/, (res) => { - // if (!annoyIntervalId) { - // res.send('Not annoying you right now, am I?') - // return - // } - // - // res.send('OKAY, OKAY, OKAY!') - // clearInterval(annoyIntervalId) - // annoyIntervalId = null - // }) - -// This example is quite annoying to say the least if you @botname annoy me -// It will annoy you with AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH -// Until you tell it to stop with @botname unannoy me - -app.message(directMention(), /(? { - if (annoyIntervalId) { - await say('AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH'); - return; - } - - await say('Hey, want to hear the most annoying sound in the world?'); - annoyIntervalId = setInterval(() => { - say('AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH'); - }, 1000); - }); - -app.message(directMention(), 'unannoy me', async ({ say }) => { - if (!annoyIntervalId) { - await say('Not annoying you right now, am I?'); - return; - } - await say('OKAY, OKAY, OKAY!'); - clearInterval(annoyIntervalId); - annoyIntervalId = null; - }); - - // robot.router.post('/hubot/chatsecrets/:room', (req, res) => { - // const room = req.params.room - // const data = JSON.parse(req.body.payload) - // const secret = data.secret - // - // robot.messageRoom(room, `I have a secret: ${secret}`) - // - // res.send('OK') - // }) - - // 🚫 stand up your own express router - - // robot.error((error, response) => { - // const message = `DOES NOT COMPUTE: ${error.toString()}` - // robot.logger.error(message) - // - // if (response) { - // response.reply(message) - // } - // }) - -// Possibly not needed the built in error handler will output errors to the console anyway -// Could use a try{} catch{} around something where you wish to halt the program if an error occurs - -app.error(async (error) => { - // Check the details of the error to handle cases where you should retry sending a message or stop the app - const message = `DOES NOT COMPUTE: ${error.toString()}`; - console.error(message); -}); - - // 🚫 no reply handling from global error handler -}); - - // robot.respond(/have a soda/i, (response) => { - // // Get number of sodas had (coerced to a number). - // const sodasHad = +robot.brain.get('totalSodas') || 0 - // - // if (sodasHad > 4) { - // response.reply('I’m too fizzy…') - // return - // } - // - // response.reply('Sure!') - // robot.brain.set('totalSodas', sodasHad + 1) - // }) - // - // robot.respond(/sleep it off/i, (res) => { - // robot.brain.set('totalSodas', 0) - // res.reply('zzzzz') - // }) - -// NOTE: In a real application, you should provide a convoStore option to the App constructor. The default convoStore -// only persists data to memory, so its lost when the process terminates. -// This example really does not work without a conversation store for me it just keeps saying Sure! -// It should after 4 requests to have a soda it should say I'm to fizzy.. - -app.message(directMention(), 'have a soda', async ({ context, say }) => { - // Initialize conversation - const conversation = context.conversation !== undefined ? context.conversation : {}; - - // Initialize data for this listener - conversation.sodasHad = conversation.sodasHad !== undefined ? conversation.sodasHad : 0; - - if (conversation.sodasHad > 4) { - await say('I\'m too fizzy...'); - return; - } - - await say('Sure!'); - conversation.sodasHad += 1; - try { - await context.updateConversation(conversation); - } catch (error) { - console.error(error); - } -}); -// if you say @botnam sleep it off. It responds with zzzz -app.message(directMention(), 'sleep it off', async ({ context, say }) => { - try { - await context.updateConversation({ ...context.conversation, sodasHad: 0 }); - await say('zzzzz'); - } catch (error) { - console.error(error); - } -}); diff --git a/examples/message-metadata/app-manifest.json b/examples/message-metadata/app-manifest.json index 911ec2f07..454f5b930 100644 --- a/examples/message-metadata/app-manifest.json +++ b/examples/message-metadata/app-manifest.json @@ -1,51 +1,41 @@ { "display_information": { - "name": "Message Metadata Example" + "name": "Message Metadata Example" }, "features": { - "bot_user": { - "display_name": "Message Metadata Bot", - "always_online": false - }, - "slash_commands": [ - { - "command": "/post", - "description": "Post Message Metadata", - "should_escape": false - } - ] + "bot_user": { + "display_name": "Message Metadata Bot", + "always_online": false + }, + "slash_commands": [ + { + "command": "/post", + "description": "Post Message Metadata", + "should_escape": false + } + ] }, "oauth_config": { - "redirect_urls": [ - "https://localhost" - ], - "scopes": { - "bot": [ - "metadata.message:read", - "chat:write", - "commands" - ] - } + "redirect_urls": ["https://localhost"], + "scopes": { + "bot": ["metadata.message:read", "chat:write", "commands"] + } }, "settings": { - "event_subscriptions": { - "bot_events": [ - "message_metadata_deleted", - "message_metadata_posted", - "message_metadata_updated" - ], - "metadata_subscriptions": [ - { - "app_id": "[app id]", - "event_type": "my_event" - } - ] - }, - "interactivity": { - "is_enabled": true - }, - "org_deploy_enabled": false, - "socket_mode_enabled": true, - "token_rotation_enabled": false + "event_subscriptions": { + "bot_events": ["message_metadata_deleted", "message_metadata_posted", "message_metadata_updated"], + "metadata_subscriptions": [ + { + "app_id": "[app id]", + "event_type": "my_event" + } + ] + }, + "interactivity": { + "is_enabled": true + }, + "org_deploy_enabled": false, + "socket_mode_enabled": true, + "token_rotation_enabled": false } } diff --git a/examples/message-metadata/app.js b/examples/message-metadata/app.js index 5143ac8af..fad0bd4bb 100644 --- a/examples/message-metadata/app.js +++ b/examples/message-metadata/app.js @@ -4,7 +4,7 @@ const app = new App({ token: process.env.SLACK_BOT_TOKEN, appToken: process.env.SLACK_APP_TOKEN, socketMode: true, - logLevel: LogLevel.DEBUG + logLevel: LogLevel.DEBUG, }); (async () => { @@ -12,44 +12,43 @@ const app = new App({ console.log('⚡️ Bolt app started'); })(); - // Listen to slash command // Post a message with Message Metadata -app.command('/post', async ({ ack, command, say }) => { +app.command('/post', async ({ ack, say }) => { await ack(); await say({ - text: "Message Metadata Posting", + text: 'Message Metadata Posting', metadata: { - "event_type": "my_event", - "event_payload": { - "key": "value" - } - } + event_type: 'my_event', + event_payload: { + key: 'value', + }, + }, }); }); app.event('message_metadata_posted', async ({ event, say }) => { const { message_ts: thread_ts } = event; await say({ - text: "Message Metadata Posted", + text: 'Message Metadata Posted', blocks: [ { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "Message Metadata Posted" - } + type: 'section', + text: { + type: 'mrkdwn', + text: 'Message Metadata Posted', + }, }, { - "type": "context", - "elements": [ + type: 'context', + elements: [ { - "type": "mrkdwn", - "text": `${JSON.stringify(event.metadata)}` - } - ] - } + type: 'mrkdwn', + text: `${JSON.stringify(event.metadata)}`, + }, + ], + }, ], - thread_ts - }) + thread_ts, + }); }); diff --git a/examples/oauth-express-receiver/app.js b/examples/oauth-express-receiver/app.js index 07241c8c1..779812ba9 100644 --- a/examples/oauth-express-receiver/app.js +++ b/examples/oauth-express-receiver/app.js @@ -1,7 +1,7 @@ const { App, ExpressReceiver, LogLevel, FileInstallationStore } = require('@slack/bolt'); // Create an ExpressReceiver -const receiver = new ExpressReceiver({ +const receiver = new ExpressReceiver({ signingSecret: process.env.SLACK_SIGNING_SECRET, clientId: process.env.SLACK_CLIENT_ID, clientSecret: process.env.SLACK_CLIENT_SECRET, diff --git a/examples/oauth/app.js b/examples/oauth/app.js index 289d09695..d45f96d12 100644 --- a/examples/oauth/app.js +++ b/examples/oauth/app.js @@ -63,7 +63,7 @@ const app = new App({ // without rendering the web page with "Add to Slack" button. // This flag is available in @slack/bolt v3.7 or higher // directInstall: true, - } + }, }); (async () => { diff --git a/examples/socket-mode-oauth/package.json b/examples/socket-mode-oauth/package.json index 3cc238b7b..3b8ae34fa 100644 --- a/examples/socket-mode-oauth/package.json +++ b/examples/socket-mode-oauth/package.json @@ -12,4 +12,4 @@ "dependencies": { "@slack/bolt": "^3.10.0" } -} \ No newline at end of file +} diff --git a/examples/socket-mode/app.js b/examples/socket-mode/app.js index 6b1a01e72..174764880 100644 --- a/examples/socket-mode/app.js +++ b/examples/socket-mode/app.js @@ -42,17 +42,17 @@ app.event('app_home_opened', async ({ event, client }) => { await client.views.publish({ user_id: event.user, view: { - "type": "home", - "blocks": [ + type: 'home', + blocks: [ { - "type": "section", - "block_id": "section678", - "text": { - "type": "mrkdwn", - "text": "App Home Published" + type: 'section', + block_id: 'section678', + text: { + type: 'mrkdwn', + text: 'App Home Published', }, - } - ] + }, + ], }, }); }); @@ -75,65 +75,64 @@ app.shortcut('launch_shortcut', async ({ shortcut, body, ack, context, client }) const result = await client.views.open({ trigger_id: shortcut.trigger_id, view: { - type: "modal", + type: 'modal', title: { - type: "plain_text", - text: "My App" + type: 'plain_text', + text: 'My App', }, close: { - type: "plain_text", - text: "Close" + type: 'plain_text', + text: 'Close', }, blocks: [ { - type: "section", + type: 'section', text: { - type: "mrkdwn", - text: "About the simplest modal you could conceive of :smile:\n\nMaybe or ." - } + type: 'mrkdwn', + text: 'About the simplest modal you could conceive of :smile:\n\nMaybe or .', + }, }, { - type: "context", + type: 'context', elements: [ { - type: "mrkdwn", - text: "Psssst this modal was designed using " - } - ] - } - ] - } + type: 'mrkdwn', + text: 'Psssst this modal was designed using ', + }, + ], + }, + ], + }, }); } catch (error) { console.error(error); } }); - // subscribe to 'app_mention' event in your App config // need app_mentions:read and chat:write scopes app.event('app_mention', async ({ event, context, client, say }) => { try { await say({ - "blocks": [ + blocks: [ { - "type": "section", - "text": { - "type": "mrkdwn", - "text": `Thanks for the mention <@${event.user}>! Click my fancy button` + type: 'section', + text: { + type: 'mrkdwn', + text: `Thanks for the mention <@${event.user}>! Click my fancy button`, }, - "accessory": { - "type": "button", - "text": { - "type": "plain_text", - "text": "Button", - "emoji": true + accessory: { + type: 'button', + text: { + type: 'plain_text', + text: 'Button', + emoji: true, }, - "value": "click_me_123", - "action_id": "first_button" - } - } - ] + value: 'click_me_123', + action_id: 'first_button', + }, + }, + ], }); } catch (error) { console.error(error); @@ -146,25 +145,25 @@ app.message('hello', async ({ message, say }) => { // say() sends a message to the channel where the event was triggered // no need to directly use 'chat.postMessage', no need to include token await say({ - "blocks": [ + blocks: [ { - "type": "section", - "text": { - "type": "mrkdwn", - "text": `Thanks for the mention <@${message.user}>! Click my fancy button` + type: 'section', + text: { + type: 'mrkdwn', + text: `Thanks for the mention <@${message.user}>! Click my fancy button`, }, - "accessory": { - "type": "button", - "text": { - "type": "plain_text", - "text": "Button", - "emoji": true + accessory: { + type: 'button', + text: { + type: 'plain_text', + text: 'Button', + emoji: true, }, - "value": "click_me_123", - "action_id": "first_button" - } - } - ] + value: 'click_me_123', + action_id: 'first_button', + }, + }, + ], }); }); diff --git a/package.json b/package.json index c073fa18e..d49b793c7 100644 --- a/package.json +++ b/package.json @@ -27,12 +27,12 @@ "scripts": { "prepare": "npm run build", "build": "npm run build:clean && tsc", - "build:clean": "shx rm -rf ./dist ./coverage ./.nyc_output", - "lint": "eslint --fix --ext .ts src", - "mocha": "TS_NODE_PROJECT=tsconfig.json nyc mocha --config .mocharc.json \"src/**/*.spec.ts\"", - "test": "npm run build && npm run lint && npm run mocha && npm run test:types", - "test:coverage": "npm run mocha && nyc report --reporter=text", - "test:types": "tsd", + "build:clean": "shx rm -rf ./dist ./coverage", + "lint": "npx @biomejs/biome check --write docs src test examples", + "test": "npm run build && npm run lint && npm run test:types && npm run test:coverage", + "test:unit": "TS_NODE_PROJECT=tsconfig.json mocha --config test/unit/.mocharc.json", + "test:coverage": "c8 npm run test:unit", + "test:types": "tsd --files test/types", "watch": "npx nodemon --watch 'src' --ext 'ts' --exec npm run build" }, "repository": "slackapi/bolt", @@ -54,26 +54,16 @@ "tsscmp": "^1.0.6" }, "devDependencies": { + "@biomejs/biome": "^1.9.0", "@tsconfig/node18": "^18.2.4", "@types/chai": "^4.1.7", "@types/mocha": "^10.0.1", "@types/node": "22.7.5", "@types/sinon": "^7.0.11", "@types/tsscmp": "^1.0.0", - "@typescript-eslint/eslint-plugin": "^6", - "@typescript-eslint/parser": "^6", + "c8": "^10.1.2", "chai": "~4.3.0", - "eslint": "^7.26.0", - "eslint-config-airbnb-base": "^14.2.1", - "eslint-config-airbnb-typescript": "^12.3.1", - "eslint-plugin-import": "^2.28.0", - "eslint-plugin-jsdoc": "^30.6.1", - "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-node": "^11.1.0", - "eslint-plugin-react": "^7.29.3", - "eslint-plugin-react-hooks": "^4.3.0", "mocha": "^10.2.0", - "nyc": "^15.1.0", "rewiremock": "^3.13.4", "shx": "^0.3.2", "sinon": "^18.0.1", @@ -81,8 +71,5 @@ "ts-node": "^10.9.2", "tsd": "^0.31.2", "typescript": "5.3.3" - }, - "tsd": { - "directory": "types-tests" } } diff --git a/src/App-basic-features.spec.ts b/src/App-basic-features.spec.ts deleted file mode 100644 index c9ad324ee..000000000 --- a/src/App-basic-features.spec.ts +++ /dev/null @@ -1,1329 +0,0 @@ -import 'mocha'; -import sinon, { SinonSpy } from 'sinon'; -import { assert } from 'chai'; -import rewiremock from 'rewiremock'; -import { LogLevel } from '@slack/logger'; -import { WebClientOptions, WebClient } from '@slack/web-api'; -import { Override, mergeOverrides, createFakeLogger } from './test-helpers'; -import { ErrorCode } from './errors'; -import { - Receiver, - ReceiverEvent, - SayFn, - NextFn, -} from './types'; -import { ConversationStore } from './conversation-store'; -import App from './App'; -import SocketModeReceiver from './receivers/SocketModeReceiver'; - -// Utility functions -const noop = () => Promise.resolve(undefined); -const noopMiddleware = async ({ next }: { next: NextFn }) => { - await next(); -}; -const noopAuthorize = () => Promise.resolve({}); - -// Fakes -class FakeReceiver implements Receiver { - private bolt: App | undefined; - - public init = (bolt: App) => { - this.bolt = bolt; - }; - - public start = sinon.fake((...params: any[]): Promise => Promise.resolve([...params])); - - public stop = sinon.fake((...params: any[]): Promise => Promise.resolve([...params])); - - public async sendEvent(event: ReceiverEvent): Promise { - return this.bolt?.processEvent(event); - } -} - -// Dummies (values that have no real behavior but pass through the system opaquely) -function createDummyReceiverEvent(type: string = 'dummy_event_type'): ReceiverEvent { - // NOTE: this is a degenerate ReceiverEvent that would successfully pass through the App. it happens to look like a - // IncomingEventType.Event - return { - body: { - event: { - type, - }, - }, - ack: noop, - }; -} - -const fakeAppToken = 'xapp-1234'; - -describe('App basic features', () => { - describe('constructor', () => { - describe('with a custom port value in HTTP Mode', () => { - const fakeBotId = 'B_FAKE_BOT_ID'; - const fakeBotUserId = 'U_FAKE_BOT_USER_ID'; - const overrides = mergeOverrides( - withNoopAppMetadata(), - withSuccessfulBotUserFetchingWebClient(fakeBotId, fakeBotUserId), - ); - it('should accept a port value at the top-level', async () => { - // Arrange - const MockApp = await importApp(overrides); - // Act - const app = new MockApp({ token: '', signingSecret: '', port: 9999 }); - // Assert - assert.equal((app as any).receiver.port, 9999); - }); - it('should accept a port value under installerOptions', async () => { - // Arrange - const MockApp = await importApp(overrides); - // Act - const app = new MockApp({ token: '', signingSecret: '', port: 7777, installerOptions: { port: 9999 } }); - // Assert - assert.equal((app as any).receiver.port, 9999); - }); - }); - - describe('with a custom port value in Socket Mode', () => { - const fakeBotId = 'B_FAKE_BOT_ID'; - const fakeBotUserId = 'U_FAKE_BOT_USER_ID'; - const installationStore = { - storeInstallation: async () => { }, - fetchInstallation: async () => { throw new Error('Failed fetching installation'); }, - deleteInstallation: async () => { }, - }; - const overrides = mergeOverrides( - withNoopAppMetadata(), - withSuccessfulBotUserFetchingWebClient(fakeBotId, fakeBotUserId), - ); - it('should accept a port value at the top-level', async () => { - // Arrange - const MockApp = await importApp(overrides); - // Act - const app = new MockApp({ - socketMode: true, - appToken: fakeAppToken, - port: 9999, - clientId: '', - clientSecret: '', - stateSecret: '', - installerOptions: { - }, - installationStore, - }); - // Assert - assert.equal((app as any).receiver.httpServerPort, 9999); - }); - it('should accept a port value under installerOptions', async () => { - // Arrange - const MockApp = await importApp(overrides); - // Act - const app = new MockApp({ - socketMode: true, - appToken: fakeAppToken, - port: 7777, - clientId: '', - clientSecret: '', - stateSecret: '', - installerOptions: { - port: 9999, - }, - installationStore, - }); - // Assert - assert.equal((app as any).receiver.httpServerPort, 9999); - }); - }); - - // TODO: test when the single team authorization results fail. that should still succeed but warn. it also means - // that the `ignoreSelf` middleware will fail (or maybe just warn) a bunch. - describe('with successful single team authorization results', () => { - it('should succeed with a token for single team authorization', async () => { - // Arrange - const fakeBotId = 'B_FAKE_BOT_ID'; - const fakeBotUserId = 'U_FAKE_BOT_USER_ID'; - const overrides = mergeOverrides( - withNoopAppMetadata(), - withSuccessfulBotUserFetchingWebClient(fakeBotId, fakeBotUserId), - ); - const MockApp = await importApp(overrides); - - // Act - const app = new MockApp({ token: '', signingSecret: '' }); - - // Assert - // TODO: verify that the fake bot ID and fake bot user ID are retrieved - assert.instanceOf(app, MockApp); - }); - it('should pass the given token to app.client', async () => { - // Arrange - const fakeBotId = 'B_FAKE_BOT_ID'; - const fakeBotUserId = 'U_FAKE_BOT_USER_ID'; - const overrides = mergeOverrides( - withNoopAppMetadata(), - withSuccessfulBotUserFetchingWebClient(fakeBotId, fakeBotUserId), - ); - const MockApp = await importApp(overrides); - - // Act - const app = new MockApp({ token: 'xoxb-foo-bar', signingSecret: '' }); - - // Assert - assert.isDefined(app.client); - assert.equal(app.client.token, 'xoxb-foo-bar'); - }); - }); - it('should succeed with an authorize callback', async () => { - // Arrange - const authorizeCallback = sinon.fake(); - const MockApp = await importApp(); - - // Act - const app = new MockApp({ authorize: authorizeCallback, signingSecret: '' }); - - // Assert - assert(authorizeCallback.notCalled, 'Should not call the authorize callback on instantiation'); - assert.instanceOf(app, MockApp); - }); - it('should fail without a token for single team authorization, authorize callback, nor oauth installer', async () => { - // Arrange - const MockApp = await importApp(); - - // Act - try { - new MockApp({ signingSecret: '' }); // eslint-disable-line no-new - assert.fail(); - } catch (error: any) { - // Assert - assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); - } - }); - it('should fail when both a token and authorize callback are specified', async () => { - // Arrange - const authorizeCallback = sinon.fake(); - const MockApp = await importApp(); - - // Act - try { - new MockApp({ token: '', authorize: authorizeCallback, signingSecret: '' }); // eslint-disable-line no-new - assert.fail(); - } catch (error: any) { - // Assert - assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); - assert(authorizeCallback.notCalled); - } - }); - it('should fail when both a token is specified and OAuthInstaller is initialized', async () => { - // Arrange - const authorizeCallback = sinon.fake(); - const MockApp = await importApp(); - - // Act - try { - new MockApp({ token: '', clientId: '', clientSecret: '', stateSecret: '', signingSecret: '' }); // eslint-disable-line no-new - assert.fail(); - } catch (error: any) { - // Assert - assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); - assert(authorizeCallback.notCalled); - } - }); - it('should fail when both a authorize callback is specified and OAuthInstaller is initialized', async () => { - // Arrange - const authorizeCallback = sinon.fake(); - const MockApp = await importApp(); - - // Act - try { - new MockApp({ authorize: authorizeCallback, clientId: '', clientSecret: '', stateSecret: '', signingSecret: '' }); // eslint-disable-line no-new - assert.fail(); - } catch (error: any) { - // Assert - assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); - assert(authorizeCallback.notCalled); - } - }); - describe('with a custom receiver', () => { - it('should succeed with no signing secret', async () => { - // Arrange - const MockApp = await importApp(); - - // Act - const app = new MockApp({ receiver: new FakeReceiver(), authorize: noopAuthorize }); - - // Assert - assert.instanceOf(app, MockApp); - }); - }); - it('should fail when no signing secret for the default receiver is specified', async () => { - // Arrange - const MockApp = await importApp(); - - // Act - try { - new MockApp({ authorize: noopAuthorize }); // eslint-disable-line no-new - assert.fail(); - } catch (error: any) { - // Assert - assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); - } - }); - it('should fail when both socketMode and a custom receiver are specified', async () => { - // Arrange - const fakeReceiver = new FakeReceiver(); - const MockApp = await importApp(); - - // Act - try { - new MockApp({ token: '', signingSecret: '', socketMode: true, receiver: fakeReceiver }); // eslint-disable-line no-new - assert.fail(); - } catch (error: any) { - // Assert - assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); - } - }); - it('should succeed when both socketMode and SocketModeReceiver are specified', async () => { - // Arrange - const fakeBotId = 'B_FAKE_BOT_ID'; - const fakeBotUserId = 'U_FAKE_BOT_USER_ID'; - const overrides = mergeOverrides( - withNoopAppMetadata(), - withSuccessfulBotUserFetchingWebClient(fakeBotId, fakeBotUserId), - ); - const MockApp = await importApp(overrides); - const socketModeReceiver = new SocketModeReceiver({ appToken: fakeAppToken }); - - // Act - const app = new MockApp({ token: '', signingSecret: '', socketMode: true, receiver: socketModeReceiver }); - - // Assert - assert.instanceOf(app, MockApp); - }); - it('should initialize MemoryStore conversation store by default', async () => { - // Arrange - const fakeMemoryStore = sinon.fake(); - const fakeConversationContext = sinon.fake.returns(noopMiddleware); - const overrides = mergeOverrides( - withNoopAppMetadata(), - withNoopWebClient(), - withMemoryStore(fakeMemoryStore), - withConversationContext(fakeConversationContext), - ); - const MockApp = await importApp(overrides); - - // Act - const app = new MockApp({ authorize: noopAuthorize, signingSecret: '' }); - - // Assert - assert.instanceOf(app, MockApp); - assert(fakeMemoryStore.calledWithNew); - assert(fakeConversationContext.called); - }); - it('should initialize without a conversation store when option is false', async () => { - // Arrange - const fakeConversationContext = sinon.fake.returns(noopMiddleware); - const overrides = mergeOverrides( - withNoopAppMetadata(), - withNoopWebClient(), - withConversationContext(fakeConversationContext), - ); - const MockApp = await importApp(overrides); - - // Act - const app = new MockApp({ convoStore: false, authorize: noopAuthorize, signingSecret: '' }); - - // Assert - assert.instanceOf(app, MockApp); - assert(fakeConversationContext.notCalled); - }); - describe('with a custom conversation store', () => { - it('should initialize the conversation store', async () => { - // Arrange - const fakeConversationContext = sinon.fake.returns(noopMiddleware); - const overrides = mergeOverrides( - withNoopAppMetadata(), - withNoopWebClient(), - withConversationContext(fakeConversationContext), - ); - const dummyConvoStore = Symbol() as unknown as ConversationStore; - const MockApp = await importApp(overrides); - - // Act - const app = new MockApp({ convoStore: dummyConvoStore, authorize: noopAuthorize, signingSecret: '' }); - - // Assert - assert.instanceOf(app, MockApp); - assert(fakeConversationContext.firstCall.calledWith(dummyConvoStore)); - }); - }); - describe('with custom redirectUri supplied', () => { - it('should fail when missing installerOptions', async () => { - // Arrange - const MockApp = await importApp(); - - // Act - try { - new MockApp({ token: '', signingSecret: '', redirectUri: 'http://example.com/redirect' }); // eslint-disable-line no-new - assert.fail(); - } catch (error: any) { - // Assert - assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); - } - }); - it('should fail when missing installerOptions.redirectUriPath', async () => { - // Arrange - const MockApp = await importApp(); - - // Act - try { - new MockApp({ token: '', signingSecret: '', redirectUri: 'http://example.com/redirect', installerOptions: {} }); // eslint-disable-line no-new - assert.fail(); - } catch (error: any) { - // Assert - assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); - } - }); - }); - it('with clientOptions', async () => { - const fakeConstructor = sinon.fake(); - const overrides = mergeOverrides(withNoopAppMetadata(), { - '@slack/web-api': { - WebClient: class { - public constructor() { - fakeConstructor(...arguments); // eslint-disable-line prefer-rest-params - } - }, - }, - }); - - const MockApp = await importApp(overrides); - - const clientOptions = { slackApiUrl: 'proxy.slack.com' }; - - new MockApp({ clientOptions, authorize: noopAuthorize, signingSecret: '', logLevel: LogLevel.ERROR }); // eslint-disable-line no-new - - assert.ok(fakeConstructor.called); - - const [token, options] = fakeConstructor.lastCall.args; - assert.strictEqual(undefined, token, 'token should be undefined'); - assert.strictEqual(clientOptions.slackApiUrl, options.slackApiUrl); - assert.strictEqual(LogLevel.ERROR, options.logLevel, 'override logLevel'); - }); - it('should not perform auth.test API call if tokenVerificationEnabled is false', async () => { - // Arrange - const fakeConstructor = sinon.fake(); - const overrides = mergeOverrides(withNoopAppMetadata(), { - '@slack/web-api': { - WebClient: class { - public constructor() { - fakeConstructor(...arguments); // eslint-disable-line prefer-rest-params - } - - public auth = { - test: () => { - throw new Error('This API method call should not be performed'); - }, - }; - }, - }, - }); - - const MockApp = await importApp(overrides); - const app = new MockApp({ - token: 'xoxb-completely-invalid-token', - signingSecret: 'invalid-one', - tokenVerificationEnabled: false, - }); - // Assert - assert.instanceOf(app, MockApp); - }); - - it('should fail in await App#init()', async () => { - // Arrange - const fakeConstructor = sinon.fake(); - const overrides = mergeOverrides(withNoopAppMetadata(), { - '@slack/web-api': { - WebClient: class { - public constructor() { - fakeConstructor(...arguments); // eslint-disable-line prefer-rest-params - } - - public auth = { - test: () => { - throw new Error('Failing for init() test!'); - }, - }; - }, - }, - }); - - const MockApp = await importApp(overrides); - const app = new MockApp({ - token: 'xoxb-completely-invalid-token', - signingSecret: 'invalid-one', - deferInitialization: true, - }); - // Assert - assert.instanceOf(app, MockApp); - try { - // call #start() before #init() - await app.start(); - assert.fail('The start() method should fail before init() call'); - } catch (err: any) { - assert.equal(err.message, 'This App instance is not yet initialized. Call `await App#init()` before starting the app.'); - } - try { - await app.init(); - assert.fail('The init() method should fail here'); - } catch (err: any) { - assert.equal(err.message, 'Failing for init() test!'); - } - }); - - describe('with developerMode', () => { - it('should accept developerMode: true', async () => { - // Arrange - const overrides = mergeOverrides( - withNoopAppMetadata(), - withSuccessfulBotUserFetchingWebClient('B_FAKE_BOT_ID', 'U_FAKE_BOT_USER_ID'), - ); - const fakeLogger = createFakeLogger(); - const MockApp = await importApp(overrides); - // Act - const app = new MockApp({ logger: fakeLogger, token: '', appToken: fakeAppToken, developerMode: true }); - // Assert - assert.equal((app as any).logLevel, LogLevel.DEBUG); - assert.equal((app as any).socketMode, true); - }); - }); - - // TODO: tests for ignoreSelf option - // TODO: tests for logger and logLevel option - // TODO: tests for providing botId and botUserId options - // TODO: tests for providing endpoints option - }); - - describe('#start', () => { - // The following test case depends on a definition of App that is generic on its Receiver type. This will be - // addressed in the future. It cannot even be left uncommented with the `it.skip()` global because it will fail - // TypeScript compilation as written. - // it('should pass calls through to receiver', async () => { - // // Arrange - // const dummyReturn = Symbol(); - // const dummyParams = [Symbol(), Symbol()]; - // const fakeReceiver = new FakeReceiver(); - // const MockApp = await importApp(); - // const app = new MockApp({ receiver: fakeReceiver, authorize: noopAuthorize }); - // fakeReceiver.start = sinon.fake.returns(dummyReturn); - // // Act - // const actualReturn = await app.start(...dummyParams); - // // Assert - // assert.deepEqual(actualReturn, dummyReturn); - // assert.deepEqual(dummyParams, fakeReceiver.start.firstCall.args); - // }); - // TODO: another test case to take the place of the one above (for coverage until the definition of App is made - // generic). - }); - - describe('#stop', () => { - it('should pass calls through to receiver', async () => { - // Arrange - const dummyReturn = Symbol(); - const dummyParams = [Symbol(), Symbol()]; - const fakeReceiver = new FakeReceiver(); - const MockApp = await importApp(); - fakeReceiver.stop = sinon.fake.returns(dummyReturn); - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: noopAuthorize }); - const actualReturn = await app.stop(...dummyParams); - - // Assert - assert.deepEqual(actualReturn, dummyReturn); - assert.deepEqual(dummyParams, fakeReceiver.stop.firstCall.args); - }); - }); - - let fakeReceiver: FakeReceiver; - let fakeErrorHandler: SinonSpy; - let dummyAuthorizationResult: { botToken: string; botId: string }; - - beforeEach(() => { - fakeReceiver = new FakeReceiver(); - fakeErrorHandler = sinon.fake(); - dummyAuthorizationResult = { botToken: '', botId: '' }; - }); - - describe('middleware and listener arguments', () => { - const dummyChannelId = 'CHANNEL_ID'; - let overrides: Override; - const baseEvent = createDummyReceiverEvent(); - - function buildOverrides(secondOverrides: Override[]): Override { - overrides = mergeOverrides( - withNoopAppMetadata(), - ...secondOverrides, - withMemoryStore(sinon.fake()), - withConversationContext(sinon.fake.returns(noopMiddleware)), - ); - return overrides; - } - - describe('respond()', () => { - it('should respond to events with a response_url', async () => { - // Arrange - const responseText = 'response'; - const responseUrl = 'https://fake.slack/response_url'; - const actionId = 'block_action_id'; - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); - const MockApp = await importApp(overrides); - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.action(actionId, async ({ respond }) => { - await respond(responseText); - }); - app.error(fakeErrorHandler); - await fakeReceiver.sendEvent({ - // IncomingEventType.Action (app.action) - body: { - type: 'block_actions', - response_url: responseUrl, - actions: [ - { - action_id: actionId, - }, - ], - channel: {}, - user: {}, - team: {}, - }, - ack: noop, - }); - - // Assert - assert(fakeErrorHandler.notCalled); - assert.equal(fakeAxiosPost.callCount, 1); - // Assert that each call to fakeAxiosPost had the right arguments - assert(fakeAxiosPost.calledWith(responseUrl, { text: responseText })); - }); - - it('should respond with a response object', async () => { - // Arrange - const responseObject = { text: 'response' }; - const responseUrl = 'https://fake.slack/response_url'; - const actionId = 'block_action_id'; - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); - const MockApp = await importApp(overrides); - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.action(actionId, async ({ respond }) => { - await respond(responseObject); - }); - app.error(fakeErrorHandler); - await fakeReceiver.sendEvent({ - // IncomingEventType.Action (app.action) - body: { - type: 'block_actions', - response_url: responseUrl, - actions: [ - { - action_id: actionId, - }, - ], - channel: {}, - user: {}, - team: {}, - }, - ack: noop, - }); - - // Assert - assert.equal(fakeAxiosPost.callCount, 1); - // Assert that each call to fakeAxiosPost had the right arguments - assert(fakeAxiosPost.calledWith(responseUrl, responseObject)); - }); - it('should be able to use respond for view_submission payloads', async () => { - // Arrange - const responseObject = { text: 'response' }; - const responseUrl = 'https://fake.slack/response_url'; - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); - const MockApp = await importApp(overrides); - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.view('view-id', async ({ respond }) => { - await respond(responseObject); - }); - app.error(fakeErrorHandler); - await fakeReceiver.sendEvent({ - ack: noop, - body: { - type: 'view_submission', - team: {}, - user: {}, - view: { - id: 'V111', - type: 'modal', - callback_id: 'view-id', - state: {}, - title: {}, - close: {}, - submit: {}, - }, - response_urls: [ - { - block_id: 'b', - action_id: 'a', - channel_id: 'C111', - response_url: 'https://fake.slack/response_url', - }, - ], - }, - }); - - // Assert - assert.equal(fakeAxiosPost.callCount, 1); - // Assert that each call to fakeAxiosPost had the right arguments - assert(fakeAxiosPost.calledWith(responseUrl, responseObject)); - }); - }); - - describe('logger', () => { - it('should be available in middleware/listener args', async () => { - // Arrange - const MockApp = await importApp(overrides); - const fakeLogger = createFakeLogger(); - const app = new MockApp({ - logger: fakeLogger, - receiver: fakeReceiver, - authorize: sinon.fake.resolves(dummyAuthorizationResult), - }); - app.use(async ({ logger, body, next }) => { - logger.info(body); - await next(); - }); - - app.event('app_home_opened', async ({ logger, event }) => { - logger.debug(event); - }); - - const receiverEvents = [ - { - body: { - type: 'event_callback', - token: 'XXYYZZ', - team_id: 'TXXXXXXXX', - api_app_id: 'AXXXXXXXXX', - event: { - type: 'app_home_opened', - event_ts: '1234567890.123456', - user: 'UXXXXXXX1', - text: 'hello friends!', - tab: 'home', - view: {}, - }, - }, - respond: noop, - ack: noop, - }, - ]; - - // Act - await Promise.all(receiverEvents.map((event) => fakeReceiver.sendEvent(event))); - - // Assert - assert.isTrue(fakeLogger.info.called); - assert.isTrue(fakeLogger.debug.called); - }); - - it('should work in the case both logger and logLevel are given', async () => { - // Arrange - const MockApp = await importApp(overrides); - const fakeLogger = createFakeLogger(); - const app = new MockApp({ - logger: fakeLogger, - logLevel: LogLevel.DEBUG, - receiver: fakeReceiver, - authorize: sinon.fake.resolves(dummyAuthorizationResult), - }); - app.use(async ({ logger, body, next }) => { - logger.info(body); - await next(); - }); - - app.event('app_home_opened', async ({ logger, event }) => { - logger.debug(event); - }); - - const receiverEvents = [ - { - body: { - type: 'event_callback', - token: 'XXYYZZ', - team_id: 'TXXXXXXXX', - api_app_id: 'AXXXXXXXXX', - event: { - type: 'app_home_opened', - event_ts: '1234567890.123456', - user: 'UXXXXXXX1', - text: 'hello friends!', - tab: 'home', - view: {}, - }, - }, - respond: noop, - ack: noop, - }, - ]; - - // Act - await Promise.all(receiverEvents.map((event) => fakeReceiver.sendEvent(event))); - - // Assert - assert.isTrue(fakeLogger.info.called); - assert.isTrue(fakeLogger.debug.called); - assert.isTrue(fakeLogger.setLevel.called); - }); - }); - - describe('client', () => { - it('should be available in middleware/listener args', async () => { - // Arrange - const MockApp = await importApp( - mergeOverrides( - withNoopAppMetadata(), - withSuccessfulBotUserFetchingWebClient('B123', 'U123'), - ), - ); - const tokens = ['xoxb-123', 'xoxp-456', 'xoxb-123']; - const app = new MockApp({ - receiver: fakeReceiver, - authorize: () => { - const token = tokens.pop(); - if (typeof token === 'undefined') { - return Promise.resolve({ botId: 'B123' }); - } - if (token.startsWith('xoxb-')) { - return Promise.resolve({ botToken: token, botId: 'B123' }); - } - return Promise.resolve({ userToken: token, botId: 'B123' }); - }, - }); - app.use(async ({ client, next }) => { - await client.auth.test(); - await next(); - }); - const clients: WebClient[] = []; - app.event('app_home_opened', async ({ client }) => { - clients.push(client); - await client.auth.test(); - }); - - const event = { - body: { - type: 'event_callback', - token: 'legacy', - team_id: 'T123', - api_app_id: 'A123', - event: { - type: 'app_home_opened', - event_ts: '123.123', - user: 'U123', - text: 'Hi there!', - tab: 'home', - view: {}, - }, - }, - respond: noop, - ack: noop, - }; - const receiverEvents = [event, event, event]; - - // Act - await Promise.all(receiverEvents.map((evt) => fakeReceiver.sendEvent(evt))); - - // Assert - assert.isUndefined(app.client.token); - - assert.equal(clients[0].token, 'xoxb-123'); - assert.equal(clients[1].token, 'xoxp-456'); - assert.equal(clients[2].token, 'xoxb-123'); - - assert.notEqual(clients[0], clients[1]); - assert.strictEqual(clients[0], clients[2]); - }); - - it("should be to the global app client when authorization doesn't produce a token", async () => { - // Arrange - const MockApp = await importApp(); - const app = new MockApp({ - receiver: fakeReceiver, - authorize: noopAuthorize, - ignoreSelf: false, - }); - const globalClient = app.client; - - // Act - let clientArg: WebClient | undefined; - app.use(async ({ client }) => { - clientArg = client; - }); - await fakeReceiver.sendEvent(createDummyReceiverEvent()); - - // Assert - assert.equal(globalClient, clientArg); - }); - }); - - describe('say()', () => { - function createChannelContextualReceiverEvents(channelId: string): ReceiverEvent[] { - return [ - // IncomingEventType.Event with channel in payload - { - ...baseEvent, - body: { - event: { - channel: channelId, - }, - team_id: 'TEAM_ID', - }, - }, - // IncomingEventType.Event with channel in item - { - ...baseEvent, - body: { - event: { - item: { - channel: channelId, - }, - }, - team_id: 'TEAM_ID', - }, - }, - // IncomingEventType.Command - { - ...baseEvent, - body: { - command: '/COMMAND_NAME', - channel_id: channelId, - team_id: 'TEAM_ID', - }, - }, - // IncomingEventType.Action from block action, interactive message, or message action - { - ...baseEvent, - body: { - actions: [{}], - channel: { - id: channelId, - }, - user: { - id: 'USER_ID', - }, - team: { - id: 'TEAM_ID', - }, - }, - }, - // IncomingEventType.Action from dialog submission - { - ...baseEvent, - body: { - type: 'dialog_submission', - channel: { - id: channelId, - }, - user: { - id: 'USER_ID', - }, - team: { - id: 'TEAM_ID', - }, - }, - }, - ]; - } - - it('should send a simple message to a channel where the incoming event originates', async () => { - // Arrange - const fakePostMessage = sinon.fake.resolves({}); - overrides = buildOverrides([withPostMessage(fakePostMessage)]); - const MockApp = await importApp(overrides); - - const dummyMessage = 'test'; - const dummyReceiverEvents = createChannelContextualReceiverEvents(dummyChannelId); - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.use(async (args) => { - // By definition, these events should all produce a say function, so we cast args.say into a SayFn - const say = (args as any).say as SayFn; - await say(dummyMessage); - }); - app.error(fakeErrorHandler); - await Promise.all(dummyReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); - - // Assert - assert.equal(fakePostMessage.callCount, dummyReceiverEvents.length); - // Assert that each call to fakePostMessage had the right arguments - fakePostMessage.getCalls().forEach((call) => { - const firstArg = call.args[0]; - assert.propertyVal(firstArg, 'text', dummyMessage); - assert.propertyVal(firstArg, 'channel', dummyChannelId); - }); - assert(fakeErrorHandler.notCalled); - }); - - it('should send a complex message to a channel where the incoming event originates', async () => { - // Arrange - const fakePostMessage = sinon.fake.resolves({}); - overrides = buildOverrides([withPostMessage(fakePostMessage)]); - const MockApp = await importApp(overrides); - - const dummyMessage = { text: 'test' }; - const dummyReceiverEvents = createChannelContextualReceiverEvents(dummyChannelId); - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.use(async (args) => { - // By definition, these events should all produce a say function, so we cast args.say into a SayFn - const say = (args as any).say as SayFn; - await say(dummyMessage); - }); - app.error(fakeErrorHandler); - await Promise.all(dummyReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); - - // Assert - assert.equal(fakePostMessage.callCount, dummyReceiverEvents.length); - // Assert that each call to fakePostMessage had the right arguments - fakePostMessage.getCalls().forEach((call) => { - const firstArg = call.args[0]; - assert.propertyVal(firstArg, 'channel', dummyChannelId); - Object.keys(dummyMessage).forEach((prop) => { - assert.propertyVal(firstArg, prop, (dummyMessage as any)[prop]); - }); - }); - assert(fakeErrorHandler.notCalled); - }); - - function createReceiverEventsWithoutSay(channelId: string): ReceiverEvent[] { - return [ - // IncomingEventType.Options from block action - { - ...baseEvent, - body: { - type: 'block_suggestion', - channel: { - id: channelId, - }, - user: { - id: 'USER_ID', - }, - team: { - id: 'TEAM_ID', - }, - }, - }, - // IncomingEventType.Options from interactive message or dialog - { - ...baseEvent, - body: { - name: 'select_field_name', - channel: { - id: channelId, - }, - user: { - id: 'USER_ID', - }, - team: { - id: 'TEAM_ID', - }, - }, - }, - // IncomingEventType.Event without a channel context - { - ...baseEvent, - body: { - event: {}, - team_id: 'TEAM_ID', - }, - }, - ]; - } - - it("should not exist in the arguments on incoming events that don't support say", async () => { - // Arrange - overrides = buildOverrides([withNoopWebClient()]); - const MockApp = await importApp(overrides); - - const assertionAggregator = sinon.fake(); - const dummyReceiverEvents = createReceiverEventsWithoutSay(dummyChannelId); - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.use(async (args) => { - assert.isUndefined((args as any).say); - // If the above assertion fails, then it would throw an AssertionError and the following line will not be - // called - assertionAggregator(); - }); - - await Promise.all(dummyReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); - - // Assert - assert.equal(assertionAggregator.callCount, dummyReceiverEvents.length); - }); - - it("should handle failures through the App's global error handler", async () => { - // Arrange - const fakePostMessage = sinon.fake.rejects(new Error('fake error')); - overrides = buildOverrides([withPostMessage(fakePostMessage)]); - const MockApp = await importApp(overrides); - - const dummyMessage = { text: 'test' }; - const dummyReceiverEvents = createChannelContextualReceiverEvents(dummyChannelId); - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.use(async (args) => { - // By definition, these events should all produce a say function, so we cast args.say into a SayFn - const say = (args as any).say as SayFn; - await say(dummyMessage); - }); - app.error(fakeErrorHandler); - await Promise.all(dummyReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); - - // Assert - assert.equal(fakeErrorHandler.callCount, dummyReceiverEvents.length); - }); - }); - - describe('ack()', () => { - it('should be available in middleware/listener args', async () => { - // Arrange - const MockApp = await importApp(overrides); - const fakeLogger = createFakeLogger(); - const app = new MockApp({ - logger: fakeLogger, - receiver: fakeReceiver, - authorize: sinon.fake.resolves(dummyAuthorizationResult), - }); - app.use(async ({ ack, next }) => { - if (ack) { - // this should be called even if app.view listeners do not exist - await ack(); - return; - } - fakeLogger.info('Events API'); - await next(); - }); - - app.event('app_home_opened', async ({ logger, event }) => { - logger.debug(event); - }); - - let ackInMiddlewareCalled = false; - - const receiverEvents = [ - { - body: { - type: 'event_callback', - token: 'XXYYZZ', - team_id: 'TXXXXXXXX', - api_app_id: 'AXXXXXXXXX', - event: { - type: 'app_home_opened', - event_ts: '1234567890.123456', - user: 'UXXXXXXX1', - text: 'hello friends!', - tab: 'home', - view: {}, - }, - }, - respond: noop, - ack: noop, - }, - { - body: { - type: 'view_submission', - team: {}, - user: {}, - view: { - id: 'V111', - type: 'modal', - callback_id: 'view-id', - state: {}, - title: {}, - close: {}, - submit: {}, - }, - }, - respond: noop, - ack: async () => { - ackInMiddlewareCalled = true; - }, - }, - ]; - - // Act - await Promise.all( - receiverEvents.map((event) => fakeReceiver.sendEvent(event)), - ); - - // Assert - assert.isTrue(fakeLogger.info.called); - assert.isTrue(ackInMiddlewareCalled); - }); - }); - - describe('context', () => { - it('should be able to use the app_installed_team_id when provided by the payload', async () => { - // Arrange - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([ - withNoopWebClient(), - withAxiosPost(fakeAxiosPost), - ]); - const MockApp = await importApp(overrides); - - // Act - const app = new MockApp({ - receiver: fakeReceiver, - authorize: sinon.fake.resolves(dummyAuthorizationResult), - }); - - app.view('view-id', async ({ ack, context, view }) => { - assert.equal('T-installed-workspace', context.teamId); - assert.notEqual('T-installed-workspace', view.team_id); - await ack(); - }); - app.error(fakeErrorHandler); - - let ackCalled = false; - - const receiverEvent = { - ack: async () => { - ackCalled = true; - }, - body: { - type: 'view_submission', - team: {}, - user: {}, - view: { - id: 'V111', - type: 'modal', - callback_id: 'view-id', - state: {}, - title: {}, - close: {}, - submit: {}, - app_installed_team_id: 'T-installed-workspace', - }, - }, - }; - - // Act - await fakeReceiver.sendEvent(receiverEvent); - - // Assert - assert.isTrue(ackCalled); - }); - }); - }); -}); - -/* Testing Harness */ - -// Loading the system under test using overrides -async function importApp( - overrides: Override = mergeOverrides(withNoopAppMetadata(), withNoopWebClient()), -): Promise { - return (await rewiremock.module(() => import('./App'), overrides)).default; -} - -// Composable overrides -function withNoopWebClient(): Override { - return { - '@slack/web-api': { - WebClient: class {}, - }, - }; -} - -function withNoopAppMetadata(): Override { - return { - '@slack/web-api': { - addAppMetadata: sinon.fake(), - }, - }; -} - -function withSuccessfulBotUserFetchingWebClient(botId: string, botUserId: string): Override { - return { - '@slack/web-api': { - WebClient: class { - public token?: string; - - public constructor(token?: string, _options?: WebClientOptions) { - this.token = token; - } - - public auth = { - test: sinon.fake.resolves({ user_id: botUserId }), - }; - - public users = { - info: sinon.fake.resolves({ - user: { - profile: { - bot_id: botId, - }, - }, - }), - }; - }, - }, - }; -} - -function withPostMessage(spy: SinonSpy): Override { - return { - '@slack/web-api': { - WebClient: class { - public chat = { - postMessage: spy, - }; - }, - }, - }; -} - -function withAxiosPost(spy: SinonSpy): Override { - return { - axios: { - create: () => ({ - post: spy, - }), - }, - }; -} - -function withMemoryStore(spy: SinonSpy): Override { - return { - './conversation-store': { - MemoryStore: spy, - }, - }; -} - -function withConversationContext(spy: SinonSpy): Override { - return { - './conversation-store': { - conversationContext: spy, - }, - }; -} diff --git a/src/App-built-in-middleware.spec.ts b/src/App-built-in-middleware.spec.ts deleted file mode 100644 index 1d118a0f4..000000000 --- a/src/App-built-in-middleware.spec.ts +++ /dev/null @@ -1,609 +0,0 @@ -import 'mocha'; -import sinon, { SinonSpy } from 'sinon'; -import { assert } from 'chai'; -import rewiremock from 'rewiremock'; -import { Override, mergeOverrides, createFakeLogger, delay } from './test-helpers'; -import { ErrorCode, UnknownError, AuthorizationError, CodedError, isCodedError } from './errors'; -import { - Receiver, - ReceiverEvent, - NextFn, -} from './types'; -import App, { ExtendedErrorHandlerArgs } from './App'; - -// Utility functions -const noop = () => Promise.resolve(undefined); -const noopMiddleware = async ({ next }: { next: NextFn }) => { - await next(); -}; -const noopAuthorize = () => Promise.resolve({}); - -// Fakes -class FakeReceiver implements Receiver { - private bolt: App | undefined; - - public init = (bolt: App) => { - this.bolt = bolt; - }; - - public start = sinon.fake((...params: any[]): Promise => Promise.resolve([...params])); - - public stop = sinon.fake((...params: any[]): Promise => Promise.resolve([...params])); - - public async sendEvent(event: ReceiverEvent): Promise { - return this.bolt?.processEvent(event); - } -} - -// Dummies (values that have no real behavior but pass through the system opaquely) -function createDummyReceiverEvent(type: string = 'dummy_event_type'): ReceiverEvent { - // NOTE: this is a degenerate ReceiverEvent that would successfully pass through the App. it happens to look like a - // IncomingEventType.Event - return { - body: { - event: { - type, - }, - }, - ack: noop, - }; -} - -describe('App built-in middleware and mechanism', () => { - let fakeReceiver: FakeReceiver; - let fakeErrorHandler: SinonSpy; - let dummyAuthorizationResult: { botToken: string; botId: string }; - - beforeEach(() => { - fakeReceiver = new FakeReceiver(); - fakeErrorHandler = sinon.fake(); - dummyAuthorizationResult = { botToken: '', botId: '' }; - }); - - // TODO: verify that authorize callback is called with the correct properties and responds correctly to - // various return values - - function createInvalidReceiverEvents(): ReceiverEvent[] { - // TODO: create many more invalid receiver events (fuzzing) - return [ - { - body: {}, - ack: sinon.fake.resolves(undefined), - }, - ]; - } - - it('should warn and skip when processing a receiver event with unknown type (never crash)', async () => { - // Arrange - const fakeLogger = createFakeLogger(); - const fakeMiddleware = sinon.fake(noopMiddleware); - const invalidReceiverEvents = createInvalidReceiverEvents(); - const MockApp = await importApp(); - - // Act - const app = new MockApp({ receiver: fakeReceiver, logger: fakeLogger, authorize: noopAuthorize }); - app.use(fakeMiddleware); - await Promise.all(invalidReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); - - // Assert - assert(fakeErrorHandler.notCalled); - assert(fakeMiddleware.notCalled); - assert.isAtLeast(fakeLogger.warn.callCount, invalidReceiverEvents.length); - }); - - it('should warn, send to global error handler, and skip when a receiver event fails authorization', async () => { - // Arrange - const fakeLogger = createFakeLogger(); - const fakeMiddleware = sinon.fake(noopMiddleware); - const dummyOrigError = new Error('auth failed'); - const dummyAuthorizationError = new AuthorizationError('auth failed', dummyOrigError); - const dummyReceiverEvent = createDummyReceiverEvent(); - const MockApp = await importApp(); - - // Act - const app = new MockApp({ - receiver: fakeReceiver, - logger: fakeLogger, - authorize: sinon.fake.rejects(dummyAuthorizationError), - }); - app.use(fakeMiddleware); - app.error(fakeErrorHandler); - await fakeReceiver.sendEvent(dummyReceiverEvent); - - // Assert - assert(fakeMiddleware.notCalled); - assert(fakeLogger.warn.called); - assert.instanceOf(fakeErrorHandler.firstCall.args[0], Error); - assert.propertyVal(fakeErrorHandler.firstCall.args[0], 'code', ErrorCode.AuthorizationError); - assert.propertyVal(fakeErrorHandler.firstCall.args[0], 'original', dummyAuthorizationError.original); - }); - - describe('global middleware', () => { - let fakeFirstMiddleware: SinonSpy; - let fakeSecondMiddleware: SinonSpy; - let app: App; - let dummyReceiverEvent: ReceiverEvent; - - beforeEach(async () => { - const fakeConversationContext = sinon.fake.returns(noopMiddleware); - const overrides = mergeOverrides( - withNoopAppMetadata(), - withNoopWebClient(), - withMemoryStore(sinon.fake()), - withConversationContext(fakeConversationContext), - ); - const MockApp = await importApp(overrides); - - dummyReceiverEvent = createDummyReceiverEvent(); - fakeFirstMiddleware = sinon.fake(noopMiddleware); - fakeSecondMiddleware = sinon.fake(noopMiddleware); - - app = new MockApp({ - logger: createFakeLogger(), - receiver: fakeReceiver, - authorize: sinon.fake.resolves(dummyAuthorizationResult), - }); - }); - - it('should error if next called multiple times', async () => { - // Arrange - app.use(fakeFirstMiddleware); - app.use(async ({ next }) => { - await next(); - await next(); - }); - app.use(fakeSecondMiddleware); - app.error(fakeErrorHandler); - - // Act - await fakeReceiver.sendEvent(dummyReceiverEvent); - - // Assert - assert.instanceOf(fakeErrorHandler.firstCall.args[0], Error); - }); - - it('correctly waits for async listeners', async () => { - let changed = false; - - app.use(async ({ next }) => { - await delay(10); - changed = true; - - await next(); - }); - - await fakeReceiver.sendEvent(dummyReceiverEvent); - assert.isTrue(changed); - assert(fakeErrorHandler.notCalled); - }); - - it('throws errors which can be caught by upstream async listeners', async () => { - const thrownError = new Error('Error handling the message :('); - let caughtError; - - app.use(async ({ next }) => { - try { - await next(); - } catch (err: any) { - caughtError = err; - } - }); - - app.use(async () => { - throw thrownError; - }); - - app.error(fakeErrorHandler); - - await fakeReceiver.sendEvent(dummyReceiverEvent); - - assert.equal(caughtError, thrownError); - assert(fakeErrorHandler.notCalled); - }); - - it('calls async middleware in declared order', async () => { - const message = ':wave:'; - let middlewareCount = 0; - - /** - * Middleware that, when called, asserts that it was called in the correct order - * @param orderDown The order it should be called when processing middleware down the chain - * @param orderUp The order it should be called when processing middleware up the chain - */ - const assertOrderMiddleware = (orderDown: number, orderUp: number) => async ({ next }: { next?: NextFn }) => { - await delay(10); - middlewareCount += 1; - assert.equal(middlewareCount, orderDown); - if (next !== undefined) { - await next(); - } - middlewareCount += 1; - assert.equal(middlewareCount, orderUp); - }; - - app.use(assertOrderMiddleware(1, 8)); - app.message(message, assertOrderMiddleware(3, 6), assertOrderMiddleware(4, 5)); - app.use(assertOrderMiddleware(2, 7)); - app.error(fakeErrorHandler); - - await fakeReceiver.sendEvent({ - ...dummyReceiverEvent, - body: { - type: 'event_callback', - event: { - type: 'message', - text: message, - }, - }, - }); - - assert.equal(middlewareCount, 8); - assert(fakeErrorHandler.notCalled); - }); - - it('should, on error, call the global error handler, not extended', async () => { - const error = new Error('Everything is broke, you probably should restart, if not then good luck'); - - app.use(() => { - throw error; - }); - - app.error(async (codedError: CodedError) => { - assert.instanceOf(codedError, UnknownError); - assert.equal(codedError.message, error.message); - }); - - await fakeReceiver.sendEvent(dummyReceiverEvent); - }); - - it('should, on error, call the global error handler, extended', async () => { - const error = new Error('Everything is broke, you probably should restart, if not then good luck'); - // Need to change value of private property for testing purposes - // Accessing through bracket notation because it is private - // eslint-disable-next-line @typescript-eslint/dot-notation - app['extendedErrorHandler'] = true; - - app.use(() => { - throw error; - }); - - app.error(async (args: ExtendedErrorHandlerArgs) => { - assert.property(args, 'error'); - assert.property(args, 'body'); - assert.property(args, 'context'); - assert.property(args, 'logger'); - assert.isDefined(args.error); - assert.isDefined(args.body); - assert.isDefined(args.context); - assert.isDefined(args.logger); - assert.equal(args.error.message, error.message); - }); - - await fakeReceiver.sendEvent(dummyReceiverEvent); - - // Need to change value of private property for testing purposes - // Accessing through bracket notation because it is private - // eslint-disable-next-line @typescript-eslint/dot-notation - app['extendedErrorHandler'] = false; - }); - - it('with a default global error handler, rejects App#ProcessEvent', async () => { - const error = new Error('The worst has happened, bot is beyond saving, always hug servers'); - let actualError; - - app.use(() => { - throw error; - }); - - try { - await fakeReceiver.sendEvent(dummyReceiverEvent); - } catch (err: any) { - actualError = err; - } - - assert.instanceOf(actualError, UnknownError); - assert.equal(actualError.message, error.message); - }); - }); - - describe('listener middleware', () => { - let app: App; - const eventType = 'some_event_type'; - const dummyReceiverEvent = createDummyReceiverEvent(eventType); - - beforeEach(async () => { - const MockAppNoOverrides = await importApp(); - app = new MockAppNoOverrides({ - receiver: fakeReceiver, - authorize: sinon.fake.resolves(dummyAuthorizationResult), - }); - app.error(fakeErrorHandler); - }); - - it('should bubble up errors in listeners to the global error handler', async () => { - // Arrange - const errorToThrow = new Error('listener error'); - - // Act - app.event(eventType, async () => { - throw errorToThrow; - }); - await fakeReceiver.sendEvent(dummyReceiverEvent); - - // Assert - assert(fakeErrorHandler.calledOnce); - const error = fakeErrorHandler.firstCall.args[0]; - assert.equal(error.code, ErrorCode.UnknownError); - assert.equal(error.original, errorToThrow); - }); - - it('should aggregate multiple errors in listeners for the same incoming event', async () => { - // Arrange - const errorsToThrow = [new Error('first listener error'), new Error('second listener error')]; - function createThrowingListener(toBeThrown: Error): () => Promise { - return async () => { - throw toBeThrown; - }; - } - - // Act - app.event(eventType, createThrowingListener(errorsToThrow[0])); - app.event(eventType, createThrowingListener(errorsToThrow[1])); - await fakeReceiver.sendEvent(dummyReceiverEvent); - - // Assert - assert(fakeErrorHandler.calledOnce); - const error = fakeErrorHandler.firstCall.args[0]; - assert.ok(isCodedError(error)); - assert(error.code === ErrorCode.MultipleListenerError); - assert.isArray(error.originals); - if (error.originals) assert.sameMembers(error.originals, errorsToThrow); - }); - - it('should detect invalid event names', async () => { - app.event('app_mention', async () => {}); - app.event('message', async () => {}); - assert.throws(() => app.event('message.channels', async () => {}), 'Although the document mentions'); - assert.throws(() => app.event(/message\..+/, async () => {}), 'Although the document mentions'); - }); - - // https://github.com/slackapi/bolt-js/issues/1457 - it('should not cause a runtime exception if the last listener middleware invokes next()', async () => new Promise((resolve, reject) => { - app.event('app_mention', async ({ next }) => { - try { - await next(); - resolve(); - } catch (e) { - reject(e); - } - }); - fakeReceiver.sendEvent(createDummyReceiverEvent('app_mention')); - })); - }); - - describe('middleware and listener arguments', () => { - let overrides: Override; - - function buildOverrides(secondOverrides: Override[]): Override { - overrides = mergeOverrides( - withNoopAppMetadata(), - ...secondOverrides, - withMemoryStore(sinon.fake()), - withConversationContext(sinon.fake.returns(noopMiddleware)), - ); - return overrides; - } - - describe('authorize', () => { - it('should extract valid enterprise_id in a shared channel #935', async () => { - // Arrange - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); - const MockApp = await importApp(overrides); - - // Act - let workedAsExpected = false; - const app = new MockApp({ - receiver: fakeReceiver, - authorize: async ({ enterpriseId }) => { - if (enterpriseId !== undefined) { - throw new Error('the enterprise_id must be undefined in this scenario'); - } - return dummyAuthorizationResult; - }, - }); - app.event('message', async () => { - workedAsExpected = true; - }); - await fakeReceiver.sendEvent({ - ack: noop, - body: { - team_id: 'T_connected_grid_workspace', - enterprise_id: 'E_org_id', - api_app_id: 'A111', - event: { - type: 'message', - text: ':wave: Hi, this is my first message in a Slack Connect channel!', - user: 'U111', - ts: '1622099033.001500', - team: 'T_this_non_grid_workspace', - channel: 'C111', - channel_type: 'channel', - }, - type: 'event_callback', - authorizations: [ - { - enterprise_id: null, - team_id: 'T_this_non_grid_workspace', - user_id: 'U_authed_user', - is_bot: true, - is_enterprise_install: false, - }, - ], - is_ext_shared_channel: true, - event_context: '2-message-T_connected_grid_workspace-A111-C111', - }, - }); - - // Assert - assert.isTrue(workedAsExpected); - }); - it('should be skipped for tokens_revoked events #674', async () => { - // Arrange - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); - const MockApp = await importApp(overrides); - - // Act - let workedAsExpected = false; - let authorizeCallCount = 0; - const app = new MockApp({ - receiver: fakeReceiver, - authorize: async () => { - authorizeCallCount += 1; - return {}; - }, - }); - app.event('tokens_revoked', async () => { - workedAsExpected = true; - }); - - // The authorize must be called for other events - await fakeReceiver.sendEvent({ - ack: noop, - body: { - enterprise_id: 'E_org_id', - api_app_id: 'A111', - event: { - type: 'app_mention', - }, - type: 'event_callback', - }, - }); - assert.equal(authorizeCallCount, 1); - - await fakeReceiver.sendEvent({ - ack: noop, - body: { - enterprise_id: 'E_org_id', - api_app_id: 'A111', - event: { - type: 'tokens_revoked', - tokens: { - oauth: ['P'], - bot: ['B'], - }, - }, - type: 'event_callback', - }, - }); - - // Assert - assert.equal(authorizeCallCount, 1); // still 1 - assert.isTrue(workedAsExpected); - }); - it('should be skipped for app_uninstalled events #674', async () => { - // Arrange - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); - const MockApp = await importApp(overrides); - - // Act - let workedAsExpected = false; - let authorizeCallCount = 0; - const app = new MockApp({ - receiver: fakeReceiver, - authorize: async () => { - authorizeCallCount += 1; - return {}; - }, - }); - app.event('app_uninstalled', async () => { - workedAsExpected = true; - }); - - // The authorize must be called for other events - await fakeReceiver.sendEvent({ - ack: noop, - body: { - enterprise_id: 'E_org_id', - api_app_id: 'A111', - event: { - type: 'app_mention', - }, - type: 'event_callback', - }, - }); - assert.equal(authorizeCallCount, 1); - - await fakeReceiver.sendEvent({ - ack: noop, - body: { - enterprise_id: 'E_org_id', - api_app_id: 'A111', - event: { - type: 'app_uninstalled', - }, - type: 'event_callback', - }, - }); - - // Assert - assert.equal(authorizeCallCount, 1); // still 1 - assert.isTrue(workedAsExpected); - }); - }); - }); -}); - -/* Testing Harness */ - -// Loading the system under test using overrides -async function importApp( - overrides: Override = mergeOverrides(withNoopAppMetadata(), withNoopWebClient()), -): Promise { - return (await rewiremock.module(() => import('./App'), overrides)).default; -} - -// Composable overrides -function withNoopWebClient(): Override { - return { - '@slack/web-api': { - WebClient: class {}, - }, - }; -} - -function withNoopAppMetadata(): Override { - return { - '@slack/web-api': { - addAppMetadata: sinon.fake(), - }, - }; -} - -function withMemoryStore(spy: SinonSpy): Override { - return { - './conversation-store': { - MemoryStore: spy, - }, - }; -} - -function withConversationContext(spy: SinonSpy): Override { - return { - './conversation-store': { - conversationContext: spy, - }, - }; -} - -function withAxiosPost(spy: SinonSpy): Override { - return { - axios: { - create: () => ({ - post: spy, - }), - }, - }; -} diff --git a/src/App-context-types.spec.ts b/src/App-context-types.spec.ts deleted file mode 100644 index 1d75956d6..000000000 --- a/src/App-context-types.spec.ts +++ /dev/null @@ -1,887 +0,0 @@ -import sinon from 'sinon'; -import rewiremock from 'rewiremock'; -import { mergeOverrides, Override } from './test-helpers'; -import { OptionsSource, Receiver, ReceiverEvent, SlackAction, SlackShortcut, SlackViewAction } from './types'; -import App, { ActionConstraints, ShortcutConstraints } from './App'; - -// 0 should not be able to extend (1 & ), if it does, SomeType must be Any -// https://stackoverflow.com/a/55541672 -type IfAnyThenElse = 0 extends (1 & TypeToCheck) ? Then : Else; -interface valid { valid: boolean } -interface GlobalContext { globalContextKey: number } -interface MiddlewareContext { middlewareContextKey: number } - -// Loading the system under test using overrides -async function importApp( - overrides: Override = mergeOverrides(withNoopAppMetadata(), withNoopWebClient()), -): Promise { - return (await rewiremock.module(() => import('./App'), overrides)).default; -} - -class FakeReceiver implements Receiver { - private bolt: App | undefined; - - public init = (bolt: App) => { - this.bolt = bolt; - }; - - public start = sinon.fake((...params: any[]): Promise => Promise.resolve([...params])); - - public stop = sinon.fake((...params: any[]): Promise => Promise.resolve([...params])); - - public async sendEvent(event: ReceiverEvent): Promise { - return this.bolt?.processEvent(event); - } -} - -const noopAuthorize = () => Promise.resolve({}); -const receiver = new FakeReceiver(); - -describe('context typing', () => { - it('use should handle global and middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // Use - Global Context - app.use(async ({ context }) => { - const check = {} as IfAnyThenElse; - check.valid = true; - }); - - // Use - Global & Middleware Context - app.use(async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('use should handle middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // Use - Middleware Context - app.use(async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('message should handle global and middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // Message passes global context to all middleware - app.message(async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Message passes global and middleware context to all middleware - app.message(async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Message passes global context when using RegExp pattern and passes context to all middleware - app.message(/^regex/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Message passes global context when using string pattern and passes context to all middleware - app.message('string', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Message passes global and middleware context when using RegExp patterns and passes context to all middleware - app.message(/^regex/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Message passes global and middleware context when using String patterns and passes context to all middleware - app.message('string', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Message filter with RegExp pattern is aware of global context and passes context to all middleware - app.message(async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, /^regex/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Message filter with String pattern is aware of global context and passes context to all middleware - app.message(async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, 'string', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Message filter with RegExp pattern is aware of global and middleware context and passes context to all middleware - app.message(async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, /^regex/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Message filter with String pattern is aware of global and middleware context and passes context to all middleware - app.message(async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, 'string', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Message filter is aware of global context and passes context to all middleware - app.message(async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Message filter is aware of global and middleware context and passes context to all middleware - app.message(async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Message with mixed patterns and middleware is aware of global context passes context to all middleware - app.message('test_string', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, 'test_string_2', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, /regex/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - /** - * Message with mixed patterns and middleware is aware of global and - * middleware context and passes context to all middleware - */ - app.message('test_string', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, 'test_string_2', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, /regex/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('message should handle middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // Message passes middleware context to all middleware - app.message(async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Message passes middleware context when using RegExp patterns and passes context to all middleware - app.message(/^regex/, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Message passes middleware context when using String patterns and passes context to all middleware - app.message('string', async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Message filter with RegExp pattern is aware of middleware context and passes context to all middleware - app.message(async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, /^regex/, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Message filter is aware of middleware context and passes context to all middleware - app.message(async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - /** - * Message with mixed patterns and middleware is aware of global and - * middleware context and passes context to all middleware - */ - app.message('test_string', async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, 'test_string_2', async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, /regex/, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('shortcut should handle global and middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // Shortcut with RegExp callbackId is aware of global context and passes context to all middleware - app.shortcut(/callback_id/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Shortcut with string callbackId is aware of global context and passes context to all middleware - app.shortcut('callback_id', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Shortcut with RegExp callbackId is aware of global and middleware context and passes context to all middleware - app.shortcut(/callback_id/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Shortcut with string callbackId is aware of global and middleware context and passes context to all middleware - app.shortcut('callback_id', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Shortcut with constraints is aware of global context and passes context to all middleware - app.shortcut({ type: 'shortcut' }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Shortcut with constraints is aware of global and middleware context and passes context to all middleware - app.shortcut, MiddlewareContext>({ type: 'shortcut' }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('shortcut should handle middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // Shortcut with RegExp callbackId is aware of middleware context and passes context to all middleware - app.shortcut(/callback_id/, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Shortcut with string callbackId is aware of middleware context and passes context to all middleware - app.shortcut('callback_id', async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Shortcut with constraints is aware of middleware context and passes context to all middleware - app.shortcut({ type: 'shortcut' }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('action should handle global and middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // Action with RegExp callbackId is aware of global context and passes context to all middleware - app.action(/callback_id/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Action with string callbackId is aware of global context and passes context to all middleware - app.action('callback_id', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Action with RegExp callbackId is aware of global and middleware context and passes context to all middleware - app.action(/callback_id/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Action with string callbackId is aware of global and middleware context and passes context to all middleware - app.action('callback_id', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Action with constraints is aware of global context and passes context to all middleware - app.action({ type: 'interactive_message' }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Action with constraints is aware of global and middleware context and passes context to all middleware - app.action({ type: 'interactive_message' }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('action should handle middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // Action with RegExp callbackId is aware of middleware context and passes context to all middleware - app.action(/callback_id/, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Action with string callbackId is aware of middleware context and passes context to all middleware - app.action('callback_id', async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Action with constraints is aware of middleware context and passes context to all middleware - app.action({ type: 'interactive_message' }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('command should handle global and middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - // Command with commandName is aware of global context and passes context to all middleware - - // Command with RegExp commandName is aware of global and middleware context and passes context to all middleware - app.command(/command_name/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Command with String commandName is aware of global and middleware context and passes context to all middleware - app.command('command_name', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Command with RegExp commandName is aware of global and middleware context and passes context to all middleware - app.command(/command_name/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Command with string commandName is aware of global and middleware context and passes context to all middleware - app.command('command_name', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('command should handle middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // Command with RegExp commandName is aware of middleware context and passes context to all middleware - app.command(/command_name/, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Command with string commandName is aware of middleware context and passes context to all middleware - app.command('command_name', async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('options should handle global and middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // Options with RegExp actionId is aware of global context and passes context to all middleware - app.options(/action_id/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Options with string actionId is aware of global context and passes context to all middleware - app.options('action_id', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Options with RegExp actionId is aware of global and middleware context and passes context to all middleware - app.options<'block_suggestion', MiddlewareContext>(/action_id/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Options with string actionId is aware of global and middleware context and passes context to all middleware - app.options<'block_suggestion', MiddlewareContext>('action_id', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Options with constraint is aware of global context and passes context to all middleware - app.options({ type: 'block_suggestion' }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Options with constraint is aware of global and middleware context and passes context to all middleware - app.options({ type: 'block_suggestion' }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('options should handle middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // Options with RegExp actionId is aware of middleware context and passes context to all middleware - app.options<'block_suggestion', MiddlewareContext>(/action_id/, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Options with string actionId is aware of middleware context and passes context to all middleware - app.options<'block_suggestion', MiddlewareContext>('action_id', async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Options with constraint is aware of middleware context and passes context to all middleware - app.options({ type: 'block_suggestion' }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('view should handle global and middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // View with RegExp callbackId is aware of global context and passes context to all middleware - app.view(/callback_id/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // View with string callbackId is aware of global context and passes context to all middleware - app.view('callback_id', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // View with RegExp callbackId is aware of global and middleware context and passes context to all middleware - app.view(/callback_id/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // View with string callbackId is aware of global and middleware context and passes context to all middleware - app.view('callback_id', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // View with constraint is aware of global context and passes context to all middleware - app.view({ type: 'view_closed' }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // View with constraint is aware of global and middleware context and passes context to all middleware - app.view({ type: 'view_closed' }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('view should handle middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // View with RegExp callbackId is aware of global and middleware context and passes context to all middleware - app.view(/callback_id/, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // View with string callbackId is aware of global and middleware context and passes context to all middleware - app.view('callback_id', async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // View with constraint is aware of global and middleware context and passes context to all middleware - app.view({ type: 'view_closed' }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); -}); - -// Composable overrides -function withNoopWebClient(): Override { - return { - '@slack/web-api': { - WebClient: class {}, - }, - }; -} - -function withNoopAppMetadata(): Override { - return { - '@slack/web-api': { - addAppMetadata: sinon.fake(), - }, - }; -} diff --git a/src/App-routes.spec.ts b/src/App-routes.spec.ts deleted file mode 100644 index e1e22f04b..000000000 --- a/src/App-routes.spec.ts +++ /dev/null @@ -1,1090 +0,0 @@ -import 'mocha'; -import sinon, { SinonSpy } from 'sinon'; -import { assert } from 'chai'; -import rewiremock from 'rewiremock'; -import { Override, mergeOverrides, createFakeLogger } from './test-helpers'; -import { - Receiver, - ReceiverEvent, - NextFn, -} from './types'; -import App, { ViewConstraints } from './App'; - -// Utility functions -const noop = () => Promise.resolve(undefined); -const noopMiddleware = async ({ next }: { next: NextFn }) => { - await next(); -}; - -// Fakes -class FakeReceiver implements Receiver { - private bolt: App | undefined; - - public init = (bolt: App) => { - this.bolt = bolt; - }; - - public start = sinon.fake((...params: any[]): Promise => Promise.resolve([...params])); - - public stop = sinon.fake((...params: any[]): Promise => Promise.resolve([...params])); - - public async sendEvent(event: ReceiverEvent): Promise { - return this.bolt?.processEvent(event); - } -} - -// Dummies (values that have no real behavior but pass through the system opaquely) -function createDummyReceiverEvent(type: string = 'dummy_event_type'): ReceiverEvent { - // NOTE: this is a degenerate ReceiverEvent that would successfully pass through the App. it happens to look like a - // IncomingEventType.Event - return { - body: { - event: { - type, - }, - }, - ack: noop, - }; -} - -describe('App event routing', () => { - let fakeReceiver: FakeReceiver; - let fakeErrorHandler: SinonSpy; - let dummyAuthorizationResult: { botToken: string; botId: string }; - - beforeEach(() => { - fakeReceiver = new FakeReceiver(); - fakeErrorHandler = sinon.fake(); - dummyAuthorizationResult = { botToken: '', botId: '' }; - }); - - let overrides: Override; - const baseEvent = createDummyReceiverEvent(); - - function buildOverrides(secondOverrides: Override[]): Override { - overrides = mergeOverrides( - withNoopAppMetadata(), - ...secondOverrides, - withMemoryStore(sinon.fake()), - withConversationContext(sinon.fake.returns(noopMiddleware)), - ); - return overrides; - } - - describe('basic pattern coverage', () => { - function createReceiverEvents(): ReceiverEvent[] { - return [ - { - // IncomingEventType.Event (app.event) - ...baseEvent, - body: { - event: {}, - }, - }, - { - // IncomingEventType.Command (app.command) - ...baseEvent, - body: { - command: '/COMMAND_NAME', - is_enterprise_install: 'false', - }, - }, - { - // IncomingEventType.Action (app.action) - ...baseEvent, - body: { - type: 'block_actions', - actions: [ - { - action_id: 'block_action_id', - }, - ], - channel: {}, - user: {}, - team: {}, - }, - }, - { - // IncomingEventType.Shortcut (app.shortcut) - ...baseEvent, - body: { - type: 'message_action', - callback_id: 'message_action_callback_id', - channel: {}, - user: {}, - team: {}, - }, - }, - { - // IncomingEventType.Shortcut (app.shortcut) - ...baseEvent, - body: { - type: 'message_action', - callback_id: 'another_message_action_callback_id', - channel: {}, - user: {}, - team: {}, - }, - }, - { - // IncomingEventType.Shortcut (app.shortcut) - ...baseEvent, - body: { - type: 'shortcut', - callback_id: 'shortcut_callback_id', - channel: {}, - user: {}, - team: {}, - }, - }, - { - // IncomingEventType.Shortcut (app.shortcut) - ...baseEvent, - body: { - type: 'shortcut', - callback_id: 'another_shortcut_callback_id', - channel: {}, - user: {}, - team: {}, - }, - }, - { - // IncomingEventType.Action (app.action) - ...baseEvent, - body: { - type: 'interactive_message', - callback_id: 'interactive_message_callback_id', - actions: [{}], - channel: {}, - user: {}, - team: {}, - }, - }, - { - // IncomingEventType.Action with dialog submission (app.action) - ...baseEvent, - body: { - type: 'dialog_submission', - callback_id: 'dialog_submission_callback_id', - channel: {}, - user: {}, - team: {}, - }, - }, - { - // IncomingEventType.Action for an external_select block (app.options) - ...baseEvent, - body: { - type: 'block_suggestion', - action_id: 'external_select_action_id', - channel: {}, - user: {}, - team: {}, - actions: [], - }, - }, - { - // IncomingEventType.Action for "data_source": "external" in dialogs (app.options) - ...baseEvent, - body: { - type: 'dialog_suggestion', - callback_id: 'dialog_suggestion_callback_id', - name: 'the name', - channel: {}, - user: {}, - team: {}, - }, - }, - { - // IncomingEventType.ViewSubmitAction (app.view) - ...baseEvent, - body: { - type: 'view_submission', - channel: {}, - user: {}, - team: {}, - view: { - callback_id: 'view_callback_id', - }, - }, - }, - { - ...baseEvent, - body: { - type: 'view_submission', - channel: {}, - user: {}, - team: null, - enterprise: {}, - view: { - callback_id: 'view_callback_id', - }, - }, - }, - { - ...baseEvent, - body: { - type: 'view_submission', - channel: {}, - user: {}, - enterprise: {}, - // Although {team: undefined} pattern does not exist as of Jan 2021, - // this test verifies if App works even if the field is missing. - view: { - callback_id: 'view_callback_id', - }, - }, - }, - { - ...baseEvent, - body: { - type: 'view_submission', - channel: {}, - user: {}, - team: {}, - // Although {enterprise: undefined} pattern does not exist as of Jan 2021, - // this test verifies if App works even if the field is missing. - view: { - callback_id: 'view_callback_id', - }, - }, - }, - { - ...baseEvent, - body: { - type: 'view_closed', - channel: {}, - user: {}, - team: {}, - view: { - callback_id: 'view_callback_id', - }, - }, - }, - { - ...baseEvent, - 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!', - }, - }, - }, - ]; - } - - function createOrgAppReceiverEvents(): ReceiverEvent[] { - return [ - { - // IncomingEventType.Event (app.event) - ...baseEvent, - body: { - event: {}, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - // IncomingEventType.Command (app.command) - ...baseEvent, - body: { - command: '/COMMAND_NAME', - is_enterprise_install: 'true', - enterprise_id: 'E12345678', - }, - }, - { - // IncomingEventType.Action (app.action) - ...baseEvent, - body: { - type: 'block_actions', - actions: [ - { - action_id: 'block_action_id', - }, - ], - channel: {}, - user: {}, - team: {}, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - // IncomingEventType.Shortcut (app.shortcut) - ...baseEvent, - body: { - type: 'message_action', - callback_id: 'message_action_callback_id', - channel: {}, - user: {}, - team: {}, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - // IncomingEventType.Shortcut (app.shortcut) - ...baseEvent, - body: { - type: 'message_action', - callback_id: 'another_message_action_callback_id', - channel: {}, - user: {}, - team: {}, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - // IncomingEventType.Shortcut (app.shortcut) - ...baseEvent, - body: { - type: 'shortcut', - callback_id: 'shortcut_callback_id', - channel: {}, - user: {}, - team: {}, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - // IncomingEventType.Shortcut (app.shortcut) - ...baseEvent, - body: { - type: 'shortcut', - callback_id: 'another_shortcut_callback_id', - channel: {}, - user: {}, - team: {}, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - // IncomingEventType.Action (app.action) - ...baseEvent, - body: { - type: 'interactive_message', - callback_id: 'interactive_message_callback_id', - actions: [{}], - channel: {}, - user: {}, - team: {}, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - // IncomingEventType.Action with dialog submission (app.action) - ...baseEvent, - body: { - type: 'dialog_submission', - callback_id: 'dialog_submission_callback_id', - channel: {}, - user: {}, - team: {}, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - // IncomingEventType.Action for an external_select block (app.options) - ...baseEvent, - body: { - type: 'block_suggestion', - action_id: 'external_select_action_id', - channel: {}, - user: {}, - team: {}, - actions: [], - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - // IncomingEventType.Action for "data_source": "external" in dialogs (app.options) - ...baseEvent, - body: { - type: 'dialog_suggestion', - callback_id: 'dialog_suggestion_callback_id', - name: 'the name', - channel: {}, - user: {}, - team: {}, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - // IncomingEventType.ViewSubmitAction (app.view) - ...baseEvent, - body: { - type: 'view_submission', - channel: {}, - user: {}, - team: {}, - view: { - callback_id: 'view_callback_id', - }, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - ...baseEvent, - body: { - type: 'view_closed', - channel: {}, - user: {}, - team: {}, - view: { - callback_id: 'view_callback_id', - }, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - ...baseEvent, - 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!', - }, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - ]; - } - - it('should acknowledge any of possible events', async () => { - // Arrange - const ackFn = sinon.fake.resolves({}); - const actionFn = sinon.fake.resolves({}); - const shortcutFn = sinon.fake.resolves({}); - const viewFn = sinon.fake.resolves({}); - const optionsFn = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient()]); - const MockApp = await importApp(overrides); - const dummyReceiverEvents = createReceiverEvents(); - - // Act - const fakeLogger = createFakeLogger(); - const app = new MockApp({ - logger: fakeLogger, - receiver: fakeReceiver, - authorize: sinon.fake.resolves(dummyAuthorizationResult), - }); - - app.use(async ({ next }) => { - await ackFn(); - await next(); - }); - app.shortcut({ callback_id: 'message_action_callback_id' }, async () => { - await shortcutFn(); - }); - app.shortcut({ type: 'message_action', callback_id: 'another_message_action_callback_id' }, async () => { - await shortcutFn(); - }); - app.shortcut({ type: 'message_action', callback_id: 'does_not_exist' }, async () => { - await shortcutFn(); - }); - app.shortcut({ callback_id: 'shortcut_callback_id' }, async () => { - await shortcutFn(); - }); - app.shortcut({ type: 'shortcut', callback_id: 'another_shortcut_callback_id' }, async () => { - await shortcutFn(); - }); - app.shortcut({ type: 'shortcut', callback_id: 'does_not_exist' }, async () => { - await shortcutFn(); - }); - app.action('block_action_id', async () => { - await actionFn(); - }); - app.action({ callback_id: 'interactive_message_callback_id' }, async () => { - await actionFn(); - }); - app.action({ callback_id: 'dialog_submission_callback_id' }, async () => { - await actionFn(); - }); - app.view('view_callback_id', async () => { - await viewFn(); - }); - app.view({ callback_id: 'view_callback_id', type: 'view_closed' }, async () => { - await viewFn(); - }); - app.options('external_select_action_id', async () => { - await optionsFn(); - }); - app.options({ - type: 'block_suggestion', - action_id: 'external_select_action_id', - }, async () => { - await optionsFn(); - }); - app.options({ callback_id: 'dialog_suggestion_callback_id' }, async () => { - await optionsFn(); - }); - app.options({ - type: 'dialog_suggestion', - callback_id: 'dialog_suggestion_callback_id', - }, async () => { - await optionsFn(); - }); - - app.event('app_home_opened', noop); - app.event(/app_home_opened|app_mention/, noop); - app.message('hello', noop); - app.command('/echo', noop); - app.command(/\/e.*/, 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); - await Promise.all(dummyReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); - - // Assert - assert.equal(actionFn.callCount, 3); - assert.equal(shortcutFn.callCount, 4); - assert.equal(viewFn.callCount, 5); - assert.equal(optionsFn.callCount, 4); - assert.equal(ackFn.callCount, dummyReceiverEvents.length); - assert(fakeErrorHandler.notCalled); - }); - - // This test confirms authorize is being used for org events - it('should acknowledge any possible org events', async () => { - // Arrange - const ackFn = sinon.fake.resolves({}); - const actionFn = sinon.fake.resolves({}); - const shortcutFn = sinon.fake.resolves({}); - const viewFn = sinon.fake.resolves({}); - const optionsFn = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient()]); - const MockApp = await importApp(overrides); - const dummyReceiverEvents = createOrgAppReceiverEvents(); - - // Act - const fakeLogger = createFakeLogger(); - const app = new MockApp({ - logger: fakeLogger, - receiver: fakeReceiver, - authorize: sinon.fake.resolves(dummyAuthorizationResult), - }); - - app.use(async ({ next }) => { - await ackFn(); - await next(); - }); - app.shortcut({ callback_id: 'message_action_callback_id' }, async () => { - await shortcutFn(); - }); - app.shortcut({ type: 'message_action', callback_id: 'another_message_action_callback_id' }, async () => { - await shortcutFn(); - }); - app.shortcut({ type: 'message_action', callback_id: 'does_not_exist' }, async () => { - await shortcutFn(); - }); - app.shortcut({ callback_id: 'shortcut_callback_id' }, async () => { - await shortcutFn(); - }); - app.shortcut({ type: 'shortcut', callback_id: 'another_shortcut_callback_id' }, async () => { - await shortcutFn(); - }); - app.shortcut({ type: 'shortcut', callback_id: 'does_not_exist' }, async () => { - await shortcutFn(); - }); - app.action('block_action_id', async () => { - await actionFn(); - }); - app.action({ callback_id: 'interactive_message_callback_id' }, async () => { - await actionFn(); - }); - app.action({ callback_id: 'dialog_submission_callback_id' }, async () => { - await actionFn(); - }); - app.view('view_callback_id', async () => { - await viewFn(); - }); - app.view({ callback_id: 'view_callback_id', type: 'view_closed' }, async () => { - await viewFn(); - }); - app.options('external_select_action_id', async () => { - await optionsFn(); - }); - app.options({ callback_id: 'dialog_suggestion_callback_id' }, async () => { - await 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); - await Promise.all(dummyReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); - - // Assert - assert.equal(actionFn.callCount, 3); - assert.equal(shortcutFn.callCount, 4); - assert.equal(viewFn.callCount, 2); - assert.equal(optionsFn.callCount, 2); - assert.equal(ackFn.callCount, dummyReceiverEvents.length); - assert(fakeErrorHandler.notCalled); - }); - }); - - describe('App#command patterns', () => { - it('should respond to exact name matches', async () => { - // Arrange - overrides = buildOverrides([withNoopWebClient()]); - const MockApp = await importApp(overrides); - let matchCount = 0; - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.command('/hello', async () => { - matchCount += 1; - }); - await fakeReceiver.sendEvent({ - body: { - type: 'slash_command', - command: '/hello', - }, - ack: noop, - }); - - // Assert - assert.equal(matchCount, 1); - }); - - it('should respond to pattern matches', async () => { - // Arrange - overrides = buildOverrides([withNoopWebClient()]); - const MockApp = await importApp(overrides); - let matchCount = 0; - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.command(/h.*/, async () => { - matchCount += 1; - }); - await fakeReceiver.sendEvent({ - body: { - type: 'slash_command', - command: '/hello', - }, - ack: noop, - }); - - // Assert - assert.equal(matchCount, 1); - }); - - it('should run all matching listeners', async () => { - // Arrange - overrides = buildOverrides([withNoopWebClient()]); - const MockApp = await importApp(overrides); - let firstCount = 0; - let secondCount = 0; - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.command(/h.*/, async () => { - firstCount += 1; - }); - app.command(/he.*/, async () => { - secondCount += 1; - }); - await fakeReceiver.sendEvent({ - body: { - type: 'slash_command', - command: '/hello', - }, - ack: noop, - }); - - // Assert - assert.equal(firstCount, 1); - assert.equal(secondCount, 1); - }); - - it('should not stop at an unsuccessful match', async () => { - // Arrange - overrides = buildOverrides([withNoopWebClient()]); - const MockApp = await importApp(overrides); - let firstCount = 0; - let secondCount = 0; - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.command(/x.*/, async () => { - firstCount += 1; - }); - app.command(/h.*/, async () => { - secondCount += 1; - }); - await fakeReceiver.sendEvent({ - body: { - type: 'slash_command', - command: '/hello', - }, - ack: noop, - }); - - // Assert - assert.equal(firstCount, 0); - assert.equal(secondCount, 1); - }); - }); - - describe('App#message patterns', () => { - let fakeMiddleware1: sinon.SinonSpy; - let fakeMiddleware2: sinon.SinonSpy; - let fakeMiddlewares: sinon.SinonSpy[]; - let passFilter: sinon.SinonSpy; - let failFilter: sinon.SinonSpy; - let MockApp: typeof import('./App').default; - let app: App; - - const callNextMiddleware = () => async ({ next }: { next?: NextFn }) => { - if (next) { - await next(); - } - }; - - const fakeMessageEvent = (receiver: FakeReceiver, message: string): Promise => receiver.sendEvent({ - body: { - type: 'event_callback', - event: { - type: 'message', - text: message, - }, - }, - ack: noop, - }); - - const controlledMiddleware = (shouldCallNext: boolean) => async ({ next }: { next?: NextFn }) => { - if (next && shouldCallNext) { - await next(); - } - }; - - const assertMiddlewaresCalledOnce = () => { - assert(fakeMiddleware1.calledOnce); - assert(fakeMiddleware2.calledOnce); - }; - - const assertMiddlewaresCalledOrder = () => { - sinon.assert.callOrder(...fakeMiddlewares); - }; - - const assertMiddlewaresNotCalled = () => { - assert(fakeMiddleware1.notCalled); - assert(fakeMiddleware2.notCalled); - }; - - const message = 'val - pass-string - val'; - const PASS_STRING = 'pass-string'; - const PASS_PATTERN = /.*pass-string.*/; - const FAIL_STRING = 'fail-string'; - const FAIL_PATTERN = /.*fail-string.*/; - - beforeEach(async () => { - sinon.restore(); - MockApp = await importApp(); - app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - fakeMiddleware1 = sinon.spy(callNextMiddleware()); - fakeMiddleware2 = sinon.spy(callNextMiddleware()); - fakeMiddlewares = [ - fakeMiddleware1, - fakeMiddleware2, - ]; - - passFilter = sinon.spy(controlledMiddleware(true)); - failFilter = sinon.spy(controlledMiddleware(false)); - }); - - // public message(...listeners: MessageEventMiddleware[]): void; - it('overload1 - should accept list of listeners and call each one', async () => { - // Act - app.message(...fakeMiddlewares); - await fakeMessageEvent(fakeReceiver, 'testing message'); - - // Assert - assertMiddlewaresCalledOnce(); - }); - - it('overload1 - should not call second listener if first does not pass', async () => { - // Act - app.message(controlledMiddleware(false), fakeMiddleware1); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assert(fakeMiddleware1.notCalled); - }); - - // public message(pattern: string | RegExp, ...listeners: MessageEventMiddleware[]): void; - it('overload2 - should call listeners if message contains string', async () => { - // Act - app.message(PASS_STRING, ...fakeMiddlewares); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresCalledOnce(); - assertMiddlewaresCalledOrder(); - }); - - it('overload2 - should not call listeners if message does not contain string', async () => { - // Act - app.message(FAIL_STRING, fakeMiddleware1, fakeMiddleware2); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresNotCalled(); - }); - - it('overload2 - should call listeners if message matches pattern', async () => { - // Act - app.message(PASS_PATTERN, fakeMiddleware1, fakeMiddleware2); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresCalledOnce(); - assertMiddlewaresCalledOrder(); - }); - - it('overload2 - should not call listeners if message does not match pattern', async () => { - // Act - app.message(FAIL_PATTERN, fakeMiddleware1, fakeMiddleware2); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresNotCalled(); - }); - - it('overload3 - should call listeners if filter and string match', async () => { - // Act - app.message(passFilter, PASS_STRING, fakeMiddleware1, fakeMiddleware2); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresCalledOnce(); - assertMiddlewaresCalledOrder(); - }); - - it('overload3 - should not call listeners if filter does not pass', async () => { - // Act - app.message(failFilter, PASS_STRING, ...fakeMiddlewares); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresNotCalled(); - }); - - it('overload3 - should not call listeners if string does not match', async () => { - // Act - app.message(passFilter, FAIL_STRING, fakeMiddleware1, fakeMiddleware2); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresNotCalled(); - }); - - it('overload3 - should not call listeners if message does not match pattern', async () => { - // Act - app.message(passFilter, FAIL_PATTERN, fakeMiddleware1, fakeMiddleware2); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresNotCalled(); - }); - - it('overload4 - should call listeners if filter passes', async () => { - // Act - app.message(passFilter, fakeMiddleware1, fakeMiddleware2); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresCalledOrder(); - assertMiddlewaresCalledOnce(); - }); - - it('overload4 - should not call listeners if filter fails', async () => { - // Act - app.message(failFilter, fakeMiddleware1, fakeMiddleware2); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresNotCalled(); - }); - - it('should accept multiple strings', async () => { - // Act - app.message(PASS_STRING, '- val', ...fakeMiddlewares); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresCalledOnce(); - assertMiddlewaresCalledOrder(); - }); - - it('should accept string and pattern', async () => { - // Act - app.message(PASS_STRING, PASS_PATTERN, ...fakeMiddlewares); - await fakeMessageEvent(fakeReceiver, message); - // Assert - assertMiddlewaresCalledOnce(); - assertMiddlewaresCalledOrder(); - }); - - it('should not call listeners after fail', async () => { - // Act - app.message(PASS_STRING, FAIL_PATTERN, ...fakeMiddlewares); - app.message(FAIL_STRING, PASS_PATTERN, ...fakeMiddlewares); - app.message(passFilter, failFilter, ...fakeMiddlewares); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresNotCalled(); - }); - }); - - describe('Quick type compatibility checks', () => { - it('app.view ack() method can compile with minimum inputs', async () => { - const MockApp = await importApp(buildOverrides([withNoopWebClient()])); - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.view('callback_id', async ({ ack }) => { - await ack({ - response_action: 'push', - view: { - type: 'modal', - title: { - type: 'plain_text', - text: 'Title', - }, - blocks: [], - }, - }); - }); - }); - }); -}); - -/* Testing Harness */ - -// Loading the system under test using overrides -async function importApp( - overrides: Override = mergeOverrides(withNoopAppMetadata(), withNoopWebClient()), -): Promise { - return (await rewiremock.module(() => import('./App'), overrides)).default; -} - -// Composable overrides -function withNoopWebClient(): Override { - return { - '@slack/web-api': { - WebClient: class {}, - }, - }; -} - -function withNoopAppMetadata(): Override { - return { - '@slack/web-api': { - addAppMetadata: sinon.fake(), - }, - }; -} - -function withMemoryStore(spy: SinonSpy): Override { - return { - './conversation-store': { - MemoryStore: spy, - }, - }; -} - -function withConversationContext(spy: SinonSpy): Override { - return { - './conversation-store': { - conversationContext: spy, - }, - }; -} diff --git a/src/App-workflow-steps.spec.ts b/src/App-workflow-steps.spec.ts deleted file mode 100644 index d62bd9c2b..000000000 --- a/src/App-workflow-steps.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import 'mocha'; -import sinon from 'sinon'; -import { assert } from 'chai'; -import rewiremock from 'rewiremock'; -import { Override, mergeOverrides } from './test-helpers'; -import { - Receiver, - ReceiverEvent, -} from './types'; -import App from './App'; -import { WorkflowStep } from './WorkflowStep'; - -// Fakes -class FakeReceiver implements Receiver { - private bolt: App | undefined; - - public init = (bolt: App) => { - this.bolt = bolt; - }; - - public start = sinon.fake((...params: any[]): Promise => Promise.resolve([...params])); - - public stop = sinon.fake((...params: any[]): Promise => Promise.resolve([...params])); - - public async sendEvent(event: ReceiverEvent): Promise { - return this.bolt?.processEvent(event); - } -} - -describe('App WorkflowStep middleware', () => { - let fakeReceiver: FakeReceiver; - let dummyAuthorizationResult: { botToken: string; botId: string }; - - beforeEach(() => { - fakeReceiver = new FakeReceiver(); - dummyAuthorizationResult = { botToken: '', botId: '' }; - }); - - let app: App; - - beforeEach(async () => { - const MockAppNoOverrides = await importApp(); - app = new MockAppNoOverrides({ - receiver: fakeReceiver, - authorize: sinon.fake.resolves(dummyAuthorizationResult), - }); - }); - - it('should add a listener to middleware for each WorkflowStep passed to app.step', async () => { - const ws = new WorkflowStep('test_id', { edit: [], save: [], execute: [] }); - - /* middleware is a private property on App. Since app.step relies on app.use, - and app.use is fully tested above, we're opting just to ensure that the step listener - is added to the global middleware array, rather than repeating the same tests. */ - const { middleware } = (app as any); - - assert.equal(middleware.length, 2); - - app.step(ws); - - assert.equal(middleware.length, 3); - }); -}); - -/* Testing Harness */ - -// Loading the system under test using overrides -async function importApp( - overrides: Override = mergeOverrides(withNoopAppMetadata(), withNoopWebClient()), -): Promise { - return (await rewiremock.module(() => import('./App'), overrides)).default; -} - -// Composable overrides -function withNoopWebClient(): Override { - return { - '@slack/web-api': { - WebClient: class {}, - }, - }; -} - -function withNoopAppMetadata(): Override { - return { - '@slack/web-api': { - addAppMetadata: sinon.fake(), - }, - }; -} diff --git a/src/App.ts b/src/App.ts index 2e8a4e4e2..14f310fdb 100644 --- a/src/App.ts +++ b/src/App.ts @@ -1,79 +1,103 @@ -import { Agent } from 'http'; -import { SecureContextOptions } from 'tls'; -import util from 'util'; -import { WebClient, ChatPostMessageArguments, addAppMetadata, WebClientOptions } from '@slack/web-api'; -import { Logger, LogLevel, ConsoleLogger } from '@slack/logger'; -import axios, { AxiosInstance, AxiosResponse } from 'axios'; -import SocketModeReceiver from './receivers/SocketModeReceiver'; -import HTTPReceiver, { HTTPReceiverOptions } from './receivers/HTTPReceiver'; -import { isRejected, StringIndexed } from './types/utilities'; +import type { Agent } from 'node:http'; +import type { SecureContextOptions } from 'node:tls'; +import util from 'node:util'; +import { ConsoleLogger, LogLevel, type Logger } from '@slack/logger'; +import { type ChatPostMessageArguments, WebClient, type WebClientOptions, addAppMetadata } from '@slack/web-api'; +import axios, { type AxiosInstance, type AxiosResponse } from 'axios'; +import { + CustomFunction, + type CustomFunctionMiddleware, + type FunctionCompleteFn, + type FunctionFailFn, +} from './CustomFunction'; +import type { WorkflowStep } from './WorkflowStep'; +import { type ConversationStore, MemoryStore, conversationContext } from './conversation-store'; +import { + AppInitializationError, + type CodedError, + ErrorCode, + InvalidCustomPropertyError, + MultipleListenerError, + asCodedError, +} from './errors'; +import { + IncomingEventType, + assertNever, + getTypeAndConversation, + isBodyWithTypeEnterpriseInstall, + isEventTypeToSkipAuthorize, +} from './helpers'; import { ignoreSelf as ignoreSelfMiddleware, - onlyActions, + matchCommandName, matchConstraints, + matchEventType, + matchMessage, + onlyActions, onlyCommands, - matchCommandName, + onlyEvents, onlyOptions, onlyShortcuts, - onlyEvents, - matchEventType, - matchMessage, onlyViewActions, } from './middleware/builtin'; import processMiddleware from './middleware/process'; -import { ConversationStore, conversationContext, MemoryStore } from './conversation-store'; -import { WorkflowStep } from './WorkflowStep'; -import { - Middleware, +import HTTPReceiver, { type HTTPReceiverOptions } from './receivers/HTTPReceiver'; +import SocketModeReceiver from './receivers/SocketModeReceiver'; +import type { + AckFn, + ActionConstraints, + AllMiddlewareArgs, AnyMiddlewareArgs, + BlockAction, + BlockElementAction, + Context, + DialogSubmitAction, + EventTypePattern, + FunctionInputs, + InteractiveAction, + InteractiveMessage, + KnownEventFromType, + KnownOptionsPayloadFromType, + Middleware, + OptionsConstraints, + OptionsSource, + Receiver, + ReceiverEvent, + RespondArguments, + RespondFn, + SayFn, + ShortcutConstraints, + SlackAction, SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs, SlackEventMiddlewareArgs, SlackOptionsMiddlewareArgs, - SlackShortcutMiddlewareArgs, - SlackViewMiddlewareArgs, - SlackAction, - EventTypePattern, SlackShortcut, - Context, - SayFn, - AckFn, - RespondFn, - OptionsSource, - BlockAction, - InteractiveMessage, + SlackShortcutMiddlewareArgs, SlackViewAction, - Receiver, - ReceiverEvent, - RespondArguments, - DialogSubmitAction, - BlockElementAction, - InteractiveAction, - ViewOutput, - KnownOptionsPayloadFromType, - KnownEventFromType, + SlackViewMiddlewareArgs, SlashCommand, + ViewConstraints, + ViewOutput, WorkflowStepEdit, - SlackOptions, - FunctionInputs, } from './types'; -import { IncomingEventType, getTypeAndConversation, assertNever, isBodyWithTypeEnterpriseInstall, isEventTypeToSkipAuthorize } from './helpers'; -import { CodedError, asCodedError, AppInitializationError, MultipleListenerError, ErrorCode, InvalidCustomPropertyError } from './errors'; -import { AllMiddlewareArgs, contextBuiltinKeys } from './types/middleware'; -import { FunctionCompleteFn, FunctionFailFn, CustomFunction, CustomFunctionMiddleware } from './CustomFunction'; -import { Assistant } from './Assistant'; -// eslint-disable-next-line @typescript-eslint/no-require-imports, import/no-commonjs -const packageJson = require('../package.json'); // eslint-disable-line @typescript-eslint/no-var-requires +import { contextBuiltinKeys } from './types'; +import type { Assistant } from './Assistant'; +import { type StringIndexed, isRejected } from './types/utilities'; +const packageJson = require('../package.json'); + +export type { ActionConstraints, OptionsConstraints, ShortcutConstraints, ViewConstraints } from './types'; // ---------------------------- // For listener registration methods - +// TODO: we have types for this... consolidate const validViewTypes = ['view_closed', 'view_submission']; // ---------------------------- // For the constructor -const tokenUsage = 'Apps used in a single workspace can be initialized with a token. Apps used in many workspaces ' + +const tokenUsage = + 'Apps used in a single workspace can be initialized with a token. Apps used in many workspaces ' + 'should be initialized with oauth installer options or authorize.'; /** App initialization options */ @@ -87,7 +111,7 @@ export interface AppOptions { clientId?: HTTPReceiverOptions['clientId']; clientSecret?: HTTPReceiverOptions['clientSecret']; stateSecret?: HTTPReceiverOptions['stateSecret']; // required when using default stateStore - redirectUri?: HTTPReceiverOptions['redirectUri'] + redirectUri?: HTTPReceiverOptions['redirectUri']; installationStore?: HTTPReceiverOptions['installationStore']; // default MemoryInstallationStore scopes?: HTTPReceiverOptions['scopes']; installerOptions?: HTTPReceiverOptions['installerOptions']; @@ -115,9 +139,10 @@ export interface AppOptions { export { LogLevel, Logger } from '@slack/logger'; /** Authorization function - seeds the middleware processing and listeners with an authorization context */ -export interface Authorize { - (source: AuthorizeSourceData, body?: AnyMiddlewareArgs['body']): Promise; -} +export type Authorize = ( + source: AuthorizeSourceData, + body?: AnyMiddlewareArgs['body'], +) => Promise; /** Authorization function inputs - authenticated data about an event for the authorization function */ export interface AuthorizeSourceData { @@ -138,38 +163,10 @@ export interface AuthorizeResult { userId?: string; teamId?: string; enterpriseId?: string; - // TODO: for better type safety, we may want to revisit this - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: TODO: for better type safety, we may want to revisit this [key: string]: any; } -export interface ActionConstraints { - type?: A['type']; - block_id?: A extends BlockAction ? string | RegExp : never; - action_id?: A extends BlockAction ? string | RegExp : never; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - callback_id?: Extract extends any ? string | RegExp : never; -} - -// TODO: more strict typing to allow block/action_id for block_suggestion etc. -export interface OptionsConstraints { - type?: A['type']; - block_id?: A extends SlackOptions ? string | RegExp : never; - action_id?: A extends SlackOptions ? string | RegExp : never; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - callback_id?: Extract extends any ? string | RegExp : never; -} - -export interface ShortcutConstraints { - type?: S['type']; - callback_id?: string | RegExp; -} - -export interface ViewConstraints { - callback_id?: string | RegExp; - type?: 'view_closed' | 'view_submission'; -} - // Passed internally to the handleError method interface AllErrorHandlerArgs { error: Error; // Error is not necessarily a CodedError @@ -183,21 +180,17 @@ export interface ExtendedErrorHandlerArgs extends AllErrorHandlerArgs { error: CodedError; // asCodedError has been called } -export interface ErrorHandler { - (error: CodedError): Promise; -} +export type ErrorHandler = (error: CodedError) => Promise; -export interface ExtendedErrorHandler { - (args: ExtendedErrorHandlerArgs): Promise; -} +export type ExtendedErrorHandler = (args: ExtendedErrorHandlerArgs) => Promise; -export interface AnyErrorHandler extends ErrorHandler, ExtendedErrorHandler { -} +export interface AnyErrorHandler extends ErrorHandler, ExtendedErrorHandler { } // Used only in this file -type MessageEventMiddleware< - CustomContext extends StringIndexed = StringIndexed, -> = Middleware, CustomContext>; +type MessageEventMiddleware = Middleware< + SlackEventMiddlewareArgs<'message'>, + CustomContext +>; class WebClientPool { private pool: { [token: string]: WebClient } = {}; @@ -388,8 +381,8 @@ export default class App this.developerMode && this.installerOptions && (typeof this.installerOptions.callbackOptions === 'undefined' || - (typeof this.installerOptions.callbackOptions !== 'undefined' && - typeof this.installerOptions.callbackOptions.failure === 'undefined')) + (typeof this.installerOptions.callbackOptions !== 'undefined' && + typeof this.installerOptions.callbackOptions.failure === 'undefined')) ) { // add a custom failure callback for Developer Mode in case they are using OAuth this.logger.debug('adding Developer Mode custom OAuth failure handler'); @@ -437,11 +430,7 @@ export default class App this.initialized = false; // You need to run `await app.init();` on your own } else { - this.authorize = this.initAuthorizeInConstructor( - token, - authorize, - argAuthorization, - ); + this.authorize = this.initAuthorizeInConstructor(token, authorize, argAuthorization); this.initialized = true; } @@ -465,10 +454,7 @@ export default class App public async init(): Promise { this.initialized = true; try { - const initializedAuthorize = this.initAuthorizeIfNoTokenIsGiven( - this.argToken, - this.argAuthorize, - ); + const initializedAuthorize = this.initAuthorizeIfNoTokenIsGiven(this.argToken, this.argAuthorize); if (initializedAuthorize !== undefined) { this.authorize = initializedAuthorize; return; @@ -485,14 +471,12 @@ export default class App }; } } - this.authorize = singleAuthorization( - this.client, - authorization, - this.tokenVerificationEnabled, - ); + this.authorize = singleAuthorization(this.client, authorization, this.tokenVerificationEnabled); this.initialized = true; } else { - this.logger.error('Something has gone wrong. Please report this issue to the maintainers. https://github.com/slackapi/bolt-js/issues'); + this.logger.error( + 'Something has gone wrong. Please report this issue to the maintainers. https://github.com/slackapi/bolt-js/issues', + ); assertNever(); } } catch (e) { @@ -543,8 +527,8 @@ export default class App } /** - * Register CustomFunction middleware - */ + * Register CustomFunction middleware + */ public function(callbackId: string, ...listeners: CustomFunctionMiddleware): this { const fn = new CustomFunction(callbackId, listeners, this.webClientOptions); const m = fn.getMiddleware(); @@ -571,49 +555,42 @@ export default class App return this.receiver.start(...args) as ReturnType; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: receivers could accept anything as arguments for stop public stop(...args: any[]): Promise { return this.receiver.stop(...args); } - public event< - EventType extends string = string, - MiddlewareCustomContext extends StringIndexed = StringIndexed, - >( + // TODO: can constrain EventType here to the set of available slack event types to help autocomplete event names + public event( eventName: EventType, ...listeners: Middleware, AppCustomContext & MiddlewareCustomContext>[] ): void; - public event< - EventType extends RegExp = RegExp, - MiddlewareCustomContext extends StringIndexed = StringIndexed, - >( + public event( eventName: EventType, ...listeners: Middleware, AppCustomContext & MiddlewareCustomContext>[] ): void; public event< - EventType extends EventTypePattern = EventTypePattern, - MiddlewareCustomContext extends StringIndexed = StringIndexed, + EventType extends EventTypePattern = EventTypePattern, + MiddlewareCustomContext extends StringIndexed = StringIndexed, >( eventNameOrPattern: EventType, ...listeners: Middleware, AppCustomContext & MiddlewareCustomContext>[] ): void { let invalidEventName = false; if (typeof eventNameOrPattern === 'string') { - const name = eventNameOrPattern as string; + const name = eventNameOrPattern; invalidEventName = name.startsWith('message.'); } else if (eventNameOrPattern instanceof RegExp) { - const name = (eventNameOrPattern as RegExp).source; + const name = eventNameOrPattern.source; invalidEventName = name.startsWith('message\\.'); } if (invalidEventName) { throw new AppInitializationError( - `Although the document mentions "${eventNameOrPattern}",` + - 'it is not a valid event type. Use "message" instead. ' + - 'If you want to filter message events, you can use event.channel_type for it.', + `Although the document mentions "${eventNameOrPattern}", it is not a valid event type. Use "message" instead. If you want to filter message events, you can use event.channel_type for it.`, ); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const _listeners = listeners as any; // FIXME: workaround for TypeScript 4.7 breaking changes + // biome-ignore lint/suspicious/noExplicitAny: FIXME: workaround for TypeScript 4.7 breaking changes + const _listeners = listeners as any; this.listeners.push([ onlyEvents, matchEventType(eventNameOrPattern), @@ -625,18 +602,16 @@ export default class App * * @param listeners Middlewares that process and react to a message event */ - public message< - MiddlewareCustomContext extends StringIndexed = StringIndexed, - >(...listeners: MessageEventMiddleware[]): void; + public message( + ...listeners: MessageEventMiddleware[] + ): void; /** * * @param pattern Used for filtering out messages that don't match. * Strings match via {@link String.prototype.includes}. * @param listeners Middlewares that process and react to the message events that matched the provided patterns. */ - public message< - MiddlewareCustomContext extends StringIndexed = StringIndexed, - >( + public message( pattern: string | RegExp, ...listeners: MessageEventMiddleware[] ): void; @@ -648,9 +623,7 @@ export default class App * via {@link String.prototype.includes}. * @param listeners Middlewares that process and react to the message events that matched the provided pattern. */ - public message< - MiddlewareCustomContext extends StringIndexed = StringIndexed, - >( + public message( filter: MessageEventMiddleware, pattern: string | RegExp, ...listeners: MessageEventMiddleware[] @@ -661,10 +634,8 @@ export default class App * {@link AllMiddlewareArgs.next} if there is no match. See {@link directMention} for an example. * @param listeners Middlewares that process and react to the message events that matched the provided patterns. */ - public message< - MiddlewareCustomContext extends StringIndexed = StringIndexed, - >( - filter: MessageEventMiddleware, + public message( + filter: MessageEventMiddleware, // TODO: why do we need this override? shouldnt ...listeners capture this too? ...listeners: MessageEventMiddleware[] ): void; /** @@ -673,14 +644,11 @@ export default class App * all remaining patterns and middlewares will be skipped. * @param patternsOrMiddleware A mix of patterns and/or middlewares. */ - public message< - MiddlewareCustomContext extends StringIndexed = StringIndexed, - >( + public message( ...patternsOrMiddleware: (string | RegExp | MessageEventMiddleware)[] ): void; - public message< - MiddlewareCustomContext extends StringIndexed = StringIndexed, - >( + // TODO: expose a type parameter for overriding the MessageEvent type (just like shortcut() and action() does) https://github.com/slackapi/bolt-js/issues/796 + public message( ...patternsOrMiddleware: (string | RegExp | MessageEventMiddleware)[] ): void { const messageMiddleware = patternsOrMiddleware.map((patternOrMiddleware) => { @@ -688,8 +656,8 @@ export default class App return matchMessage(patternOrMiddleware); } return patternOrMiddleware; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any; // FIXME: workaround for TypeScript 4.7 breaking changes + // biome-ignore lint/suspicious/noExplicitAny: FIXME: workaround for TypeScript 4.7 breaking changes + }) as any; this.listeners.push([ onlyEvents, @@ -711,7 +679,10 @@ export default class App MiddlewareCustomContext extends StringIndexed = StringIndexed, >( constraints: Constraints, - ...listeners: Middleware>, AppCustomContext & MiddlewareCustomContext>[] + ...listeners: Middleware< + SlackShortcutMiddlewareArgs>, + AppCustomContext & MiddlewareCustomContext + >[] ): void; public shortcut< Shortcut extends SlackShortcut = SlackShortcut, @@ -719,23 +690,28 @@ export default class App MiddlewareCustomContext extends StringIndexed = StringIndexed, >( callbackIdOrConstraints: string | RegExp | Constraints, - ...listeners: Middleware>, AppCustomContext & MiddlewareCustomContext>[] + ...listeners: Middleware< + SlackShortcutMiddlewareArgs>, + AppCustomContext & MiddlewareCustomContext + >[] ): void { - const constraints: ShortcutConstraints = typeof callbackIdOrConstraints === 'string' || util.types.isRegExp(callbackIdOrConstraints) ? - { callback_id: callbackIdOrConstraints } : - callbackIdOrConstraints; + const constraints: ShortcutConstraints = + typeof callbackIdOrConstraints === 'string' || util.types.isRegExp(callbackIdOrConstraints) + ? { callback_id: callbackIdOrConstraints } + : callbackIdOrConstraints; // Fail early if the constraints contain invalid keys const unknownConstraintKeys = Object.keys(constraints).filter((k) => k !== 'callback_id' && k !== 'type'); if (unknownConstraintKeys.length > 0) { + // TODO:event() will throw an error if you provide an invalid event name; we should align this behaviour. this.logger.error( `Slack listener cannot be attached using unknown constraint keys: ${unknownConstraintKeys.join(', ')}`, ); return; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const _listeners = listeners as any; // FIXME: workaround for TypeScript 4.7 breaking changes + // biome-ignore lint/suspicious/noExplicitAny: FIXME: workaround for TypeScript 4.7 breaking changes + const _listeners = listeners as any; this.listeners.push([ onlyShortcuts, matchConstraints(constraints), @@ -743,8 +719,6 @@ export default class App ] as Middleware[]); } - // NOTE: this is what's called a convenience generic, so that types flow more easily without casting. - // https://web.archive.org/web/20210629110615/https://basarat.gitbook.io/typescript/type-system/generics#motivation-and-samples public action< Action extends SlackAction = SlackAction, MiddlewareCustomContext extends StringIndexed = StringIndexed, @@ -759,7 +733,10 @@ export default class App >( constraints: Constraints, // NOTE: Extract<> is able to return the whole union when type: undefined. Why? - ...listeners: Middleware>, AppCustomContext & MiddlewareCustomContext>[] + ...listeners: Middleware< + SlackActionMiddlewareArgs>, + AppCustomContext & MiddlewareCustomContext + >[] ): void; public action< Action extends SlackAction = SlackAction, @@ -767,37 +744,40 @@ export default class App MiddlewareCustomContext extends StringIndexed = StringIndexed, >( actionIdOrConstraints: string | RegExp | Constraints, - ...listeners: Middleware>, AppCustomContext & MiddlewareCustomContext>[] + ...listeners: Middleware< + SlackActionMiddlewareArgs>, + AppCustomContext & MiddlewareCustomContext + >[] ): void { // Normalize Constraints - const constraints: ActionConstraints = typeof actionIdOrConstraints === 'string' || util.types.isRegExp(actionIdOrConstraints) ? - { action_id: actionIdOrConstraints } : - actionIdOrConstraints; + const constraints: ActionConstraints = + typeof actionIdOrConstraints === 'string' || util.types.isRegExp(actionIdOrConstraints) + ? { action_id: actionIdOrConstraints } + : actionIdOrConstraints; // Fail early if the constraints contain invalid keys const unknownConstraintKeys = Object.keys(constraints).filter( (k) => k !== 'action_id' && k !== 'block_id' && k !== 'callback_id' && k !== 'type', ); if (unknownConstraintKeys.length > 0) { + // TODO:event() will throw an error if you provide an invalid event name; we should align this behaviour. this.logger.error( `Action listener cannot be attached using unknown constraint keys: ${unknownConstraintKeys.join(', ')}`, ); return; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const _listeners = listeners as any; // FIXME: workaround for TypeScript 4.7 breaking changes + // biome-ignore lint/suspicious/noExplicitAny: FIXME: workaround for TypeScript 4.7 breaking changes + const _listeners = listeners as any; this.listeners.push([onlyActions, matchConstraints(constraints), ..._listeners] as Middleware[]); } public command( - commandName: string | RegExp, ...listeners: Middleware< - SlackCommandMiddlewareArgs, - AppCustomContext & MiddlewareCustomContext - >[] + commandName: string | RegExp, + ...listeners: Middleware[] ): void { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const _listeners = listeners as any; // FIXME: workaround for TypeScript 4.7 breaking changes + // biome-ignore lint/suspicious/noExplicitAny: FIXME: workaround for TypeScript 4.7 breaking changes + const _listeners = listeners as any; this.listeners.push([ onlyCommands, matchCommandName(commandName), @@ -806,18 +786,18 @@ export default class App } public options< - Source extends OptionsSource = 'block_suggestion', + Source extends OptionsSource = 'block_suggestion', // TODO: here, similarly to `message()`, the generic is the string `type` of the payload. in others, like `action()`, it's the entire payload. could we make this consistent? MiddlewareCustomContext extends StringIndexed = StringIndexed, >( actionId: string | RegExp, ...listeners: Middleware, AppCustomContext & MiddlewareCustomContext>[] ): void; - // TODO: reflect the type in constraints to Source + // TODO: reflect the type in constraints to Source (this relates to the above TODO, too) public options< Source extends OptionsSource = OptionsSource, MiddlewareCustomContext extends StringIndexed = StringIndexed, >( - constraints: OptionsConstraints, + constraints: OptionsConstraints, // TODO: to be able to 'link' listener arguments to the constrains, should pass the Source type in as a generic here ...listeners: Middleware, AppCustomContext & MiddlewareCustomContext>[] ): void; // TODO: reflect the type in constraints to Source @@ -828,13 +808,13 @@ export default class App actionIdOrConstraints: string | RegExp | OptionsConstraints, ...listeners: Middleware, AppCustomContext & MiddlewareCustomContext>[] ): void { - const constraints: OptionsConstraints = typeof actionIdOrConstraints === 'string' || - util.types.isRegExp(actionIdOrConstraints) ? - { action_id: actionIdOrConstraints } : - actionIdOrConstraints; + const constraints: OptionsConstraints = + typeof actionIdOrConstraints === 'string' || util.types.isRegExp(actionIdOrConstraints) + ? { action_id: actionIdOrConstraints } + : actionIdOrConstraints; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const _listeners = listeners as any; // FIXME: workaround for TypeScript 4.7 breaking changes + // biome-ignore lint/suspicious/noExplicitAny: FIXME: workaround for TypeScript 4.7 breaking changes + const _listeners = listeners as any; this.listeners.push([onlyOptions, matchConstraints(constraints), ..._listeners] as Middleware[]); } @@ -847,6 +827,7 @@ export default class App ): void; public view< ViewActionType extends SlackViewAction = SlackViewAction, + // TODO: add a type parameter for view constraints; this way we can constrain the handler view arguments based on the type of the constraint, similar to what action() does MiddlewareCustomContext extends StringIndexed = StringIndexed, >( constraints: ViewConstraints, @@ -859,9 +840,10 @@ export default class App callbackIdOrConstraints: string | RegExp | ViewConstraints, ...listeners: Middleware, AppCustomContext & MiddlewareCustomContext>[] ): void { - const constraints: ViewConstraints = typeof callbackIdOrConstraints === 'string' || util.types.isRegExp(callbackIdOrConstraints) ? - { callback_id: callbackIdOrConstraints, type: 'view_submission' } : - callbackIdOrConstraints; + const constraints: ViewConstraints = + typeof callbackIdOrConstraints === 'string' || util.types.isRegExp(callbackIdOrConstraints) + ? { callback_id: callbackIdOrConstraints, type: 'view_submission' } + : callbackIdOrConstraints; // Fail early if the constraints contain invalid keys const unknownConstraintKeys = Object.keys(constraints).filter((k) => k !== 'callback_id' && k !== 'type'); if (unknownConstraintKeys.length > 0) { @@ -876,8 +858,8 @@ export default class App return; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const _listeners = listeners as any; // FIXME: workaround for TypeScript 4.7 breaking changes + // biome-ignore lint/suspicious/noExplicitAny: FIXME: workaround for TypeScript 4.7 breaking changes + const _listeners = listeners as any; this.listeners.push([ onlyViewActions, matchConstraints(constraints), @@ -933,12 +915,10 @@ export default class App try { authorizeResult = await this.authorize(source, bodyArg); } catch (error) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything const e = error as any; this.logger.warn('Authorization of incoming event did not succeed. No listeners will be called.'); e.code = ErrorCode.AuthorizationError; - // disabling due to https://github.com/typescript-eslint/typescript-eslint/issues/1277 - // eslint-disable-next-line consistent-return return this.handleError({ error: e, logger: this.logger, @@ -985,12 +965,16 @@ export default class App const { functionExecutionId, functionBotAccessToken, functionInputs } = extractFunctionContext(body); if (functionExecutionId) { context.functionExecutionId = functionExecutionId; - if (functionInputs) { context.functionInputs = functionInputs; } + if (functionInputs) { + context.functionInputs = functionInputs; + } } // Attach and make available the JIT/function-related token on context if (this.attachFunctionToken) { - if (functionBotAccessToken) { context.functionBotAccessToken = functionBotAccessToken; } + if (functionBotAccessToken) { + context.functionBotAccessToken = functionBotAccessToken; + } } // Factory for say() utility @@ -1011,9 +995,17 @@ export default class App // Set body and payload // TODO: this value should eventually conform to AnyMiddlewareArgs // TODO: remove workflow step stuff in bolt v5 - let payload: DialogSubmitAction | WorkflowStepEdit | SlackShortcut | KnownEventFromType | SlashCommand - | KnownOptionsPayloadFromType | BlockElementAction | ViewOutput | InteractiveAction; // TODO: can we instead use type predicates in these switch cases to allow for narrowing of the body simultaneously? we have isEvent, isView, isShortcut, isAction already in types/utilities / helpers + let payload: + | DialogSubmitAction + | WorkflowStepEdit + | SlackShortcut + | KnownEventFromType + | SlashCommand + | KnownOptionsPayloadFromType + | BlockElementAction + | ViewOutput + | InteractiveAction; switch (type) { case IncomingEventType.Event: payload = (bodyArg as SlackEventMiddlewareArgs['body']).event; @@ -1022,23 +1014,21 @@ export default class App payload = (bodyArg as SlackViewMiddlewareArgs['body']).view; break; case IncomingEventType.Shortcut: - payload = (bodyArg as SlackShortcutMiddlewareArgs['body']); + payload = bodyArg as SlackShortcutMiddlewareArgs['body']; break; + // biome-ignore lint/suspicious/noFallthroughSwitchClause: usually not great, but we do it here case IncomingEventType.Action: if (isBlockActionOrInteractiveMessageBody(bodyArg as SlackActionMiddlewareArgs['body'])) { - const { actions } = (bodyArg as SlackActionMiddlewareArgs['body']); + const { actions } = bodyArg as SlackActionMiddlewareArgs['body']; [payload] = actions; break; } - // If above conditional does not hit, fall through to fallback payload in default block below + // If above conditional does not hit, fall through to fallback payload in default block below default: - payload = (bodyArg as ( - | Exclude< - AnyMiddlewareArgs, - SlackEventMiddlewareArgs | SlackActionMiddlewareArgs | SlackViewMiddlewareArgs - > + payload = bodyArg as ( + | Exclude | SlackActionMiddlewareArgs> - )['body']); + )['body']; break; } // NOTE: the following doesn't work because... distributive? @@ -1049,7 +1039,7 @@ export default class App /** Respond function might be set below */ respond?: RespondFn; /** Ack function might be set below */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: different kinds of acks accept different arguments, TODO: revisit this to see if we can type better ack?: AckFn; complete?: FunctionCompleteFn; fail?: FunctionFailFn; @@ -1122,12 +1112,11 @@ export default class App } if (token !== undefined) { - let pool; + let pool: WebClientPool | undefined = undefined; const clientOptionsCopy = { ...this.clientOptions }; if (authorizeResult.teamId !== undefined) { pool = this.clients[authorizeResult.teamId]; if (pool === undefined) { - // eslint-disable-next-line no-multi-assign pool = this.clients[authorizeResult.teamId] = new WebClientPool(); } // Add teamId to clientOptions so it can be automatically added to web-api calls @@ -1135,7 +1124,6 @@ export default class App } else if (authorizeResult.enterpriseId !== undefined) { pool = this.clients[authorizeResult.enterpriseId]; if (pool === undefined) { - // eslint-disable-next-line no-multi-assign pool = this.clients[authorizeResult.enterpriseId] = new WebClientPool(); } } @@ -1173,13 +1161,14 @@ export default class App this.logger, // When all the listener middleware are done processing, // `listener` here will be called with a noop `next` fn - async () => listener({ - ...(listenerArgs as AnyMiddlewareArgs), - context, - client, - logger: this.logger, - next: () => {}, - } as AnyMiddlewareArgs & AllMiddlewareArgs), + async () => + listener({ + ...(listenerArgs as AnyMiddlewareArgs), + context, + client, + logger: this.logger, + next: () => { }, + } as AnyMiddlewareArgs & AllMiddlewareArgs), ); }); @@ -1187,16 +1176,15 @@ export default class App const rejectedListenerResults = settledListenerResults.filter(isRejected); if (rejectedListenerResults.length === 1) { throw rejectedListenerResults[0].reason; + // biome-ignore lint/style/noUselessElse: I think this is a biome issue actually... } else if (rejectedListenerResults.length > 1) { throw new MultipleListenerError(rejectedListenerResults.map((rlr) => rlr.reason)); } }, ); } catch (error) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything const e = error as any; - // disabling due to https://github.com/typescript-eslint/typescript-eslint/issues/1277 - // eslint-disable-next-line consistent-return return this.handleError({ context, error: e, @@ -1212,9 +1200,9 @@ export default class App private handleError(args: AllErrorHandlerArgs): Promise { const { error, ...rest } = args; - return this.extendedErrorHandler && this.hasCustomErrorHandler ? - this.errorHandler({ error: asCodedError(error), ...rest }) : - this.errorHandler(asCodedError(error)); + return this.extendedErrorHandler && this.hasCustomErrorHandler + ? this.errorHandler({ error: asCodedError(error), ...rest }) + : this.errorHandler(asCodedError(error)); } // --------------------- @@ -1247,7 +1235,9 @@ export default class App } if (this.socketMode === true) { if (appToken === undefined) { - throw new AppInitializationError('You must provide an appToken when socketMode is set to true. To generate an appToken see: https://api.slack.com/apis/connections/socket#token'); + throw new AppInitializationError( + 'You must provide an appToken when socketMode is set to true. To generate an appToken see: https://api.slack.com/apis/connections/socket#token', + ); } this.logger.debug('Initializing SocketModeReceiver'); return new SocketModeReceiver({ @@ -1268,7 +1258,7 @@ export default class App // Using default receiver HTTPReceiver, signature verification enabled, missing signingSecret throw new AppInitializationError( 'signingSecret is required to initialize the default receiver. Set signingSecret or use a ' + - 'custom receiver. You can find your Signing Secret in your Slack App Settings.', + 'custom receiver. You can find your Signing Secret in your Slack App Settings.', ); } this.logger.debug('Initializing HTTPReceiver'); @@ -1291,16 +1281,10 @@ export default class App }); } - private initAuthorizeIfNoTokenIsGiven( - token?: string, - authorize?: Authorize, - ): Authorize | undefined { + private initAuthorizeIfNoTokenIsGiven(token?: string, authorize?: Authorize): Authorize | undefined { let usingOauth = false; - const httpReceiver = (this.receiver as HTTPReceiver); - if ( - httpReceiver.installer !== undefined && - httpReceiver.installer.authorize !== undefined - ) { + const httpReceiver = this.receiver as HTTPReceiver; + if (httpReceiver.installer !== undefined && httpReceiver.installer.authorize !== undefined) { // This supports using the built-in HTTPReceiver, declaring your own HTTPReceiver // and theoretically, doing a fully custom (non-Express.js) receiver that implements OAuth usingOauth = true; @@ -1319,11 +1303,14 @@ export default class App throw new AppInitializationError( `${tokenUsage} \n\nSince you have not provided a token or authorize, you might be missing one or more required oauth installer options. See https://slack.dev/bolt-js/concepts/authenticating-oauth for these required fields.\n`, ); + // biome-ignore lint/style/noUselessElse: I think this is a biome issue actually... } else if (authorize !== undefined && usingOauth) { throw new AppInitializationError(`You cannot provide both authorize and oauth installer options. ${tokenUsage}`); + // biome-ignore lint/style/noUselessElse: I think this is a biome issue actually... } else if (authorize === undefined && usingOauth) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // biome-ignore lint/style/noNonNullAssertion: we know installer is truthy here return httpReceiver.installer!.authorize; + // biome-ignore lint/style/noUselessElse: I think this is a biome issue actually... } else if (authorize !== undefined && !usingOauth) { return authorize as Authorize; } @@ -1335,19 +1322,12 @@ export default class App authorize?: Authorize, authorization?: Authorization, ): Authorize { - const initializedAuthorize = this.initAuthorizeIfNoTokenIsGiven( - token, - authorize, - ); + const initializedAuthorize = this.initAuthorizeIfNoTokenIsGiven(token, authorize); if (initializedAuthorize !== undefined) { return initializedAuthorize; } if (token !== undefined && authorization !== undefined) { - return singleAuthorization( - this.client, - authorization, - this.tokenVerificationEnabled, - ); + return singleAuthorization(this.client, authorization, this.tokenVerificationEnabled); } const hasToken = token !== undefined && token.length > 0; const errorMessage = `Something has gone wrong in #initAuthorizeInConstructor method (hasToken: ${hasToken}, authorize: ${authorize}). Please report this issue to the maintainers. https://github.com/slackapi/bolt-js/issues`; @@ -1372,9 +1352,9 @@ function runAuthTestForBotToken( authorization: Partial & { botToken: Required['botToken'] }, ): Promise<{ botUserId: string; botId: string }> { // TODO: warn when something needed isn't found - return authorization.botUserId !== undefined && authorization.botId !== undefined ? - Promise.resolve({ botUserId: authorization.botUserId, botId: authorization.botId }) : - client.auth.test({ token: authorization.botToken }).then((result) => ({ + return authorization.botUserId !== undefined && authorization.botId !== undefined + ? Promise.resolve({ botUserId: authorization.botUserId, botId: authorization.botId }) + : client.auth.test({ token: authorization.botToken }).then((result) => ({ botUserId: result.user_id as string, botId: result.bot_id as string, })); @@ -1402,9 +1382,8 @@ function singleAuthorization( if (tokenVerificationEnabled) { // call auth.test immediately cachedAuthTestResult = runAuthTestForBotToken(client, authorization); - return async ({ isEnterpriseInstall }) => buildAuthorizeResult( - isEnterpriseInstall, cachedAuthTestResult, authorization, - ); + return async ({ isEnterpriseInstall }) => + buildAuthorizeResult(isEnterpriseInstall, cachedAuthTestResult, authorization); } return async ({ isEnterpriseInstall }) => { // hold off calling auth.test API until the first access to authorize function @@ -1446,11 +1425,7 @@ function buildSource( } const parseTeamId = ( - bodyAs: - | SlackAction - | SlackViewAction - | SlackShortcut - | KnownOptionsPayloadFromType, + bodyAs: SlackAction | SlackViewAction | SlackShortcut | KnownOptionsPayloadFromType, ): string | undefined => { // When the app is installed using org-wide deployment, team property will be null if (typeof bodyAs.team !== 'undefined' && bodyAs.team !== null) { @@ -1616,7 +1591,8 @@ function buildRespondFn( function escapeHtml(input: string | undefined | null): string { if (input) { - return input.replace(/&/g, '&') + return input + .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') @@ -1626,9 +1602,9 @@ function escapeHtml(input: string | undefined | null): string { } function extractFunctionContext(body: StringIndexed) { - let functionExecutionId; - let functionBotAccessToken; - let functionInputs; + let functionExecutionId: string | undefined = undefined; + let functionBotAccessToken: string | undefined = undefined; + let functionInputs: FunctionInputs | undefined = undefined; // function_executed event if (body.event && body.event.type === 'function_executed' && body.event.function_execution_id) { diff --git a/src/CustomFunction.ts b/src/CustomFunction.ts index 04ae1819d..b1d38779d 100644 --- a/src/CustomFunction.ts +++ b/src/CustomFunction.ts @@ -1,40 +1,32 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { FunctionExecutedEvent } from '@slack/types'; +import type { FunctionExecutedEvent } from '@slack/types'; import { + type FunctionsCompleteErrorResponse, + type FunctionsCompleteSuccessResponse, WebClient, - FunctionsCompleteErrorResponse, - FunctionsCompleteSuccessResponse, - WebClientOptions, + type WebClientOptions, } from '@slack/web-api'; import { - Middleware, - AllMiddlewareArgs, - AnyMiddlewareArgs, - SlackEventMiddlewareArgs, - Context, -} from './types'; + CustomFunctionCompleteFailError, + CustomFunctionCompleteSuccessError, + CustomFunctionInitializationError, +} from './errors'; import processMiddleware from './middleware/process'; -import { CustomFunctionCompleteFailError, CustomFunctionCompleteSuccessError, CustomFunctionInitializationError } from './errors'; +import type { AllMiddlewareArgs, AnyMiddlewareArgs, Context, Middleware, SlackEventMiddlewareArgs } from './types'; /** Interfaces */ interface FunctionCompleteArguments { - outputs?: { - [key: string]: any; - }; + // biome-ignore lint/suspicious/noExplicitAny: TODO: could probably improve custom function parameter shapes - deno-slack-sdk has a bunch of this stuff we should move to slack/types + outputs?: Record; } -export interface FunctionCompleteFn { - (params?: FunctionCompleteArguments): Promise; -} +export type FunctionCompleteFn = (params?: FunctionCompleteArguments) => Promise; interface FunctionFailArguments { error: string; } -export interface FunctionFailFn { - (params: FunctionFailArguments): Promise; -} +export type FunctionFailFn = (params: FunctionFailArguments) => Promise; export interface CustomFunctionExecuteMiddlewareArgs extends SlackEventMiddlewareArgs<'function_executed'> { inputs: FunctionExecutedEvent['inputs']; @@ -50,8 +42,9 @@ type CustomFunctionExecuteMiddleware = Middleware[]; -export type AllCustomFunctionMiddlewareArgs - = T & AllMiddlewareArgs; +export type AllCustomFunctionMiddlewareArgs< + T extends SlackCustomFunctionMiddlewareArgs = SlackCustomFunctionMiddlewareArgs, +> = T & AllMiddlewareArgs; /** Constants */ @@ -67,11 +60,7 @@ export class CustomFunction { private middleware: CustomFunctionMiddleware; - public constructor( - callbackId: string, - middleware: CustomFunctionExecuteMiddleware, - clientOptions: WebClientOptions, - ) { + public constructor(callbackId: string, middleware: CustomFunctionExecuteMiddleware, clientOptions: WebClientOptions) { validate(callbackId, middleware); this.appWebClientOptions = clientOptions; @@ -80,7 +69,7 @@ export class CustomFunction { } public getMiddleware(): Middleware { - return async (args): Promise => { + return async (args): Promise => { if (isFunctionEvent(args) && this.matchesConstraints(args)) { return this.processEvent(args); } @@ -114,16 +103,17 @@ export class CustomFunction { throw new CustomFunctionCompleteSuccessError(errorMsg); } - return (params: Parameters[0] = {}) => client.functions.completeSuccess({ - token, - outputs: params.outputs || {}, - function_execution_id: functionExecutionId, - }); + return (params: Parameters[0] = {}) => + client.functions.completeSuccess({ + token, + outputs: params.outputs || {}, + function_execution_id: functionExecutionId, + }); } /** - * Factory for `fail()` utility - */ + * Factory for `fail()` utility + */ public static createFunctionFail(context: Context, client: WebClient): FunctionFailFn { const token = selectToken(context); const { functionExecutionId } = context; @@ -161,12 +151,12 @@ export function validate(callbackId: string, middleware: CustomFunctionExecuteMi // Ensure array includes only functions if (Array.isArray(middleware)) { - middleware.forEach((fn) => { + for (const fn of middleware) { if (!(fn instanceof Function)) { const errorMsg = 'All CustomFunction middleware must be functions'; throw new CustomFunctionInitializationError(errorMsg); } - }); + } } } @@ -182,9 +172,8 @@ export async function processFunctionMiddleware( const lastCallback = callbacks.pop(); if (lastCallback !== undefined) { - await processMiddleware( - callbacks, args, context, client, logger, - async () => lastCallback({ ...args, context, client, logger }), + await processMiddleware(callbacks, args, context, client, logger, async () => + lastCallback({ ...args, context, client, logger }), ); } } @@ -205,7 +194,8 @@ function selectToken(context: Context): string | undefined { * 2. augments args with step lifecycle-specific properties/utilities * */ export function enrichFunctionArgs( - args: AllCustomFunctionMiddlewareArgs, webClientOptions: WebClientOptions, + args: AllCustomFunctionMiddlewareArgs, + webClientOptions: WebClientOptions, ): AllCustomFunctionMiddlewareArgs { const { next: _next, ...functionArgs } = args; const enrichedArgs = { ...functionArgs }; diff --git a/src/WorkflowStep.ts b/src/WorkflowStep.ts index 05038ece4..84f941517 100644 --- a/src/WorkflowStep.ts +++ b/src/WorkflowStep.ts @@ -1,26 +1,25 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { WorkflowStepExecuteEvent } from '@slack/types'; -import { - KnownBlock, +import type { WorkflowStepExecuteEvent } from '@slack/types'; +import type { Block, + KnownBlock, ViewsOpenResponse, - WorkflowsUpdateStepResponse, WorkflowsStepCompletedResponse, WorkflowsStepFailedResponse, + WorkflowsUpdateStepResponse, } from '@slack/web-api'; -import { - Middleware, +import { WorkflowStepInitializationError } from './errors'; +import processMiddleware from './middleware/process'; +import type { AllMiddlewareArgs, AnyMiddlewareArgs, - SlackActionMiddlewareArgs, - SlackViewMiddlewareArgs, - WorkflowStepEdit, Context, + Middleware, + SlackActionMiddlewareArgs, SlackEventMiddlewareArgs, + SlackViewMiddlewareArgs, ViewWorkflowStepSubmitAction, + WorkflowStepEdit, } from './types'; -import processMiddleware from './middleware/process'; -import { WorkflowStepInitializationError } from './errors'; /** Interfaces */ @@ -38,15 +37,16 @@ export interface StepConfigureArguments { * version. */ export interface StepUpdateArguments { - inputs?: { - [key: string]: { + inputs?: Record< + string, + { + // biome-ignore lint/suspicious/noExplicitAny: user-defined workflow inputs could be anything value: any; skip_variable_replacement?: boolean; - variables?: { - [key: string]: any; - }; - }; - }; + // biome-ignore lint/suspicious/noExplicitAny: user-defined workflow inputs could be anything + variables?: Record; + } + >; outputs?: { name: string; type: string; @@ -60,9 +60,8 @@ export interface StepUpdateArguments { * version. */ export interface StepCompleteArguments { - outputs?: { - [key: string]: any; - }; + // biome-ignore lint/suspicious/noExplicitAny: user-defined workflow outputs could be anything + outputs?: Record; } /** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js @@ -77,30 +76,22 @@ export interface StepFailArguments { /** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js * version. */ -export interface StepConfigureFn { - (params: StepConfigureArguments): Promise; -} +export type StepConfigureFn = (params: StepConfigureArguments) => Promise; /** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js * version. */ -export interface StepUpdateFn { - (params?: StepUpdateArguments): Promise; -} +export type StepUpdateFn = (params?: StepUpdateArguments) => Promise; /** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js * version. */ -export interface StepCompleteFn { - (params?: StepCompleteArguments): Promise; -} +export type StepCompleteFn = (params?: StepCompleteArguments) => Promise; /** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js * version. */ -export interface StepFailFn { - (params: StepFailArguments): Promise; -} +export type StepFailFn = (params: StepFailArguments) => Promise; /** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js * version. @@ -205,7 +196,7 @@ export class WorkflowStep { } public getMiddleware(): Middleware { - return async (args): Promise => { + return async (args): Promise => { if (isStepEvent(args) && this.matchesConstraints(args)) { return this.processEvent(args); } @@ -259,11 +250,11 @@ export function validate(callbackId: string, config: WorkflowStepConfig): void { // Check for missing required keys const requiredKeys: (keyof WorkflowStepConfig)[] = ['save', 'edit', 'execute']; const missingKeys: (keyof WorkflowStepConfig)[] = []; - requiredKeys.forEach((key) => { + for (const key of requiredKeys) { if (config[key] === undefined) { missingKeys.push(key); } - }); + } if (missingKeys.length > 0) { const errorMsg = `WorkflowStep is missing required keys: ${missingKeys.join(', ')}`; @@ -272,12 +263,12 @@ export function validate(callbackId: string, config: WorkflowStepConfig): void { // Ensure a callback or an array of callbacks is present const requiredFns: (keyof WorkflowStepConfig)[] = ['save', 'edit', 'execute']; - requiredFns.forEach((fn) => { + for (const fn of requiredFns) { if (typeof config[fn] !== 'function' && !Array.isArray(config[fn])) { const errorMsg = `WorkflowStep ${fn} property must be a function or an array of functions`; throw new WorkflowStepInitializationError(errorMsg); } - }); + } } /** @@ -296,9 +287,8 @@ export async function processStepMiddleware( const lastCallback = callbacks.pop(); if (lastCallback !== undefined) { - await processMiddleware( - callbacks, args, context, client, logger, - async () => lastCallback({ ...args, context, client, logger }), + await processMiddleware(callbacks, args, context, client, logger, async () => + lastCallback({ ...args, context, client, logger }), ); } } @@ -326,15 +316,16 @@ function createStepConfigure(args: AllWorkflowStepMiddlewareArgs[0]) => client.views.open({ - token, - trigger_id, - view: { - callback_id, - type: 'workflow_step', - ...params, - }, - }); + return (params: Parameters[0]) => + client.views.open({ + token, + trigger_id, + view: { + callback_id, + type: 'workflow_step', + ...params, + }, + }); } /** @@ -351,11 +342,12 @@ function createStepUpdate(args: AllWorkflowStepMiddlewareArgs[0] = {}) => client.workflows.updateStep({ - token, - workflow_step_edit_id, - ...params, - }); + return (params: Parameters[0] = {}) => + client.workflows.updateStep({ + token, + workflow_step_edit_id, + ...params, + }); } /** @@ -372,11 +364,12 @@ function createStepComplete(args: AllWorkflowStepMiddlewareArgs[0] = {}) => client.workflows.stepCompleted({ - token, - workflow_step_execute_id, - ...params, - }); + return (params: Parameters[0] = {}) => + client.workflows.stepCompleted({ + token, + workflow_step_execute_id, + ...params, + }); } /** @@ -411,8 +404,10 @@ function createStepFail(args: AllWorkflowStepMiddlewareArgs { // NOTE: expiresAt is in milliseconds set(conversationId: string, value: ConversationState, expiresAt?: number): Promise; @@ -17,6 +17,7 @@ export interface ConversationStore { * This should not be used in situations where there is more than once instance of the app running because state will * not be shared amongst the processes. */ +// biome-ignore lint/suspicious/noExplicitAny: user-defined convo values can be anything export class MemoryStore implements ConversationStore { private state: Map = new Map(); @@ -53,19 +54,20 @@ export class MemoryStore implements ConversationStore( store: ConversationStore, ): Middleware { return async ({ body, context, next, logger }) => { const { conversationId } = getTypeAndConversation(body); if (conversationId !== undefined) { - context.updateConversation = (conversation: ConversationState, - expiresAt?:number) => store.set(conversationId, conversation, expiresAt); + context.updateConversation = (conversation: ConversationState, expiresAt?: number) => + store.set(conversationId, conversation, expiresAt); try { context.conversation = await store.get(conversationId); logger.debug(`Conversation context loaded for ID: ${conversationId}`); } catch (error) { - const e = error as any; + const e = error as Error; if (e.message !== undefined && e.message !== 'Conversation not found') { // The conversation data can be expired - error: Conversation expired logger.debug(`Conversation context failed loading for ID: ${conversationId}, error: ${e.message}`); diff --git a/src/errors.ts b/src/errors.ts index f628a8e87..582aea969 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,4 +1,4 @@ -import type { IncomingMessage, ServerResponse } from 'http'; +import type { IncomingMessage, ServerResponse } from 'node:http'; import type { BufferedIncomingMessage } from './receivers/BufferedIncomingMessage'; export interface CodedError extends Error { @@ -10,7 +10,7 @@ export interface CodedError extends Error { res?: ServerResponse; // HTTPReceiverDeferredRequestError } -// eslint-disable-next-line @typescript-eslint/no-explicit-any +// biome-ignore lint/suspicious/noExplicitAny: errors can be anything export function isCodedError(err: any): err is CodedError { return 'code' in err; } diff --git a/src/helpers.ts b/src/helpers.ts index 21a4e7813..394dc87a3 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,27 +1,26 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { - SlackEventMiddlewareArgs, +import type { + AnyMiddlewareArgs, + MessageShortcut, + OptionsSource, + ReceiverEvent, + SlackAction, + SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs, + SlackEventMiddlewareArgs, SlackOptionsMiddlewareArgs, - SlackActionMiddlewareArgs, SlackShortcutMiddlewareArgs, - SlackAction, - OptionsSource, - MessageShortcut, - AnyMiddlewareArgs, - ReceiverEvent, } from './types'; /** * Internal data type for capturing the class of event processed in App#onIncomingEvent() */ export enum IncomingEventType { - Event, - Action, - Command, - Options, - ViewAction, - Shortcut, + Event = 0, + Action = 1, + Command = 2, + Options = 3, + ViewAction = 4, // TODO: terminology: ViewAction? Why Action? + Shortcut = 5, } // ---------------------------- @@ -35,6 +34,7 @@ const eventTypesToSkipAuthorize = ['app_uninstalled', 'tokens_revoked']; * This is analogous to WhenEventHasChannelContext and the conditional type that checks SlackAction for a channel * context. */ +// biome-ignore lint/suspicious/noExplicitAny: response bodies can be anything export function getTypeAndConversation(body: any): { type?: IncomingEventType; conversationId?: string } { if (body.event !== undefined) { const { event } = body as SlackEventMiddlewareArgs['body']; @@ -59,7 +59,7 @@ export function getTypeAndConversation(body: any): { type?: IncomingEventType; c // Using non-null assertion (!) because the alternative is to use `foundConversation: (string | undefined)`, which // impedes the very useful type checker help above that ensures the value is only defined to strings, not // undefined. This is safe when used in combination with the || operator with a default value. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // biome-ignore lint/style/noNonNullAssertion: TODO: revisit this and use the types return foundConversationId! || undefined; })(); diff --git a/src/index.ts b/src/index.ts index 26b064d44..3404674d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,16 +21,16 @@ export { default as AwsLambdaReceiver, AwsLambdaReceiverOptions } from './receiv export { BufferedIncomingMessage } from './receivers/BufferedIncomingMessage'; export { - HTTPModuleFunctions, RequestVerificationOptions, ReceiverDispatchErrorHandlerArgs, ReceiverProcessEventErrorHandlerArgs, ReceiverUnhandledRequestHandlerArgs, } from './receivers/HTTPModuleFunctions'; +export * as HTTPModuleFunctions from './receivers/HTTPModuleFunctions'; export { HTTPResponseAck } from './receivers/HTTPResponseAck'; export { - SocketModeFunctions, + defaultProcessEventErrorHandler, SocketModeReceiverProcessEventErrorHandlerArgs, } from './receivers/SocketModeFunctions'; diff --git a/src/middleware/builtin.spec.ts b/src/middleware/builtin.spec.ts deleted file mode 100644 index 8f76c1c90..000000000 --- a/src/middleware/builtin.spec.ts +++ /dev/null @@ -1,902 +0,0 @@ -import 'mocha'; -import { assert } from 'chai'; -import sinon from 'sinon'; -import rewiremock from 'rewiremock'; -import { Logger } from '@slack/logger'; -import { WebClient } from '@slack/web-api'; -import { - AppHomeOpenedEvent, - AppMentionEvent, - GenericMessageEvent, - MessageEvent, - SlackEvent, -} from '@slack/types'; -import { ErrorCode } from '../errors'; -import { Override, createFakeLogger } from '../test-helpers'; -import { - SlackEventMiddlewareArgs, - NextFn, - Context, - SlackCommandMiddlewareArgs, -} from '../types'; -import { onlyCommands, onlyEvents, matchCommandName, matchEventType, subtype } from './builtin'; -import { SlashCommand } from '../types/command'; - -// Test fixtures -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', - api_app_id: 'A123456', -}; - -const appMentionEvent: AppMentionEvent = { - type: 'app_mention', - username: 'USERNAME', - user: 'U1234567', - text: 'this is my message', - ts: '123.123', - channel: 'C1234567', - event_ts: '123.123', - thread_ts: '123.123', -}; - -const appHomeOpenedEvent: AppHomeOpenedEvent = { - type: 'app_home_opened', - user: 'USERNAME', - channel: 'U1234567', - tab: 'home', - view: { - type: 'home', - blocks: [], - external_id: '', - }, - event_ts: '123.123', -}; - -const botMessageEvent: MessageEvent = { - type: 'message', - subtype: 'bot_message', - channel: 'CHANNEL_ID', - event_ts: '123.123', - user: 'U1234567', - ts: '123.123', - text: 'this is my message', - bot_id: 'B1234567', - channel_type: 'channel', -}; - -const noop = () => Promise.resolve(undefined); -const sayNoop = () => Promise.resolve({ ok: true }); - -describe('Built-in global middleware', () => { - describe('matchMessage()', () => { - function initializeTestCase(pattern: string | RegExp): Mocha.AsyncFunc { - return async () => { - // Arrange - const { matchMessage } = await importBuiltin(); - - // Act - const middleware = matchMessage(pattern); - - // Assert - assert.isOk(middleware); - }; - } - - function matchesPatternTestCase( - pattern: string | RegExp, - matchingText: string, - buildFakeEvent: (content: string) => SlackEvent, - ): Mocha.AsyncFunc { - return async () => { - // Arrange - const dummyContext: DummyContext = {}; - const fakeNext = sinon.fake(); - const fakeArgs = { - next: fakeNext, - event: buildFakeEvent(matchingText), - context: dummyContext, - } as unknown as MessageMiddlewareArgs; - const { matchMessage } = await importBuiltin(); - - // Act - const middleware = matchMessage(pattern); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.called); - // The following assertion(s) check behavior that is only targeted at RegExp patterns - if (typeof pattern !== 'string') { - if (dummyContext.matches !== undefined) { - assert.lengthOf(dummyContext.matches, 1); - } else { - assert.fail(); - } - } - }; - } - - function notMatchesPatternTestCase( - pattern: string | RegExp, - nonMatchingText: string, - buildFakeEvent: (content: string) => SlackEvent, - ): Mocha.AsyncFunc { - return async () => { - // Arrange - const dummyContext = {}; - const fakeNext = sinon.fake(); - const fakeArgs = { - event: buildFakeEvent(nonMatchingText), - context: dummyContext, - next: fakeNext, - } as unknown as MessageMiddlewareArgs; - const { matchMessage } = await importBuiltin(); - - // Act - const middleware = matchMessage(pattern); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.notCalled); - assert.notProperty(dummyContext, 'matches'); - }; - } - - function noTextMessageTestCase(pattern: string | RegExp): Mocha.AsyncFunc { - return async () => { - // Arrange - const dummyContext = {}; - const fakeNext = sinon.fake(); - const fakeArgs = { - event: createFakeMessageEvent([{ type: 'divider' }]), - context: dummyContext, - next: fakeNext, - } as unknown as MessageMiddlewareArgs; - const { matchMessage } = await importBuiltin(); - - // Act - const middleware = matchMessage(pattern); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.notCalled); - assert.notProperty(dummyContext, 'matches'); - }; - } - - describe('using a string pattern', () => { - const pattern = 'foo'; - const matchingText = 'foobar'; - const nonMatchingText = 'bar'; - it('should initialize', initializeTestCase(pattern)); - it( - 'should match message events with a pattern that matches', - matchesPatternTestCase(pattern, matchingText, createFakeMessageEvent), - ); - it( - 'should match app_mention events with a pattern that matches', - matchesPatternTestCase(pattern, matchingText, createFakeAppMentionEvent), - ); - it( - 'should filter out message events with a pattern that does not match', - notMatchesPatternTestCase(pattern, nonMatchingText, createFakeMessageEvent), - ); - it( - 'should filter out app_mention events with a pattern that does not match', - notMatchesPatternTestCase(pattern, nonMatchingText, createFakeAppMentionEvent), - ); - it('should filter out message events which do not have text (block kit)', noTextMessageTestCase(pattern)); - }); - - describe('using a RegExp pattern', () => { - const pattern = /foo/; - const matchingText = 'foobar'; - const nonMatchingText = 'bar'; - it('should initialize', initializeTestCase(pattern)); - it( - 'should match message events with a pattern that matches', - matchesPatternTestCase(pattern, matchingText, createFakeMessageEvent), - ); - it( - 'should match app_mention events with a pattern that matches', - matchesPatternTestCase(pattern, matchingText, createFakeAppMentionEvent), - ); - it( - 'should filter out message events with a pattern that does not match', - notMatchesPatternTestCase(pattern, nonMatchingText, createFakeMessageEvent), - ); - it( - 'should filter out app_mention events with a pattern that does not match', - notMatchesPatternTestCase(pattern, nonMatchingText, createFakeAppMentionEvent), - ); - it('should filter out message events which do not have text (block kit)', noTextMessageTestCase(pattern)); - }); - }); - - describe('directMention()', () => { - it('should bail when the context does not provide a bot user ID', async () => { - // Arrange - const fakeArgs = { - next: () => Promise.resolve(), - message: createFakeMessageEvent(), - context: { - isEnterpriseInstall: false, - }, - } as unknown as MessageMiddlewareArgs; - const { directMention } = await importBuiltin(); - - // Act - const middleware = directMention(); - - let error; - - try { - await middleware(fakeArgs); - } catch (err) { - error = err; - } - - assert.instanceOf(error, Error); - assert.propertyVal(error, 'code', ErrorCode.ContextMissingPropertyError); - assert.propertyVal(error, 'missingProperty', 'botUserId'); - }); - - it('should match message events that mention the bot user ID at the beginning of message text', async () => { - // Arrange - const fakeBotUserId = 'B123456'; - const messageText = `<@${fakeBotUserId}> hi`; - const fakeNext = sinon.fake(); - // TODO: make test utilities that create fake middleware arguments and events - const fakeArgs = { - next: fakeNext, - message: createFakeMessageEvent(messageText), - context: { botUserId: fakeBotUserId }, - } as unknown as MessageMiddlewareArgs; - const { directMention } = await importBuiltin(); - - // Act - const middleware = directMention(); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.called); - }); - - it('should not match message events that do not mention the bot user ID', async () => { - // Arrange - const fakeBotUserId = 'B123456'; - const messageText = 'hi'; - const fakeNext = sinon.fake(); - const fakeArgs = { - next: fakeNext, - message: createFakeMessageEvent(messageText), - context: { botUserId: fakeBotUserId }, - } as unknown as MessageMiddlewareArgs; - const { directMention } = await importBuiltin(); - - // Act - const middleware = directMention(); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.notCalled); - }); - - it('should not match message events that mention the bot user ID NOT at the beginning of message text', async () => { - // Arrange - const fakeBotUserId = 'B123456'; - const messageText = `hello <@${fakeBotUserId}>`; - const fakeNext = sinon.fake(); - const fakeArgs = { - next: fakeNext, - message: createFakeMessageEvent(messageText), - context: { botUserId: fakeBotUserId }, - } as unknown as MessageMiddlewareArgs; - const { directMention } = await importBuiltin(); - - // Act - const middleware = directMention(); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.notCalled); - }); - - it('should not match message events which do not have text (block kit)', async () => { - // Arrange - const fakeBotUserId = 'B123456'; - const fakeNext = sinon.fake(); - const fakeArgs = { - next: fakeNext, - message: createFakeMessageEvent([{ type: 'divider' }]), - context: { botUserId: fakeBotUserId }, - } as unknown as MessageMiddlewareArgs; - const { directMention } = await importBuiltin(); - - // Act - const middleware = directMention(); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.notCalled); - }); - - it('should not match message events that contain a link to a conversation at the beginning', async () => { - // Arrange - const fakeBotUserId = 'B123456'; - const messageText = '<#C12345> hi'; - const fakeNext = sinon.fake(); - const fakeArgs = { - next: fakeNext, - message: createFakeMessageEvent(messageText), - context: { botUserId: fakeBotUserId }, - } as unknown as MessageMiddlewareArgs; - const { directMention } = await importBuiltin(); - - // Act - const middleware = directMention(); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.notCalled); - }); - }); - - describe('ignoreSelf()', () => { - it("should immediately call next(), because incoming middleware args don't contain event", async () => { - // Arrange - const fakeNext = sinon.fake(); - const fakeBotUserId = 'BUSER1'; - const fakeArgs = { - next: fakeNext, - context: { botUserId: fakeBotUserId, botId: fakeBotUserId }, - command: { - command: '/fakeCommand', - }, - } as unknown as CommandMiddlewareArgs; - - const { ignoreSelf: getIgnoreSelfMiddleware } = await importBuiltin(); - - // Act - const middleware = getIgnoreSelfMiddleware(); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.calledOnce); - }); - - it('should look for an event identified as a bot message from the same bot ID as this app and skip it', async () => { - // Arrange - const fakeNext = sinon.fake(); - const fakeBotUserId = 'BUSER1'; - // TODO: Fix typings here - const fakeArgs = { - next: fakeNext, - context: { botUserId: fakeBotUserId, botId: fakeBotUserId }, - event: { - type: 'message', - subtype: 'bot_message', - bot_id: fakeBotUserId, - }, - message: { - type: 'message', - subtype: 'bot_message', - bot_id: fakeBotUserId, - }, - } as any; - - const { ignoreSelf: getIgnoreSelfMiddleware } = await importBuiltin(); - - // Act - const middleware = getIgnoreSelfMiddleware(); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.notCalled); - }); - - it('should filter an event out when only a botUserId is passed', async () => { - // Arrange - const fakeNext = sinon.fake(); - const fakeBotUserId = 'BUSER1'; - const fakeArgs = { - next: fakeNext, - context: { botUserId: fakeBotUserId }, - event: { - type: 'tokens_revoked', - user: fakeBotUserId, - }, - } as unknown as TokensRevokedMiddlewareArgs; - - const { ignoreSelf: getIgnoreSelfMiddleware } = await importBuiltin(); - - // Act - const middleware = getIgnoreSelfMiddleware(); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.notCalled); - }); - - it("should filter an event out, because it matches our own app and shouldn't be retained", async () => { - // Arrange - const fakeNext = sinon.fake(); - const fakeBotUserId = 'BUSER1'; - const fakeArgs = { - next: fakeNext, - context: { botUserId: fakeBotUserId, botId: fakeBotUserId }, - event: { - type: 'tokens_revoked', - user: fakeBotUserId, - }, - } as unknown as TokensRevokedMiddlewareArgs; - - const { ignoreSelf: getIgnoreSelfMiddleware } = await importBuiltin(); - - // Act - const middleware = getIgnoreSelfMiddleware(); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.notCalled); - }); - - it("should filter an event out, because it matches our own app and shouldn't be retained", async () => { - // Arrange - const fakeNext = sinon.fake(); - const fakeBotUserId = 'BUSER1'; - const fakeArgs = { - next: fakeNext, - context: { botUserId: fakeBotUserId, botId: fakeBotUserId }, - event: { - type: 'tokens_revoked', - user: fakeBotUserId, - }, - } as unknown as TokensRevokedMiddlewareArgs; - - const { ignoreSelf: getIgnoreSelfMiddleware } = await importBuiltin(); - - // Act - const middleware = getIgnoreSelfMiddleware(); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.notCalled); - }); - - it("shouldn't filter an event out, because it should be retained", async () => { - // Arrange - const fakeNext = sinon.fake(); - const fakeBotUserId = 'BUSER1'; - const eventsWhichShouldNotBeFilteredOut = ['member_joined_channel', 'member_left_channel']; - - const listOfFakeArgs = eventsWhichShouldNotBeFilteredOut.map((eventType) => ({ - next: fakeNext, - context: { botUserId: fakeBotUserId, botId: fakeBotUserId }, - event: { - type: eventType, - user: fakeBotUserId, - }, - } as unknown as MemberJoinedOrLeftChannelMiddlewareArgs)); - - const { ignoreSelf: getIgnoreSelfMiddleware } = await importBuiltin(); - - // Act - const middleware = getIgnoreSelfMiddleware(); - await Promise.all(listOfFakeArgs.map(middleware)); - - // Assert - assert.equal(fakeNext.callCount, listOfFakeArgs.length); - }); - }); - - describe('onlyCommands', () => { - const logger = createFakeLogger(); - const client = new WebClient(undefined, { logger, slackApiUrl: undefined }); - - it('should detect valid requests', async () => { - const payload: SlashCommand = { ...validCommandPayload }; - const fakeNext = sinon.fake(); - const args = { - logger, - client, - payload, - command: payload, - body: payload, - say: sayNoop, - respond: noop, - ack: noop, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - }; - await onlyCommands(args); - assert.isTrue(fakeNext.called); - }); - - it('should skip other requests', async () => { - const payload: any = {}; - const fakeNext = sinon.fake(); - const args = { - logger, - client, - payload, - action: payload, - command: undefined, - body: payload, - say: sayNoop, - respond: noop, - ack: noop, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - }; - await onlyCommands(args); - assert.isTrue(fakeNext.notCalled); - }); - }); - - describe('matchCommandName', () => { - const logger = createFakeLogger(); - const client = new WebClient(undefined, { logger, slackApiUrl: undefined }); - - function buildArgs(fakeNext: NextFn): SlackCommandMiddlewareArgs & MiddlewareCommonArgs { - const payload: SlashCommand = { ...validCommandPayload }; - return { - payload, - logger, - client, - command: payload, - body: payload, - say: sayNoop, - respond: noop, - ack: noop, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - }; - } - - it('should detect requests that match exactly', async () => { - const fakeNext = sinon.fake(); - await matchCommandName('/hi')(buildArgs(fakeNext)); - assert.isTrue(fakeNext.called); - }); - - it('should detect requests that match a pattern', async () => { - const fakeNext = sinon.fake(); - await matchCommandName(/h/)(buildArgs(fakeNext)); - assert.isTrue(fakeNext.called); - }); - - it('should skip other requests', async () => { - const fakeNext = sinon.fake(); - await matchCommandName('/hello')(buildArgs(fakeNext)); - assert.isTrue(fakeNext.notCalled); - }); - }); - - describe('onlyEvents', () => { - const logger = createFakeLogger(); - const client = new WebClient(undefined, { logger, slackApiUrl: undefined }); - - it('should detect valid requests', async () => { - const fakeNext = sinon.fake(); - // FIXME: Removing type def here is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - const args /* : SlackEventMiddlewareArgs<'app_mention'> & { event?: SlackEvent } */ = { - payload: appMentionEvent, - event: appMentionEvent, - message: null as never, // a bit hackey to satisfy TS compiler as 'null' cannot be assigned to type 'never' - 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: sayNoop, - }; - const allArgs = { - logger, - client, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - ...args, - }; - // FIXME: Using any is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - await onlyEvents(allArgs as any); - assert.isTrue(fakeNext.called); - }); - - it('should skip other requests', async () => { - const payload: SlashCommand = { ...validCommandPayload }; - const fakeNext = sinon.fake(); - const args = { - logger, - client, - payload, - command: payload, - body: payload, - say: sayNoop, - respond: noop, - ack: noop, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - }; - await onlyEvents(args); - assert.isFalse(fakeNext.called); - }); - }); - - describe('matchEventType', () => { - const logger = createFakeLogger(); - const client = new WebClient(undefined, { logger, slackApiUrl: undefined }); - - function buildArgs(): SlackEventMiddlewareArgs<'app_mention'> & { event?: SlackEvent } { - // FIXME: Using any here is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - return { - payload: appMentionEvent, - event: appMentionEvent, - message: null as never, // a bit hackey to satisfy TS compiler as 'null' cannot be assigned to type 'never' - 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: sayNoop, - } as any; - } - - function buildArgsAppHomeOpened(): SlackEventMiddlewareArgs<'app_home_opened'> & { - event?: SlackEvent; - } { - // FIXME: Using any here is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - return { - payload: appHomeOpenedEvent, - event: appHomeOpenedEvent, - message: null as never, // a bit hackey to satisfy TS compiler as 'null' cannot be assigned to type 'never' - body: { - token: 'token-value', - team_id: 'T1234567', - api_app_id: 'A1234567', - event: appHomeOpenedEvent, - type: 'event_callback', - event_id: 'event-id-value', - event_time: 123, - authed_users: [], - }, - say: sayNoop, - } as any; - } - - it('should detect valid requests', async () => { - const fakeNext = sinon.fake(); - // FIXME: Using any here is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - const _args = buildArgs() as any; - const args = { - logger, - client, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - ..._args, - }; - await matchEventType('app_mention')(args); - assert.isTrue(fakeNext.called); - }); - - it('should detect valid RegExp requests with app_mention', async () => { - const fakeNext = sinon.fake(); - // FIXME: Using any here is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - const _args = buildArgs() as any; - const args = { - logger, - client, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - ..._args, - }; - await matchEventType(/app_mention|app_home_opened/)(args); - assert.isTrue(fakeNext.called); - }); - - it('should detect valid RegExp requests with app_home_opened', async () => { - const fakeNext = sinon.fake(); - // FIXME: Using any here is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - const _args = buildArgsAppHomeOpened() as any; - const args = { - logger, - client, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - ..._args, - }; - await matchEventType(/app_mention|app_home_opened/)(args); - assert.isTrue(fakeNext.called); - }); - - it('should skip other requests', async () => { - const fakeNext = sinon.fake(); - // FIXME: Using any here is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - const _args = buildArgs() as any; - const args = { - logger, - client, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - ..._args, - }; - await matchEventType('app_home_opened')(args); - assert.isFalse(fakeNext.called); - }); - - it('should skip other requests for RegExp', async () => { - const fakeNext = sinon.fake(); - // FIXME: Using any here is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - const _args = buildArgs() as any; - const args = { - logger, - client, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - ..._args, - } as any; - await matchEventType(/foo/)(args); - assert.isFalse(fakeNext.called); - }); - }); - - describe('subtype', () => { - const logger = createFakeLogger(); - const client = new WebClient(undefined, { logger, slackApiUrl: undefined }); - - function buildArgs(): SlackEventMiddlewareArgs<'message'> & { event?: SlackEvent } { - // FIXME: Using any here is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - 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: sayNoop, - } as any; - } - - it('should detect valid requests', async () => { - const fakeNext = sinon.fake(); - // FIXME: Using any here is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - const _args = buildArgs() as any; - const args = { - logger, - client, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - ..._args, - }; - await subtype('bot_message')(args); - assert.isTrue(fakeNext.called); - }); - - it('should skip other requests', async () => { - const fakeNext = sinon.fake(); - // FIXME: Using any here is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - const _args = buildArgs() as any; - const args = { - logger, - client, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - ..._args, - }; - await subtype('me_message')(args); - assert.isFalse(fakeNext.called); - }); - }); -}); - -/* Testing Harness */ - -interface DummyContext { - matches?: RegExpExecArray; -} - -interface MiddlewareCommonArgs { - next: NextFn; - context: Context; - logger: Logger; - client: WebClient; -} -type MessageMiddlewareArgs = SlackEventMiddlewareArgs<'message'> & MiddlewareCommonArgs; -type TokensRevokedMiddlewareArgs = SlackEventMiddlewareArgs<'tokens_revoked'> & MiddlewareCommonArgs; - -type MemberJoinedOrLeftChannelMiddlewareArgs = SlackEventMiddlewareArgs<'member_joined_channel' | 'member_left_channel'> & MiddlewareCommonArgs; - -type CommandMiddlewareArgs = SlackCommandMiddlewareArgs & MiddlewareCommonArgs; - -async function importBuiltin(overrides: Override = {}): Promise { - return rewiremock.module(() => import('./builtin'), overrides); -} - -function createFakeMessageEvent(content: string | GenericMessageEvent['blocks'] = ''): MessageEvent { - const event: Partial = { - type: 'message', - channel: 'CHANNEL_ID', - user: 'USER_ID', - ts: 'MESSAGE_ID', - }; - if (typeof content === 'string') { - event.text = content; - } else { - event.blocks = content; - } - return event as MessageEvent; -} - -function createFakeAppMentionEvent(text: string = ''): AppMentionEvent { - const event: Partial = { - text, - type: 'app_mention', - user: 'USER_ID', - ts: 'MESSAGE_ID', - channel: 'CHANNEL_ID', - event_ts: 'MESSAGE_ID', - }; - return event as AppMentionEvent; -} diff --git a/src/middleware/builtin.ts b/src/middleware/builtin.ts index ea96412ec..90f00d307 100644 --- a/src/middleware/builtin.ts +++ b/src/middleware/builtin.ts @@ -1,32 +1,32 @@ -import { - Middleware, +import type { ActionConstraints, OptionsConstraints, ShortcutConstraints, ViewConstraints } from '../App'; +import { ContextMissingPropertyError } from '../errors'; +import type { AnyMiddlewareArgs, + BlockElementAction, + BlockSuggestion, + DialogSubmitAction, + DialogSuggestion, + EventTypePattern, + GlobalShortcut, + InteractiveMessage, + InteractiveMessageSuggestion, + MessageShortcut, + Middleware, SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs, SlackEventMiddlewareArgs, SlackOptionsMiddlewareArgs, SlackShortcutMiddlewareArgs, - SlackViewMiddlewareArgs, - BlockSuggestion, - InteractiveMessageSuggestion, - DialogSuggestion, - InteractiveMessage, - DialogSubmitAction, - GlobalShortcut, - MessageShortcut, - BlockElementAction, SlackViewAction, - EventTypePattern, + SlackViewMiddlewareArgs, } from '../types'; -import { ActionConstraints, ViewConstraints, ShortcutConstraints, OptionsConstraints } from '../App'; -import { ContextMissingPropertyError } from '../errors'; /** Type predicate that can narrow payloads block action or suggestion payloads */ function isBlockPayload( payload: - | SlackActionMiddlewareArgs['payload'] - | SlackOptionsMiddlewareArgs['payload'] - | SlackViewMiddlewareArgs['payload'], + | SlackActionMiddlewareArgs['payload'] + | SlackOptionsMiddlewareArgs['payload'] + | SlackViewMiddlewareArgs['payload'], ): payload is BlockElementAction | BlockSuggestion { return 'action_id' in payload && payload.action_id !== undefined; } @@ -55,15 +55,11 @@ function isViewBody( return 'view' in body && body.view !== undefined; } -function isEventArgs( - args: AnyMiddlewareArgs, -): args is SlackEventMiddlewareArgs { +function isEventArgs(args: AnyMiddlewareArgs): args is SlackEventMiddlewareArgs { return 'event' in args && args.event !== undefined; } -function isMessageEventArgs( - args: AnyMiddlewareArgs, -): args is SlackEventMiddlewareArgs<'message'> { +function isMessageEventArgs(args: AnyMiddlewareArgs): args is SlackEventMiddlewareArgs<'message'> { return isEventArgs(args) && 'message' in args; } @@ -308,12 +304,16 @@ export const ignoreSelf: Middleware = async (args) => { } } - // Its an Events API event that isn't of type message, but the user ID might match our own app. Filter these out. + // It's an Events API event that isn't of type message, but the user ID might match our own app. Filter these out. // However, some events still must be fired, because they can make sense. const eventsWhichShouldBeKept = ['member_joined_channel', 'member_left_channel']; - const isEventShouldBeKept = eventsWhichShouldBeKept.includes(args.event.type); - if (botUserId !== undefined && 'user' in args.event && args.event.user === botUserId && !isEventShouldBeKept) { + if ( + botUserId !== undefined && + 'user' in args.event && + args.event.user === botUserId && + !eventsWhichShouldBeKept.includes(args.event.type) + ) { return; } } @@ -322,6 +322,7 @@ export const ignoreSelf: Middleware = async (args) => { await args.next(); }; +// TODO: breaking change: constrain the subtype argument to be a valid message subtype /** * Filters out any message events whose subtype does not match the provided subtype. */ diff --git a/src/middleware/process.ts b/src/middleware/process.ts index cc963d8f3..04db84894 100644 --- a/src/middleware/process.ts +++ b/src/middleware/process.ts @@ -1,6 +1,6 @@ -import { WebClient } from '@slack/web-api'; -import { Logger } from '@slack/logger'; -import { Middleware, AnyMiddlewareArgs, Context } from '../types'; +import type { Logger } from '@slack/logger'; +import type { WebClient } from '@slack/web-api'; +import type { AnyMiddlewareArgs, Context, Middleware } from '../types'; export default async function processMiddleware( middleware: Middleware[], diff --git a/src/receivers/AwsLambdaReceiver.spec.ts b/src/receivers/AwsLambdaReceiver.spec.ts deleted file mode 100644 index da6b261d4..000000000 --- a/src/receivers/AwsLambdaReceiver.spec.ts +++ /dev/null @@ -1,623 +0,0 @@ -import crypto from 'crypto'; -import sinon from 'sinon'; -import { Logger, LogLevel } from '@slack/logger'; -import { assert } from 'chai'; -import 'mocha'; -import rewiremock from 'rewiremock'; -import { WebClientOptions } from '@slack/web-api'; -import AwsLambdaReceiver, { AwsHandler } from './AwsLambdaReceiver'; -import { Override, mergeOverrides } from '../test-helpers'; - -const noop = () => Promise.resolve(undefined); - -describe('AwsLambdaReceiver', function () { - beforeEach(function () {}); - - const noopLogger: Logger = { - 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 { - /* noop */ - }, - }; - - it('should instantiate with default logger', async (): Promise => { - const awsReceiver = new AwsLambdaReceiver({ - signingSecret: 'my-secret', - logger: noopLogger, - }); - assert.isNotNull(awsReceiver); - }); - - it('should have start method', async (): Promise => { - const awsReceiver = new AwsLambdaReceiver({ - signingSecret: 'my-secret', - logger: noopLogger, - }); - const startedHandler: AwsHandler = await awsReceiver.start(); - assert.isNotNull(startedHandler); - }); - - it('should have stop method', async (): Promise => { - const awsReceiver = new AwsLambdaReceiver({ - signingSecret: 'my-secret', - logger: noopLogger, - }); - await awsReceiver.start(); - await awsReceiver.stop(); - }); - - it('should accept events', async (): Promise => { - const awsReceiver = new AwsLambdaReceiver({ - signingSecret: 'my-secret', - logger: noopLogger, - }); - const handler = awsReceiver.toHandler(); - const timestamp = Math.floor(Date.now() / 1000); - const body = JSON.stringify({ - token: 'fixed-value', - team_id: 'T111', - enterprise_id: 'E111', - api_app_id: 'A111', - event: { - client_msg_id: '977a7fa8-c9b3-4b51-a0b6-3b6c647e2165', - type: 'app_mention', - text: '<@U222> test', - user: 'W111', - ts: '1612879521.002100', - team: 'T111', - channel: 'C111', - event_ts: '1612879521.002100', - }, - type: 'event_callback', - event_id: 'Ev111', - event_time: 1612879521, - authorizations: [ - { - enterprise_id: 'E111', - team_id: 'T111', - user_id: 'W111', - is_bot: true, - is_enterprise_install: false, - }, - ], - is_ext_shared_channel: false, - event_context: '1-app_mention-T111-C111', - }); - const signature = crypto.createHmac('sha256', 'my-secret').update(`v0:${timestamp}:${body}`).digest('hex'); - const awsEvent = { - resource: '/slack/events', - path: '/slack/events', - httpMethod: 'POST', - 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}`, - }, - multiValueHeaders: {}, - queryStringParameters: null, - multiValueQueryStringParameters: null, - pathParameters: null, - stageVariables: null, - requestContext: {}, - body, - isBase64Encoded: false, - }; - const response1 = await handler( - awsEvent, - {}, - (_error, _result) => {}, - ); - assert.equal(response1.statusCode, 404); - const App = await importApp(); - const app = new App({ - token: 'xoxb-', - receiver: awsReceiver, - }); - app.event('app_mention', noop); - const response2 = await handler( - awsEvent, - {}, - (_error, _result) => {}, - ); - assert.equal(response2.statusCode, 200); - }); - - it('should accept proxy events with lowercase header properties', async (): Promise => { - const awsReceiver = new AwsLambdaReceiver({ - signingSecret: 'my-secret', - logger: noopLogger, - }); - const handler = awsReceiver.toHandler(); - const timestamp = Math.floor(Date.now() / 1000); - const body = JSON.stringify({ - token: 'fixed-value', - team_id: 'T111', - enterprise_id: 'E111', - api_app_id: 'A111', - event: { - client_msg_id: '977a7fa8-c9b3-4b51-a0b6-3b6c647e2165', - type: 'app_mention', - text: '<@U222> test', - user: 'W111', - ts: '1612879521.002100', - team: 'T111', - channel: 'C111', - event_ts: '1612879521.002100', - }, - type: 'event_callback', - event_id: 'Ev111', - event_time: 1612879521, - authorizations: [ - { - enterprise_id: 'E111', - team_id: 'T111', - user_id: 'W111', - is_bot: true, - is_enterprise_install: false, - }, - ], - is_ext_shared_channel: false, - event_context: '1-app_mention-T111-C111', - }); - const signature = crypto.createHmac('sha256', 'my-secret').update(`v0:${timestamp}:${body}`).digest('hex'); - const awsEvent = { - resource: '/slack/events', - path: '/slack/events', - httpMethod: 'POST', - 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}`, - }, - multiValueHeaders: {}, - queryStringParameters: null, - multiValueQueryStringParameters: null, - pathParameters: null, - stageVariables: null, - requestContext: {}, - body, - isBase64Encoded: false, - }; - const response1 = await handler( - awsEvent, - {}, - (_error, _result) => {}, - ); - assert.equal(response1.statusCode, 404); - const App = await importApp(); - const app = new App({ - token: 'xoxb-', - receiver: awsReceiver, - }); - app.event('app_mention', noop); - const response2 = await handler( - awsEvent, - {}, - (_error, _result) => {}, - ); - assert.equal(response2.statusCode, 200); - }); - - it('should accept interactivity requests', async (): Promise => { - const awsReceiver = new AwsLambdaReceiver({ - signingSecret: 'my-secret', - logger: noopLogger, - }); - const handler = awsReceiver.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-aws-lambda-shortcut%22%2C%22trigger_id%22%3A%22111.222.xxx%22%7D'; - const signature = crypto.createHmac('sha256', 'my-secret').update(`v0:${timestamp}:${body}`).digest('hex'); - const awsEvent = { - resource: '/slack/events', - path: '/slack/events', - httpMethod: 'POST', - headers: { - Accept: 'application/json,*/*', - 'Content-Type': 'application/x-www-form-urlencoded', - 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}`, - }, - multiValueHeaders: {}, - queryStringParameters: null, - multiValueQueryStringParameters: null, - pathParameters: null, - stageVariables: null, - requestContext: {}, - body, - isBase64Encoded: false, - }; - const response1 = await handler( - awsEvent, - {}, - (_error, _result) => {}, - ); - assert.equal(response1.statusCode, 404); - const App = await importApp(); - const app = new App({ - token: 'xoxb-', - receiver: awsReceiver, - }); - app.shortcut('bolt-js-aws-lambda-shortcut', async ({ ack }) => { - await ack(); - }); - const response2 = await handler( - awsEvent, - {}, - (_error, _result) => {}, - ); - assert.equal(response2.statusCode, 200); - }); - - it('should accept slash commands', async (): Promise => { - const awsReceiver = new AwsLambdaReceiver({ - signingSecret: 'my-secret', - logger: noopLogger, - }); - const handler = awsReceiver.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&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 signature = crypto.createHmac('sha256', 'my-secret').update(`v0:${timestamp}:${body}`).digest('hex'); - const awsEvent = { - resource: '/slack/events', - path: '/slack/events', - httpMethod: 'POST', - headers: { - Accept: 'application/json,*/*', - 'Content-Type': 'application/x-www-form-urlencoded', - 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}`, - }, - multiValueHeaders: {}, - queryStringParameters: null, - multiValueQueryStringParameters: null, - pathParameters: null, - stageVariables: null, - requestContext: {}, - body, - isBase64Encoded: false, - }; - const response1 = await handler( - awsEvent, - {}, - (_error, _result) => {}, - ); - assert.equal(response1.statusCode, 404); - const App = await importApp(); - const app = new App({ - token: 'xoxb-', - receiver: awsReceiver, - }); - app.command('/hello-bolt-js', async ({ ack }) => { - await ack(); - }); - const response2 = await handler( - awsEvent, - {}, - (_error, _result) => {}, - ); - assert.equal(response2.statusCode, 200); - }); - - it('should accept an event containing a base64 encoded body', async () => { - const awsReceiver = new AwsLambdaReceiver({ - signingSecret: 'my-secret', - logger: noopLogger, - }); - const handler = awsReceiver.toHandler(); - const timestamp = Math.floor(Date.now() / 1000); - const body = JSON.stringify({ - token: 'fixed-value', - team_id: 'T111', - enterprise_id: 'E111', - api_app_id: 'A111', - event: { - client_msg_id: '977a7fa8-c9b3-4b51-a0b6-3b6c647e2165', - type: 'app_mention', - text: '<@U222> test', - user: 'W111', - ts: '1612879521.002100', - team: 'T111', - channel: 'C111', - event_ts: '1612879521.002100', - }, - type: 'event_callback', - event_id: 'Ev111', - event_time: 1612879521, - authorizations: [ - { - enterprise_id: 'E111', - team_id: 'T111', - user_id: 'W111', - is_bot: true, - is_enterprise_install: false, - }, - ], - is_ext_shared_channel: false, - event_context: '1-app_mention-T111-C111', - }); - const signature = crypto.createHmac('sha256', 'my-secret').update(`v0:${timestamp}:${body}`).digest('hex'); - const awsEvent = { - resource: '/slack/events', - path: '/slack/events', - httpMethod: 'POST', - 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}`, - }, - multiValueHeaders: {}, - queryStringParameters: null, - multiValueQueryStringParameters: null, - pathParameters: null, - stageVariables: null, - requestContext: {}, - body: Buffer.from(body).toString('base64'), - isBase64Encoded: true, - }; - const response1 = await handler( - awsEvent, - {}, - (_error, _result) => {}, - ); - assert.equal(response1.statusCode, 404); - }); - - it('should accept ssl_check requests', async (): Promise => { - const awsReceiver = new AwsLambdaReceiver({ - signingSecret: 'my-secret', - logger: noopLogger, - }); - const handler = awsReceiver.toHandler(); - const body = 'ssl_check=1&token=legacy-fixed-token'; - const awsEvent = { - resource: '/slack/events', - path: '/slack/events', - httpMethod: 'POST', - headers: { - Accept: 'application/json,*/*', - 'Content-Type': 'application/x-www-form-urlencoded', - Host: 'xxx.execute-api.ap-northeast-1.amazonaws.com', - 'User-Agent': 'Slackbot 1.0 (+https://api.slack.com/robots)', - }, - multiValueHeaders: {}, - queryStringParameters: null, - multiValueQueryStringParameters: null, - pathParameters: null, - stageVariables: null, - requestContext: {}, - body, - isBase64Encoded: false, - }; - const response = await handler( - awsEvent, - {}, - (_error, _result) => {}, - ); - assert.equal(response.statusCode, 200); - }); - - const urlVerificationBody = JSON.stringify({ - token: 'Jhj5dZrVaK7ZwHHjRyZWjbDl', - challenge: '3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P', - type: 'url_verification', - }); - - it('should accept url_verification requests', async (): Promise => { - const timestamp = Math.floor(Date.now() / 1000); - const awsReceiver = new AwsLambdaReceiver({ - signingSecret: 'my-secret', - logger: noopLogger, - }); - const handler = awsReceiver.toHandler(); - const signature = crypto.createHmac('sha256', 'my-secret').update(`v0:${timestamp}:${urlVerificationBody}`).digest('hex'); - const awsEvent = { - resource: '/slack/events', - path: '/slack/events', - httpMethod: 'POST', - 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}`, - }, - multiValueHeaders: {}, - queryStringParameters: null, - multiValueQueryStringParameters: null, - pathParameters: null, - stageVariables: null, - requestContext: {}, - body: urlVerificationBody, - isBase64Encoded: false, - }; - const response = await handler( - awsEvent, - {}, - (_error, _result) => {}, - ); - assert.equal(response.statusCode, 200); - }); - - it('should detect invalid signature', async (): Promise => { - const spy = sinon.spy(); - const awsReceiver = new AwsLambdaReceiver({ - signingSecret: 'my-secret', - logger: noopLogger, - invalidRequestSignatureHandler: spy, - }); - const handler = awsReceiver.toHandler(); - const timestamp = Math.floor(Date.now() / 1000); - const signature = crypto.createHmac('sha256', 'my-secret').update(`v0:${timestamp}:${urlVerificationBody}`).digest('hex'); - const awsEvent = { - resource: '/slack/events', - path: '/slack/events', - httpMethod: 'POST', - 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 - }, - multiValueHeaders: {}, - queryStringParameters: null, - multiValueQueryStringParameters: null, - pathParameters: null, - stageVariables: null, - requestContext: {}, - body: urlVerificationBody, - isBase64Encoded: false, - }; - const response = await handler( - awsEvent, - {}, - (_error, _result) => {}, - ); - assert.equal(response.statusCode, 401); - assert(spy.calledOnce); - }); - - it('should detect too old request timestamp', async (): Promise => { - const awsReceiver = new AwsLambdaReceiver({ - signingSecret: 'my-secret', - logger: noopLogger, - }); - const handler = awsReceiver.toHandler(); - const timestamp = Math.floor(Date.now() / 1000) - 600; // 10 minutes ago - const signature = crypto.createHmac('sha256', 'my-secret').update(`v0:${timestamp}:${urlVerificationBody}`).digest('hex'); - const awsEvent = { - resource: '/slack/events', - path: '/slack/events', - httpMethod: 'POST', - 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}`, - }, - multiValueHeaders: {}, - queryStringParameters: null, - multiValueQueryStringParameters: null, - pathParameters: null, - stageVariables: null, - requestContext: {}, - body: urlVerificationBody, - isBase64Encoded: false, - }; - const response = await handler( - awsEvent, - {}, - (_error, _result) => {}, - ); - assert.equal(response.statusCode, 401); - }); - - it('does not perform signature verification if signature verification flag is set to false', async () => { - const awsReceiver = new AwsLambdaReceiver({ - signingSecret: '', - signatureVerification: false, - logger: noopLogger, - }); - const handler = awsReceiver.toHandler(); - const awsEvent = { - resource: '/slack/events', - path: '/slack/events', - httpMethod: 'POST', - 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': 'far back dude', - 'X-Slack-Signature': 'very much invalid', - }, - multiValueHeaders: {}, - queryStringParameters: null, - multiValueQueryStringParameters: null, - pathParameters: null, - stageVariables: null, - requestContext: {}, - body: urlVerificationBody, - isBase64Encoded: false, - }; - const response = await handler( - awsEvent, - {}, - (_error, _result) => {}, - ); - assert.equal(response.statusCode, 200); - }); -}); - -// Composable overrides -function withNoopWebClient(): Override { - return { - '@slack/web-api': { - WebClient: class { - public token?: string; - - public constructor(token?: string, _options?: WebClientOptions) { - this.token = token; - } - - public auth = { - test: sinon.fake.resolves({ - enterprise_id: 'E111', - team_id: 'T111', - bot_id: 'B111', - user_id: 'W111', - }), - }; - }, - }, - }; -} - -function withNoopAppMetadata(): Override { - return { - '@slack/web-api': { - addAppMetadata: sinon.fake(), - }, - }; -} - -// Loading the system under test using overrides -async function importApp( - overrides: Override = mergeOverrides(withNoopAppMetadata(), withNoopWebClient()), -): Promise { - return (await rewiremock.module(() => import('../App'), overrides)).default; -} diff --git a/src/receivers/AwsLambdaReceiver.ts b/src/receivers/AwsLambdaReceiver.ts index 6b368aef0..c9780214b 100644 --- a/src/receivers/AwsLambdaReceiver.ts +++ b/src/receivers/AwsLambdaReceiver.ts @@ -1,12 +1,11 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import querystring from 'querystring'; -import crypto from 'crypto'; -import { Logger, ConsoleLogger, LogLevel } from '@slack/logger'; +import crypto from 'node:crypto'; +import querystring from 'node:querystring'; +import { ConsoleLogger, LogLevel, type Logger } from '@slack/logger'; import tsscmp from 'tsscmp'; -import App from '../App'; -import { Receiver, ReceiverEvent } from '../types/receiver'; +import type App from '../App'; import { ReceiverMultipleAckError } from '../errors'; -import { StringIndexed } from '../types/utilities'; +import type { Receiver, ReceiverEvent } from '../types/receiver'; +import type { StringIndexed } from '../types/utilities'; export type AwsEvent = AwsEventV1 | AwsEventV2; type AwsEventStringParameters = Record; @@ -18,6 +17,7 @@ export interface AwsEventV1 { isBase64Encoded: boolean; pathParameters: AwsEventStringParameters | null; queryStringParameters: AwsEventStringParameters | null; + // biome-ignore lint/suspicious/noExplicitAny: request contexts can be anything requestContext: any; stageVariables: AwsEventStringParameters | null; // v1-only properties: @@ -34,6 +34,7 @@ export interface AwsEventV2 { isBase64Encoded: boolean; pathParameters?: AwsEventStringParameters; queryStringParameters?: AwsEventStringParameters; + // biome-ignore lint/suspicious/noExplicitAny: request contexts can be anything requestContext: any; stageVariables?: AwsEventStringParameters; // v2-only properties: @@ -44,6 +45,7 @@ export interface AwsEventV2 { version: string; } +// biome-ignore lint/suspicious/noExplicitAny: userland function results can be anything export type AwsCallback = (error?: Error | string | null, result?: any) => void; export interface ReceiverInvalidRequestSignatureHandlerArgs { @@ -66,6 +68,7 @@ export interface AwsResponse { isBase64Encoded?: boolean; } +// biome-ignore lint/suspicious/noExplicitAny: request context can be anything export type AwsHandler = (event: AwsEvent, context: any, callback: AwsCallback) => Promise; export interface AwsLambdaReceiverOptions { @@ -138,7 +141,8 @@ export default class AwsLambdaReceiver implements Receiver { // Initialize instance variables, substituting defaults for each value this.signingSecret = signingSecret; this.signatureVerification = signatureVerification; - this.logger = logger ?? + this.logger = + logger ?? (() => { const defaultLogger = new ConsoleLogger(); defaultLogger.setLevel(logLevel); @@ -156,6 +160,7 @@ export default class AwsLambdaReceiver implements Receiver { 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 { @@ -167,7 +172,7 @@ export default class AwsLambdaReceiver implements Receiver { }); } - // eslint-disable-next-line class-methods-use-this + // 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(); @@ -175,11 +180,13 @@ export default class AwsLambdaReceiver implements Receiver { } public toHandler(): AwsHandler { + // biome-ignore lint/suspicious/noExplicitAny: request context can be anything return async (awsEvent: AwsEvent, _awsContext: any, _awsCallback: AwsCallback): Promise => { this.logger.debug(`AWS event: ${JSON.stringify(awsEvent, null, 2)}`); const rawBody = this.getRawBody(awsEvent); + // biome-ignore lint/suspicious/noExplicitAny: request bodies can be anything const body: any = this.parseRequestBody( rawBody, this.getHeaderValue(awsEvent.headers, 'Content-Type'), @@ -228,13 +235,14 @@ export default class AwsLambdaReceiver implements Receiver { if (!isAcknowledged) { this.logger.error( 'An incoming event was not acknowledged within 3 seconds. ' + - 'Ensure that the ack() argument is called in a listener.', + 'Ensure that the ack() argument is called in a listener.', ); } }, 3001); // Structure the ReceiverEvent - let storedResponse; + // biome-ignore lint/suspicious/noExplicitAny: request responses can be anything + let storedResponse: any; const event: ReceiverEvent = { body, ack: async (response) => { @@ -283,7 +291,6 @@ export default class AwsLambdaReceiver implements Receiver { }; } - // eslint-disable-next-line class-methods-use-this private getRawBody(awsEvent: AwsEvent): string { if (typeof awsEvent.body === 'undefined' || awsEvent.body == null) { return ''; @@ -294,7 +301,7 @@ export default class AwsLambdaReceiver implements Receiver { return awsEvent.body; } - // eslint-disable-next-line class-methods-use-this + // biome-ignore lint/suspicious/noExplicitAny: request bodies can be anything private parseRequestBody(stringBody: string, contentType: string | undefined, logger: Logger): any { if (contentType === 'application/x-www-form-urlencoded') { const parsedBody = querystring.parse(stringBody); @@ -317,7 +324,6 @@ export default class AwsLambdaReceiver implements Receiver { } } - // eslint-disable-next-line class-methods-use-this private isValidRequestSignature( signingSecret: string, body: string, @@ -345,7 +351,6 @@ export default class AwsLambdaReceiver implements Receiver { return true; } - // eslint-disable-next-line class-methods-use-this private getHeaderValue(headers: AwsEvent['headers'], key: string): string | undefined { const caseInsensitiveKey = Object.keys(headers).find((it) => key.toLowerCase() === it.toLowerCase()); return caseInsensitiveKey !== undefined ? headers[caseInsensitiveKey] : undefined; diff --git a/src/receivers/BufferedIncomingMessage.ts b/src/receivers/BufferedIncomingMessage.ts index a755267f7..0947cdd29 100644 --- a/src/receivers/BufferedIncomingMessage.ts +++ b/src/receivers/BufferedIncomingMessage.ts @@ -1,5 +1,7 @@ -import { IncomingMessage } from 'http'; +import type { IncomingMessage } from 'node:http'; +// TODO: we should see about removing this or using native means of accomplishing what this interface provides (seems like a memory reference to an unbuffered request body) +// the helper methods around this are simplistic and work against TypeScript export interface BufferedIncomingMessage extends IncomingMessage { rawBody: Buffer; } diff --git a/src/receivers/ExpressReceiver.ts b/src/receivers/ExpressReceiver.ts index 8f66adf6b..ef881ff53 100644 --- a/src/receivers/ExpressReceiver.ts +++ b/src/receivers/ExpressReceiver.ts @@ -1,27 +1,38 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import { createServer, Server, ServerOptions } from 'http'; -import type { IncomingMessage, ServerResponse } from 'http'; -import { createServer as createHttpsServer, Server as HTTPSServer, ServerOptions as HTTPSServerOptions } from 'https'; -import { ListenOptions } from 'net'; -import querystring from 'querystring'; -import crypto from 'crypto'; -import express, { Request, Response, Application, RequestHandler, Router, IRouter } from 'express'; +import crypto from 'node:crypto'; +import { type Server, type ServerOptions, createServer } from 'node:http'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { + type Server as HTTPSServer, + type ServerOptions as HTTPSServerOptions, + createServer as createHttpsServer, +} from 'node:https'; +import type { ListenOptions } from 'node:net'; +import querystring from 'node:querystring'; +import { ConsoleLogger, LogLevel, type Logger } from '@slack/logger'; +import { + type CallbackOptions, + type InstallPathOptions, + InstallProvider, + type InstallProviderOptions, + type InstallURLOptions, +} from '@slack/oauth'; +import express, { + type Request, + type Response, + type Application, + type RequestHandler, + Router, + type IRouter, +} from 'express'; import rawBody from 'raw-body'; import tsscmp from 'tsscmp'; -import { Logger, ConsoleLogger, LogLevel } from '@slack/logger'; -import { InstallProvider, CallbackOptions, InstallProviderOptions, InstallURLOptions, InstallPathOptions } from '@slack/oauth'; -import App from '../App'; -import { - ReceiverAuthenticityError, - ReceiverInconsistentStateError, - CodedError, -} from '../errors'; -import { AnyMiddlewareArgs, Receiver, ReceiverEvent } from '../types'; -import { verifyRedirectOpts } from './verify-redirect-opts'; -import { StringIndexed } from '../types/utilities'; -import { HTTPModuleFunctions as httpFunc, ReceiverDispatchErrorHandlerArgs, ReceiverProcessEventErrorHandlerArgs, ReceiverUnhandledRequestHandlerArgs } from './HTTPModuleFunctions'; +import type App from '../App'; +import { type CodedError, ReceiverAuthenticityError, ReceiverInconsistentStateError } from '../errors'; +import type { AnyMiddlewareArgs, Receiver, ReceiverEvent } from '../types'; +import type { StringIndexed } from '../types/utilities'; +import * as httpFunc from './HTTPModuleFunctions'; import { HTTPResponseAck } from './HTTPResponseAck'; +import { verifyRedirectOpts } from './verify-redirect-opts'; // Option keys for tls.createServer() and tls.createSecureContext(), exclusive of those for http.createServer() const httpsOptionKeys = [ @@ -57,10 +68,11 @@ const httpsOptionKeys = [ 'sessionIdContext', ]; -const missingServerErrorDescription = 'The receiver cannot be started because private state was mutated. Please report this to the maintainers.'; +const missingServerErrorDescription = + 'The receiver cannot be started because private state was mutated. Please report this to the maintainers.'; export const respondToSslCheck: RequestHandler = (req, res, next) => { - if (req.body && req.body.ssl_check) { + if (req.body?.ssl_check) { res.send(); return; } @@ -68,7 +80,7 @@ export const respondToSslCheck: RequestHandler = (req, res, next) => { }; export const respondToUrlVerification: RequestHandler = (req, res, next) => { - if (req.body && req.body.type && req.body.type === 'url_verification') { + if (req.body?.type && req.body.type === 'url_verification') { res.json({ challenge: req.body.challenge }); return; } @@ -82,10 +94,10 @@ export interface ExpressReceiverOptions { logger?: Logger; logLevel?: LogLevel; endpoints?: - | string - | { - [endpointType: string]: string; - }; + | string + | { + [endpointType: string]: string; + }; signatureVerification?: boolean; processBeforeResponse?: boolean; clientId?: string; @@ -98,12 +110,12 @@ export interface ExpressReceiverOptions { app?: Application; router?: IRouter; customPropertiesExtractor?: (request: Request) => StringIndexed; - dispatchErrorHandler?: (args: ReceiverDispatchErrorHandlerArgs) => Promise; - processEventErrorHandler?: (args: ReceiverProcessEventErrorHandlerArgs) => Promise; + dispatchErrorHandler?: (args: httpFunc.ReceiverDispatchErrorHandlerArgs) => Promise; + processEventErrorHandler?: (args: httpFunc.ReceiverProcessEventErrorHandlerArgs) => Promise; // NOTE: for the compatibility with HTTPResponseAck, this handler is not async // If we receive requests to provide async version of this handler, // we can add a different name function for it. - unhandledRequestHandler?: (args: ReceiverUnhandledRequestHandlerArgs) => void; + unhandledRequestHandler?: (args: httpFunc.ReceiverUnhandledRequestHandlerArgs) => void; unhandledRequestTimeoutMillis?: number; } @@ -152,11 +164,11 @@ export default class ExpressReceiver implements Receiver { private customPropertiesExtractor: (request: Request) => StringIndexed; - private dispatchErrorHandler: (args: ReceiverDispatchErrorHandlerArgs) => Promise; + private dispatchErrorHandler: (args: httpFunc.ReceiverDispatchErrorHandlerArgs) => Promise; - private processEventErrorHandler: (args: ReceiverProcessEventErrorHandlerArgs) => Promise; + private processEventErrorHandler: (args: httpFunc.ReceiverProcessEventErrorHandlerArgs) => Promise; - private unhandledRequestHandler: (args: ReceiverUnhandledRequestHandlerArgs) => void; + private unhandledRequestHandler: (args: httpFunc.ReceiverUnhandledRequestHandlerArgs) => void; private unhandledRequestTimeoutMillis: number; @@ -192,9 +204,9 @@ export default class ExpressReceiver implements Receiver { } this.signatureVerification = signatureVerification; - const bodyParser = this.signatureVerification ? - buildVerificationBodyParserMiddleware(this.logger, signingSecret) : - buildBodyParserMiddleware(this.logger); + const bodyParser = this.signatureVerification + ? buildVerificationBodyParserMiddleware(this.logger, signingSecret) + : buildBodyParserMiddleware(this.logger); const expressMiddleware: RequestHandler[] = [ bodyParser, respondToSslCheck, @@ -205,9 +217,9 @@ export default class ExpressReceiver implements Receiver { const endpointList = typeof endpoints === 'string' ? [endpoints] : Object.values(endpoints); this.router = router !== undefined ? router : Router(); - endpointList.forEach((endpoint) => { + for (const endpoint of endpointList) { this.router.post(endpoint, ...expressMiddleware); - }); + } this.customPropertiesExtractor = customPropertiesExtractor; this.dispatchErrorHandler = dispatchErrorHandler; @@ -254,9 +266,8 @@ export default class ExpressReceiver implements Receiver { // Add OAuth routes to receiver if (this.installer !== undefined) { const { installer } = this; - const redirectUriPath = installerOptions.redirectUriPath === undefined ? - '/slack/oauth_redirect' : - installerOptions.redirectUriPath; + const redirectUriPath = + installerOptions.redirectUriPath === undefined ? '/slack/oauth_redirect' : installerOptions.redirectUriPath; const { callbackOptions, stateVerification } = installerOptions; this.router.use(redirectUriPath, async (req, res) => { try { @@ -355,8 +366,8 @@ export default class ExpressReceiver implements Receiver { serverOptions: ServerOptions | HTTPSServerOptions = {}, ): Promise { let createServerFn: - typeof createServer | - typeof createHttpsServer = createServer; + | typeof createServer + | typeof createHttpsServer = createServer; // Look for HTTPS-specific serverOptions to determine which factory function to use if (Object.keys(serverOptions).filter((k) => httpsOptionKeys.includes(k)).length > 0) { @@ -412,7 +423,9 @@ export default class ExpressReceiver implements Receiver { // generic types public stop(): Promise { if (this.server === undefined) { - return Promise.reject(new ReceiverInconsistentStateError('The receiver cannot be stopped because it was not started.')); + return Promise.reject( + new ReceiverInconsistentStateError('The receiver cannot be stopped because it was not started.'), + ); } return new Promise((resolve, reject) => { this.server?.close((error) => { @@ -444,20 +457,11 @@ function buildVerificationBodyParserMiddleware( signingSecret: string | (() => PromiseLike), ): RequestHandler { return async (req, res, next): Promise => { - let stringBody: string; - // On some environments like GCP (Google Cloud Platform), - // req.body can be pre-parsed and be passed as req.rawBody here - const preparsedRawBody: any = (req as any).rawBody; - if (preparsedRawBody !== undefined) { - stringBody = preparsedRawBody.toString(); - } else { - stringBody = (await rawBody(req)).toString(); - } - // *** Parsing body *** // As the verification passed, parse the body as an object and assign it to req.body - // Following middlewares can expect `req.body` is already a parsed one. + const stringBody = await parseExpressRequestRawBody(req); + // Following middlewares can expect `req.body` is already parsed. try { // This handler parses `req.body` or `req.rawBody`(on Google Could Platform) // and overwrites `req.body` with the parsed JS object. @@ -484,10 +488,10 @@ function buildVerificationBodyParserMiddleware( }; } +// biome-ignore lint/suspicious/noExplicitAny: errors can be anything function logError(logger: Logger, message: string, error: any): void { - const logMessage = 'code' in error ? - `${message} (code: ${error.code}, message: ${error.message})` : - `${message} (error: ${error})`; + const logMessage = + 'code' in error ? `${message} (code: ${error.code}, message: ${error.message})` : `${message} (error: ${error})`; logger.warn(logMessage); } @@ -502,8 +506,7 @@ function verifyRequestSignature( } const ts = Number(requestTimestamp); - // eslint-disable-next-line no-restricted-globals - if (isNaN(ts)) { + if (Number.isNaN(ts)) { throw new ReceiverAuthenticityError('Slack request signing verification failed. Timestamp is invalid.'); } @@ -532,6 +535,7 @@ function verifyRequestSignature( export function verifySignatureAndParseBody( signingSecret: string, body: string, + // biome-ignore lint/suspicious/noExplicitAny: TODO: headers should only be of a certain type, but some other functions here expect a more complicated type. revisit to type properly later. headers: Record, ): AnyMiddlewareArgs['body'] { // *** Request verification *** @@ -547,16 +551,8 @@ export function verifySignatureAndParseBody( } export function buildBodyParserMiddleware(logger: Logger): RequestHandler { - return async (req, res, next): Promise => { - let stringBody: string; - // On some environments like GCP (Google Cloud Platform), - // req.body can be pre-parsed and be passed as req.rawBody here - const preparsedRawBody: any = (req as any).rawBody; - if (preparsedRawBody !== undefined) { - stringBody = preparsedRawBody.toString(); - } else { - stringBody = (await rawBody(req)).toString(); - } + return async (req, res, next) => { + const stringBody = await parseExpressRequestRawBody(req); try { const { 'content-type': contentType } = req.headers; req.body = parseRequestBody(stringBody, contentType); @@ -571,9 +567,9 @@ export function buildBodyParserMiddleware(logger: Logger): RequestHandler { }; } +// biome-ignore lint/suspicious/noExplicitAny: request bodies can be anything function parseRequestBody(stringBody: string, contentType: string | undefined): any { if (contentType === 'application/x-www-form-urlencoded') { - // TODO: querystring is deprecated since Node.js v17 const parsedBody = querystring.parse(stringBody); if (typeof parsedBody.payload === 'string') { @@ -585,3 +581,12 @@ function parseRequestBody(stringBody: string, contentType: string | undefined): return JSON.parse(stringBody); } + +// On some environments like GCP (Google Cloud Platform), +// req.body can be pre-parsed and be passed as req.rawBody +async function parseExpressRequestRawBody(req: Parameters[0]): Promise { + if ('rawBody' in req && req.rawBody) { + return Promise.resolve(req.rawBody.toString()); + } + return (await rawBody(req)).toString(); +} diff --git a/src/receivers/HTTPModuleFunctions.ts b/src/receivers/HTTPModuleFunctions.ts index 6947a9bf5..e29e5f7e4 100644 --- a/src/receivers/HTTPModuleFunctions.ts +++ b/src/receivers/HTTPModuleFunctions.ts @@ -1,238 +1,228 @@ -/* eslint-disable import/prefer-default-export */ -import { parse as qsParse } from 'querystring'; -import type { IncomingMessage, ServerResponse } from 'http'; -import rawBody from 'raw-body'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { parse as qsParse } from 'node:querystring'; import type { Logger } from '@slack/logger'; -import { CodedError, ErrorCode } from '../errors'; -import { BufferedIncomingMessage } from './BufferedIncomingMessage'; +import rawBody from 'raw-body'; +import { type CodedError, ErrorCode } from '../errors'; +import type { BufferedIncomingMessage } from './BufferedIncomingMessage'; import { verifySlackRequest } from './verify-request'; const verifyErrorPrefix = 'Failed to verify authenticity'; -export class HTTPModuleFunctions { - // ------------------------------------------ - // Request header extraction - // ------------------------------------------ - - public static extractRetryNumFromHTTPRequest(req: IncomingMessage): number | undefined { - let retryNum; - const retryNumHeaderValue = req.headers['x-slack-retry-num']; - if (retryNumHeaderValue === undefined) { - retryNum = undefined; - } else if (typeof retryNumHeaderValue === 'string') { - retryNum = parseInt(retryNumHeaderValue, 10); - } else if (Array.isArray(retryNumHeaderValue) && retryNumHeaderValue.length > 0) { - retryNum = parseInt(retryNumHeaderValue[0], 10); - } - return retryNum; +export const extractRetryNumFromHTTPRequest = (req: IncomingMessage): number | undefined => { + let retryNum: number | undefined; + const retryNumHeaderValue = req.headers['x-slack-retry-num']; + if (retryNumHeaderValue === undefined) { + retryNum = undefined; + } else if (typeof retryNumHeaderValue === 'string') { + retryNum = Number.parseInt(retryNumHeaderValue, 10); + } else if (Array.isArray(retryNumHeaderValue) && retryNumHeaderValue.length > 0) { + retryNum = Number.parseInt(retryNumHeaderValue[0], 10); } - - public static extractRetryReasonFromHTTPRequest(req: IncomingMessage): string | undefined { - let retryReason; - const retryReasonHeaderValue = req.headers['x-slack-retry-reason']; - if (retryReasonHeaderValue === undefined) { - retryReason = undefined; - } else if (typeof retryReasonHeaderValue === 'string') { - retryReason = retryReasonHeaderValue; - } else if (Array.isArray(retryReasonHeaderValue) && retryReasonHeaderValue.length > 0) { - // eslint-disable-next-line prefer-destructuring - retryReason = retryReasonHeaderValue[0]; - } - return retryReason; + return retryNum; +}; + +export const extractRetryReasonFromHTTPRequest = (req: IncomingMessage): string | undefined => { + let retryReason: string | undefined; + const retryReasonHeaderValue = req.headers['x-slack-retry-reason']; + if (retryReasonHeaderValue === undefined) { + retryReason = undefined; + } else if (typeof retryReasonHeaderValue === 'string') { + retryReason = retryReasonHeaderValue; + } else if (Array.isArray(retryReasonHeaderValue) && retryReasonHeaderValue.length > 0) { + retryReason = retryReasonHeaderValue[0]; } - - // ------------------------------------------ - // HTTP request parsing and verification - // ------------------------------------------ - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public static parseHTTPRequestBody(req: BufferedIncomingMessage): any { - const bodyAsString = req.rawBody.toString(); - const contentType = req.headers['content-type']; - if (contentType === 'application/x-www-form-urlencoded') { - const parsedQs = qsParse(bodyAsString); - const { payload } = parsedQs; - if (typeof payload === 'string') { - return JSON.parse(payload); - } - return parsedQs; + return retryReason; +}; + +// ------------------------------------------ +// HTTP request parsing and verification +// ------------------------------------------ +// biome-ignore lint/suspicious/noExplicitAny: HTTP request bodies could be anything +export const parseHTTPRequestBody = (req: BufferedIncomingMessage): any => { + const bodyAsString = req.rawBody.toString(); + const contentType = req.headers['content-type']; + if (contentType === 'application/x-www-form-urlencoded') { + const parsedQs = qsParse(bodyAsString); + const { payload } = parsedQs; + if (typeof payload === 'string') { + return JSON.parse(payload); } - return JSON.parse(bodyAsString); + return parsedQs; } + return JSON.parse(bodyAsString); +}; - public static async parseAndVerifyHTTPRequest( - options: RequestVerificationOptions, - req: IncomingMessage, - _res?: ServerResponse, - ): Promise { - const { signingSecret } = options; - - // Consume the readable stream (or use the previously consumed readable stream) - const bufferedReq = await HTTPModuleFunctions.bufferIncomingMessage(req); - - if (options.enabled !== undefined && !options.enabled) { - // As the validation is disabled, immediately return the buffered request - return bufferedReq; - } - const textBody = bufferedReq.rawBody.toString(); +export const parseAndVerifyHTTPRequest = async ( + options: RequestVerificationOptions, + req: IncomingMessage, + _res?: ServerResponse, +): Promise => { + const { signingSecret } = options; - const contentType = req.headers['content-type']; - if (contentType === 'application/x-www-form-urlencoded') { - // `ssl_check=1` requests do not require x-slack-signature verification - const parsedQs = qsParse(textBody); - if (parsedQs && parsedQs.ssl_check) { - return bufferedReq; - } - } + // Consume the readable stream (or use the previously consumed readable stream) + const bufferedReq = await bufferIncomingMessage(req); - // Find the relevant request headers - const signature = HTTPModuleFunctions.getHeader(req, 'x-slack-signature'); - const requestTimestampSec = Number(HTTPModuleFunctions.getHeader(req, 'x-slack-request-timestamp')); - verifySlackRequest({ - signingSecret, - body: textBody, - headers: { - 'x-slack-signature': signature, - 'x-slack-request-timestamp': requestTimestampSec, - }, - logger: options.logger, - }); - - // Checks have passed! Return the value that has a side effect (the buffered request) + if (options.enabled !== undefined && !options.enabled) { + // As the validation is disabled, immediately return the buffered request return bufferedReq; } + const textBody = bufferedReq.rawBody.toString(); - public static isBufferedIncomingMessage(req: IncomingMessage): req is BufferedIncomingMessage { - return Buffer.isBuffer((req as BufferedIncomingMessage).rawBody); - } - - public static getHeader(req: IncomingMessage, header: string): string { - const value = req.headers[header]; - if (value === undefined || Array.isArray(value)) { - throw new Error(`${verifyErrorPrefix}: header ${header} did not have the expected type (received ${typeof value}, expected string)`); - } - return value; - } - - public static async bufferIncomingMessage(req: IncomingMessage): Promise { - if (HTTPModuleFunctions.isBufferedIncomingMessage(req)) { - return req; + const contentType = req.headers['content-type']; + if (contentType === 'application/x-www-form-urlencoded') { + // `ssl_check=1` requests do not require x-slack-signature verification + const parsedQs = qsParse(textBody); + if (parsedQs?.ssl_check) { + return bufferedReq; } - const bufferedRequest = req as BufferedIncomingMessage; - bufferedRequest.rawBody = await rawBody(req); - return bufferedRequest; } - // ------------------------------------------ - // HTTP response builder methods - // ------------------------------------------ - - public static buildNoBodyResponse(res: ServerResponse, status: number): void { - res.writeHead(status); - res.end(); + // Find the relevant request headers + const signature = getHeader(req, 'x-slack-signature'); + const requestTimestampSec = Number(getHeader(req, 'x-slack-request-timestamp')); + verifySlackRequest({ + signingSecret, + body: textBody, + headers: { + 'x-slack-signature': signature, + 'x-slack-request-timestamp': requestTimestampSec, + }, + logger: options.logger, + }); + + // Checks have passed! Return the value that has a side effect (the buffered request) + return bufferedReq; +}; + +export const isBufferedIncomingMessage = (req: IncomingMessage): req is BufferedIncomingMessage => { + // TODO: why are we casting the argument if we're using this as a type guard? + return Buffer.isBuffer((req as BufferedIncomingMessage).rawBody); +}; + +export const getHeader = (req: IncomingMessage, header: string): string => { + const value = req.headers[header]; + if (value === undefined || Array.isArray(value)) { + throw new Error( + `${verifyErrorPrefix}: header ${header} did not have the expected type (received ${typeof value}, expected string)`, + ); } + return value; +}; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public static buildUrlVerificationResponse(res: ServerResponse, body: any): void { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end(JSON.stringify({ challenge: body.challenge })); +export const bufferIncomingMessage = async (req: IncomingMessage): Promise => { + if (isBufferedIncomingMessage(req)) { + return req; } - - public static buildSSLCheckResponse(res: ServerResponse): void { + const bufferedRequest = req as BufferedIncomingMessage; + bufferedRequest.rawBody = await rawBody(req); + return bufferedRequest; +}; + +// ------------------------------------------ +// HTTP response builder methods +// ------------------------------------------ + +export const buildNoBodyResponse = (res: ServerResponse, status: number): void => { + res.writeHead(status); + res.end(); +}; + +// biome-ignore lint/suspicious/noExplicitAny: HTTP request bodies could be anything +export const buildUrlVerificationResponse = (res: ServerResponse, body: any): void => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ challenge: body.challenge })); +}; + +export const buildSSLCheckResponse = (res: ServerResponse): void => { + res.writeHead(200); + res.end(); +}; + +// biome-ignore lint/suspicious/noExplicitAny: HTTP request bodies could be anything +export const buildContentResponse = (res: ServerResponse, body: any): void => { + if (!body) { res.writeHead(200); res.end(); + } else if (typeof body === 'string') { + res.writeHead(200); + res.end(body); + } else { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public static buildContentResponse(res: ServerResponse, body: string | any | undefined): void { - if (!body) { - res.writeHead(200); - res.end(); - } else if (typeof body === 'string') { - res.writeHead(200); - res.end(body); - } else { - res.writeHead(200, { 'content-type': 'application/json' }); - res.end(JSON.stringify(body)); - } - } - - // ------------------------------------------ - // Error handlers for event processing - // ------------------------------------------ - - // The default dispatchErrorHandler implementation: - // Developers can customize this behavior by passing dispatchErrorHandler to the constructor - // Note that it was not possible to make this function async due to the limitation of http module - public static defaultDispatchErrorHandler(args: ReceiverDispatchErrorHandlerArgs): void { - const { error, logger, request, response } = args; - if ('code' in error) { - if (error.code === ErrorCode.HTTPReceiverDeferredRequestError) { - logger.info(`Unhandled HTTP request (${request.method}) made to ${request.url}`); - response.writeHead(404); - response.end(); - return; - } +}; + +// ------------------------------------------ +// Error handlers for event processing +// ------------------------------------------ + +// The default dispatchErrorHandler implementation: +// Developers can customize this behavior by passing dispatchErrorHandler to the constructor +// Note that it was not possible to make this function async due to the limitation of http module +export const defaultDispatchErrorHandler = (args: ReceiverDispatchErrorHandlerArgs): void => { + const { error, logger, request, response } = args; + if ('code' in error) { + if (error.code === ErrorCode.HTTPReceiverDeferredRequestError) { + logger.info(`Unhandled HTTP request (${request.method}) made to ${request.url}`); + response.writeHead(404); + response.end(); + return; } - logger.error(`An unexpected error occurred during a request (${request.method}) made to ${request.url}`); - logger.debug(`Error details: ${error}`); - response.writeHead(500); - response.end(); } - - public static async defaultAsyncDispatchErrorHandler(args: ReceiverDispatchErrorHandlerArgs): Promise { - return HTTPModuleFunctions.defaultDispatchErrorHandler(args); + logger.error(`An unexpected error occurred during a request (${request.method}) made to ${request.url}`); + logger.debug(`Error details: ${error}`); + response.writeHead(500); + response.end(); +}; + +export const defaultAsyncDispatchErrorHandler = async (args: ReceiverDispatchErrorHandlerArgs): Promise => { + return defaultDispatchErrorHandler(args); +}; + +// The default processEventErrorHandler implementation: +// Developers can customize this behavior by passing processEventErrorHandler to the constructor +export const defaultProcessEventErrorHandler = async (args: ReceiverProcessEventErrorHandlerArgs): Promise => { + const { error, response, logger, storedResponse } = args; + + // Check if the response headers have already been sent + if (response.headersSent) { + logger.error('An unhandled error occurred after ack() called in a listener'); + logger.debug(`Error details: ${error}, storedResponse: ${storedResponse}`); + return false; } - // The default processEventErrorHandler implementation: - // Developers can customize this behavior by passing processEventErrorHandler to the constructor - public static async defaultProcessEventErrorHandler( - args: ReceiverProcessEventErrorHandlerArgs, - ): Promise { - const { error, response, logger, storedResponse } = args; - - // Check if the response headers have already been sent - if (response.headersSent) { - logger.error('An unhandled error occurred after ack() called in a listener'); - logger.debug(`Error details: ${error}, storedResponse: ${storedResponse}`); - return false; - } - - if ('code' in error) { - // CodedError has code: string - const errorCode = (error as CodedError).code; - if (errorCode === ErrorCode.AuthorizationError) { + if ('code' in error) { + if (error.code === ErrorCode.AuthorizationError) { // authorize function threw an exception, which means there is no valid installation data - response.writeHead(401); - response.end(); - return true; - } + response.writeHead(401); + response.end(); + return true; } - logger.error('An unhandled error occurred while Bolt processed an event'); - logger.debug(`Error details: ${error}, storedResponse: ${storedResponse}`); - response.writeHead(500); - response.end(); - return false; } - - // The default unhandledRequestHandler implementation: - // Developers can customize this behavior by passing unhandledRequestHandler to the constructor - // Note that this method cannot be an async function to align with the implementation using setTimeout - public static defaultUnhandledRequestHandler(args: ReceiverUnhandledRequestHandlerArgs): void { - const { logger, response } = args; - logger.error( - 'An incoming event was not acknowledged within 3 seconds. ' + + logger.error('An unhandled error occurred while Bolt processed an event'); + logger.debug(`Error details: ${error}, storedResponse: ${storedResponse}`); + response.writeHead(500); + response.end(); + return false; +}; + +// The default unhandledRequestHandler implementation: +// Developers can customize this behavior by passing unhandledRequestHandler to the constructor +// Note that this method cannot be an async function to align with the implementation using setTimeout +export const defaultUnhandledRequestHandler = (args: ReceiverUnhandledRequestHandlerArgs): void => { + const { logger, response } = args; + logger.error( + 'An incoming event was not acknowledged within 3 seconds. ' + 'Ensure that the ack() argument is called in a listener.', - ); + ); - // Check if the response has already been sent - if (!response.headersSent) { - // If not, set the status code and end the response to close the connection - response.writeHead(404); // Not Found - response.end(); - } + // Check if the response has already been sent + if (!response.headersSent) { + // If not, set the status code and end the response to close the connection + response.writeHead(404); // Not Found + response.end(); } -} +}; export interface RequestVerificationOptions { enabled?: boolean; @@ -256,7 +246,7 @@ export interface ReceiverProcessEventErrorHandlerArgs { logger: Logger; request: IncomingMessage; response: ServerResponse; - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: user responses could be anything storedResponse: any; } diff --git a/src/receivers/HTTPReceiver.ts b/src/receivers/HTTPReceiver.ts index d2911d373..c337f1f3d 100644 --- a/src/receivers/HTTPReceiver.ts +++ b/src/receivers/HTTPReceiver.ts @@ -1,31 +1,38 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { createServer, Server, ServerOptions, RequestListener, IncomingMessage, ServerResponse } from 'http'; -import { createServer as createHttpsServer, Server as HTTPSServer, ServerOptions as HTTPSServerOptions } from 'https'; -import { ListenOptions } from 'net'; -import { URL } from 'url'; -import { Logger, ConsoleLogger, LogLevel } from '@slack/logger'; -import { InstallProvider, CallbackOptions, InstallProviderOptions, InstallURLOptions, InstallPathOptions } from '@slack/oauth'; -import { match } from 'path-to-regexp'; -import { ParamsDictionary } from 'express-serve-static-core'; -import { ParamsIncomingMessage } from './ParamsIncomingMessage'; -import { verifyRedirectOpts } from './verify-redirect-opts'; -import App from '../App'; -import { Receiver, ReceiverEvent } from '../types'; import { - ReceiverInconsistentStateError, - HTTPReceiverDeferredRequestError, - CodedError, -} from '../errors'; -import { CustomRoute, buildReceiverRoutes, ReceiverRoutes } from './custom-routes'; -import { StringIndexed } from '../types/utilities'; -import { BufferedIncomingMessage } from './BufferedIncomingMessage'; + type IncomingMessage, + type RequestListener, + type Server, + type ServerOptions, + type ServerResponse, + createServer, +} from 'node:http'; +import { + type Server as HTTPSServer, + type ServerOptions as HTTPSServerOptions, + createServer as createHttpsServer, +} from 'node:https'; +import type { ListenOptions } from 'node:net'; +import { URL } from 'node:url'; +import { ConsoleLogger, LogLevel, type Logger } from '@slack/logger'; import { - HTTPModuleFunctions as httpFunc, - ReceiverDispatchErrorHandlerArgs, - ReceiverProcessEventErrorHandlerArgs, - ReceiverUnhandledRequestHandlerArgs, -} from './HTTPModuleFunctions'; + type CallbackOptions, + type InstallPathOptions, + InstallProvider, + type InstallProviderOptions, + type InstallURLOptions, +} from '@slack/oauth'; +import type { ParamsDictionary } from 'express-serve-static-core'; +import { match } from 'path-to-regexp'; +import type App from '../App'; +import { type CodedError, HTTPReceiverDeferredRequestError, ReceiverInconsistentStateError } from '../errors'; +import type { Receiver, ReceiverEvent } from '../types'; +import type { StringIndexed } from '../types/utilities'; +import type { BufferedIncomingMessage } from './BufferedIncomingMessage'; +import * as httpFunc from './HTTPModuleFunctions'; import { HTTPResponseAck } from './HTTPResponseAck'; +import type { ParamsIncomingMessage } from './ParamsIncomingMessage'; +import { type CustomRoute, type ReceiverRoutes, buildReceiverRoutes } from './custom-routes'; +import { verifyRedirectOpts } from './verify-redirect-opts'; // Option keys for tls.createServer() and tls.createSecureContext(), exclusive of those for http.createServer() const httpsOptionKeys = [ @@ -61,7 +68,8 @@ const httpsOptionKeys = [ 'sessionIdContext', ]; -const missingServerErrorDescription = 'The receiver cannot be started because private state was mutated. Please report this to the maintainers.'; +const missingServerErrorDescription = + 'The receiver cannot be started because private state was mutated. Please report this to the maintainers.'; // All the available arguments in the constructor export interface HTTPReceiverOptions { @@ -82,10 +90,11 @@ export interface HTTPReceiverOptions { installerOptions?: HTTPReceiverInstallerOptions; customPropertiesExtractor?: (request: BufferedIncomingMessage) => StringIndexed; // NOTE: As http.RequestListener is not an async function, this cannot be async - dispatchErrorHandler?: (args: ReceiverDispatchErrorHandlerArgs) => void; - processEventErrorHandler?: (args: ReceiverProcessEventErrorHandlerArgs) => Promise; + dispatchErrorHandler?: (args: httpFunc.ReceiverDispatchErrorHandlerArgs) => void; + processEventErrorHandler?: (args: httpFunc.ReceiverProcessEventErrorHandlerArgs) => Promise; + // TODO: the next note is not true // NOTE: As we use setTimeout under the hood, this cannot be async - unhandledRequestHandler?: (args: ReceiverUnhandledRequestHandlerArgs) => void; + unhandledRequestHandler?: (args: httpFunc.ReceiverUnhandledRequestHandlerArgs) => void; unhandledRequestTimeoutMillis?: number; } @@ -152,11 +161,11 @@ export default class HTTPReceiver implements Receiver { private customPropertiesExtractor: (request: BufferedIncomingMessage) => StringIndexed; - private dispatchErrorHandler: (args: ReceiverDispatchErrorHandlerArgs) => void; + private dispatchErrorHandler: (args: httpFunc.ReceiverDispatchErrorHandlerArgs) => void; - private processEventErrorHandler: (args: ReceiverProcessEventErrorHandlerArgs) => Promise; + private processEventErrorHandler: (args: httpFunc.ReceiverProcessEventErrorHandlerArgs) => Promise; - private unhandledRequestHandler: (args: ReceiverUnhandledRequestHandlerArgs) => void; + private unhandledRequestHandler: (args: httpFunc.ReceiverUnhandledRequestHandlerArgs) => void; private unhandledRequestTimeoutMillis: number; @@ -186,7 +195,8 @@ export default class HTTPReceiver implements Receiver { this.signingSecret = signingSecret; this.processBeforeResponse = processBeforeResponse; this.signatureVerification = signatureVerification; - this.logger = logger ?? + this.logger = + logger ?? (() => { const defaultLogger = new ConsoleLogger(); defaultLogger.setLevel(logLevel); @@ -204,9 +214,9 @@ export default class HTTPReceiver implements Receiver { if ( clientId !== undefined && clientSecret !== undefined && - (this.stateVerification === false || // state store not needed - stateSecret !== undefined || - installerOptions.stateStore !== undefined) // user provided state store + (this.stateVerification === false || // state store not needed + stateSecret !== undefined || + installerOptions.stateStore !== undefined) // user provided state store ) { this.installer = new InstallProvider({ clientId, @@ -265,8 +275,8 @@ export default class HTTPReceiver implements Receiver { serverOptions: ServerOptions | HTTPSServerOptions = {}, ): Promise { let createServerFn: - typeof createServer | - typeof createHttpsServer = createServer; + | typeof createServer + | typeof createHttpsServer = createServer; // Decide which kind of server, HTTP or HTTPS, by searching for any keys in the serverOptions that are exclusive // to HTTPS @@ -325,11 +335,11 @@ export default class HTTPReceiver implements Receiver { let listenOptions: ListenOptions | number = this.port; if (portOrListenOptions !== undefined) { if (typeof portOrListenOptions === 'number') { - listenOptions = portOrListenOptions as number; + listenOptions = portOrListenOptions; } else if (typeof portOrListenOptions === 'string') { - listenOptions = Number(portOrListenOptions) as number; + listenOptions = Number(portOrListenOptions); } else if (typeof portOrListenOptions === 'object') { - listenOptions = portOrListenOptions as ListenOptions; + listenOptions = portOrListenOptions; } } this.server.listen(listenOptions, () => { @@ -346,7 +356,9 @@ export default class HTTPReceiver implements Receiver { // generic types public stop(): Promise { if (this.server === undefined) { - return Promise.reject(new ReceiverInconsistentStateError('The receiver cannot be stopped because it was not started.')); + return Promise.reject( + new ReceiverInconsistentStateError('The receiver cannot be stopped because it was not started.'), + ); } return new Promise((resolve, reject) => { this.server?.close((error) => { @@ -365,8 +377,9 @@ export default class HTTPReceiver implements Receiver { // NOTE: the domain and scheme are irrelevant here. // The URL object is only used to safely obtain the path to match + // TODO: we should add error handling for requests with falsy URLs / methods - could remove the cast here as a result. const { pathname: path } = new URL(req.url as string, 'http://localhost'); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // biome-ignore lint/style/noNonNullAssertion: TODO: check for falsy method to remove the non null assertion const method = req.method!.toUpperCase(); if (this.endpoints.includes(path) && method === 'POST') { @@ -375,8 +388,8 @@ export default class HTTPReceiver implements Receiver { } if (this.installer !== undefined && method === 'GET') { - // When installer is defined then installPath and installRedirectUriPath are always defined - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // TODO: it'd be better to check for falsiness and raise a readable error in any case, which lets us remove the non-null assertion + // biome-ignore lint/style/noNonNullAssertion: When installer is defined then installPath and installRedirectUriPath are always defined const [installPath, installRedirectUriPath] = [this.installPath!, this.installRedirectUriPath!]; // Visiting the installation endpoint @@ -414,9 +427,11 @@ export default class HTTPReceiver implements Receiver { } private handleIncomingEvent(req: IncomingMessage, res: ServerResponse) { - // Wrapped in an async closure for ease of using await + // TODO:: this essentially ejects functionality out of the event loop, doesn't seem like a good idea unless intentional? should review + // NOTE: Wrapped in an async closure for ease of using await (async () => { let bufferedReq: BufferedIncomingMessage; + // biome-ignore lint/suspicious/noExplicitAny: http request bodies could be anything let body: any; // Verify authenticity @@ -430,7 +445,7 @@ export default class HTTPReceiver implements Receiver { req, ); } catch (err) { - const e = err as any; + const e = err as Error; if (this.signatureVerification) { this.logger.warn(`Failed to parse and verify the request data: ${e.message}`); } else { @@ -447,7 +462,7 @@ export default class HTTPReceiver implements Receiver { try { body = httpFunc.parseHTTPRequestBody(bufferedReq); } catch (err) { - const e = err as any; + const e = err as Error; this.logger.warn(`Malformed request body: ${e.message}`); httpFunc.buildNoBodyResponse(res, 400); return; @@ -508,19 +523,17 @@ export default class HTTPReceiver implements Receiver { } private handleInstallPathRequest(req: IncomingMessage, res: ServerResponse) { - // Wrapped in an async closure for ease of using await + // TODO:: this essentially ejects functionality out of the event loop, doesn't seem like a good idea unless intentional? should review + // NOTE: Wrapped in an async closure for ease of using await (async () => { try { - /* eslint-disable @typescript-eslint/no-non-null-assertion */ - await this.installer!.handleInstallPath( - req, - res, - this.installPathOptions, - this.installUrlOptions, - ); + // biome-ignore lint/style/noNonNullAssertion: TODO: should check for falsiness + await this.installer!.handleInstallPath(req, res, this.installPathOptions, this.installUrlOptions); } catch (err) { - const e = err as any; - this.logger.error(`An unhandled error occurred while Bolt processed a request to the installation path (${e.message})`); + const e = err as Error; + this.logger.error( + `An unhandled error occurred while Bolt processed a request to the installation path (${e.message})`, + ); this.logger.debug(`Error details: ${e}`); } })(); @@ -529,13 +542,14 @@ export default class HTTPReceiver implements Receiver { private handleInstallRedirectRequest(req: IncomingMessage, res: ServerResponse) { // This function is only called from within unboundRequestListener after checking that installer is defined, and // when installer is defined then installCallbackOptions is always defined too. - /* eslint-disable @typescript-eslint/no-non-null-assertion */ const [installer, installCallbackOptions, installUrlOptions] = [ + // biome-ignore lint/style/noNonNullAssertion: TODO: should check for falsiness this.installer!, + // biome-ignore lint/style/noNonNullAssertion: TODO: should check for falsiness this.installCallbackOptions!, + // biome-ignore lint/style/noNonNullAssertion: TODO: should check for falsiness this.installUrlOptions!, ]; - /* eslint-enable @typescript-eslint/no-non-null-assertion */ const errorHandler = (err: Error) => { this.logger.error( 'HTTPReceiver encountered an unexpected error while handling the OAuth install redirect. Please report to the maintainers.', diff --git a/src/receivers/HTTPResponseAck.ts b/src/receivers/HTTPResponseAck.ts index afb8b5aca..09fd962ea 100644 --- a/src/receivers/HTTPResponseAck.ts +++ b/src/receivers/HTTPResponseAck.ts @@ -1,20 +1,20 @@ -import { IncomingMessage, ServerResponse } from 'http'; -import { Logger } from '@slack/logger'; -import { AckFn } from '../types'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { Logger } from '@slack/logger'; import { ReceiverMultipleAckError } from '../errors'; -import { HTTPModuleFunctions as httpFunc, ReceiverUnhandledRequestHandlerArgs } from './HTTPModuleFunctions'; +import type { AckFn } from '../types'; +import * as httpFunc from './HTTPModuleFunctions'; export interface AckArgs { logger: Logger; processBeforeResponse: boolean; - unhandledRequestHandler?: (args: ReceiverUnhandledRequestHandlerArgs) => void; + unhandledRequestHandler?: (args: httpFunc.ReceiverUnhandledRequestHandlerArgs) => void; unhandledRequestTimeoutMillis?: number; - httpRequest: IncomingMessage, - httpResponse: ServerResponse, + httpRequest: IncomingMessage; + httpResponse: ServerResponse; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type HTTResponseBody = any | string | undefined; +// biome-ignore lint/suspicious/noExplicitAny: response bodies can be anything +export type HTTResponseBody = any; export class HTTPResponseAck { private logger: Logger; @@ -23,7 +23,7 @@ export class HTTPResponseAck { private processBeforeResponse: boolean; - private unhandledRequestHandler: (args: ReceiverUnhandledRequestHandlerArgs) => void; + private unhandledRequestHandler: (args: httpFunc.ReceiverUnhandledRequestHandlerArgs) => void; private unhandledRequestTimeoutMillis: number; @@ -33,8 +33,8 @@ export class HTTPResponseAck { private noAckTimeoutId?: NodeJS.Timeout; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public storedResponse: any | string | undefined; + // biome-ignore lint/suspicious/noExplicitAny: response bodies can be anything + public storedResponse: any; public constructor(args: AckArgs) { this.logger = args.logger; diff --git a/src/receivers/ParamsIncomingMessage.ts b/src/receivers/ParamsIncomingMessage.ts index 3c6430668..96f7e505e 100644 --- a/src/receivers/ParamsIncomingMessage.ts +++ b/src/receivers/ParamsIncomingMessage.ts @@ -1,5 +1,5 @@ -import { IncomingMessage } from 'http'; -import { ParamsDictionary } from 'express-serve-static-core'; +import type { IncomingMessage } from 'node:http'; +import type { ParamsDictionary } from 'express-serve-static-core'; export interface ParamsIncomingMessage extends IncomingMessage { /** diff --git a/src/receivers/SocketModeFunctions.ts b/src/receivers/SocketModeFunctions.ts index eff9fa92b..ea8fc16fe 100644 --- a/src/receivers/SocketModeFunctions.ts +++ b/src/receivers/SocketModeFunctions.ts @@ -1,32 +1,22 @@ -/* eslint-disable import/prefer-default-export */ import type { Logger } from '@slack/logger'; -import { CodedError, ErrorCode } from '../errors'; -import { ReceiverEvent } from '../types'; +import { type CodedError, ErrorCode, isCodedError } from '../errors'; +import type { ReceiverEvent } from '../types'; -export class SocketModeFunctions { - // ------------------------------------------ - // Error handlers for event processing - // ------------------------------------------ - - // The default processEventErrorHandler implementation: - // Developers can customize this behavior by passing processEventErrorHandler to the constructor - public static async defaultProcessEventErrorHandler( - args: SocketModeReceiverProcessEventErrorHandlerArgs, - ): Promise { - const { error, logger, event } = args; - // TODO: more details like envelop_id, payload type etc. here - // To make them available, we need to enhance underlying SocketModeClient - // to return more properties to 'slack_event' listeners - logger.error(`An unhandled error occurred while Bolt processed (type: ${event.body?.type}, error: ${error})`); - logger.debug(`Error details: ${error}, retry num: ${event.retryNum}, retry reason: ${event.retryReason}`); - const errorCode = (error as CodedError).code; - if (errorCode === ErrorCode.AuthorizationError) { - // The `authorize` function threw an exception, which means there is no valid installation data. - // In this case, we can tell the Slack server-side to stop retries. - return true; - } - return false; +export async function defaultProcessEventErrorHandler( + args: SocketModeReceiverProcessEventErrorHandlerArgs, +): Promise { + const { error, logger, event } = args; + // TODO: more details like envelop_id, payload type etc. here + // To make them available, we need to enhance underlying SocketModeClient + // to return more properties to 'slack_event' listeners + logger.error(`An unhandled error occurred while Bolt processed (type: ${event.body?.type}, error: ${error})`); + logger.debug(`Error details: ${error}, retry num: ${event.retryNum}, retry reason: ${event.retryReason}`); + if (isCodedError(error) && error.code === ErrorCode.AuthorizationError) { + // The `authorize` function threw an exception, which means there is no valid installation data. + // In this case, we can tell the Slack server-side to stop retries. + return true; } + return false; } // The arguments for the processEventErrorHandler, @@ -34,5 +24,5 @@ export class SocketModeFunctions { export interface SocketModeReceiverProcessEventErrorHandlerArgs { error: Error | CodedError; logger: Logger; - event: ReceiverEvent, + event: ReceiverEvent; } diff --git a/src/receivers/SocketModeReceiver.ts b/src/receivers/SocketModeReceiver.ts index 97a39d52f..98efe1e97 100644 --- a/src/receivers/SocketModeReceiver.ts +++ b/src/receivers/SocketModeReceiver.ts @@ -1,23 +1,28 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { URL } from 'url'; -import { createServer, ServerResponse, Server } from 'http'; +import { type Server, type ServerResponse, createServer } from 'node:http'; +import { URL } from 'node:url'; +import { ConsoleLogger, LogLevel, type Logger } from '@slack/logger'; +import { + type CallbackOptions, + type InstallPathOptions, + InstallProvider, + type InstallProviderOptions, + type InstallURLOptions, +} from '@slack/oauth'; import { SocketModeClient } from '@slack/socket-mode'; -import { Logger, ConsoleLogger, LogLevel } from '@slack/logger'; -import { InstallProvider, CallbackOptions, InstallProviderOptions, InstallURLOptions, InstallPathOptions } from '@slack/oauth'; -import { AppsConnectionsOpenResponse } from '@slack/web-api'; +import type { AppsConnectionsOpenResponse } from '@slack/web-api'; +import type { ParamsDictionary } from 'express-serve-static-core'; import { match } from 'path-to-regexp'; -import { ParamsDictionary } from 'express-serve-static-core'; -import { ParamsIncomingMessage } from './ParamsIncomingMessage'; -import App from '../App'; -import { CodedError } from '../errors'; -import { Receiver, ReceiverEvent } from '../types'; -import { StringIndexed } from '../types/utilities'; -import { buildReceiverRoutes, ReceiverRoutes } from './custom-routes'; -import { verifyRedirectOpts } from './verify-redirect-opts'; +import type App from '../App'; +import type { CodedError } from '../errors'; +import type { Receiver, ReceiverEvent } from '../types'; +import type { StringIndexed } from '../types/utilities'; +import type { ParamsIncomingMessage } from './ParamsIncomingMessage'; import { - SocketModeFunctions as socketModeFunc, - SocketModeReceiverProcessEventErrorHandlerArgs, + type SocketModeReceiverProcessEventErrorHandlerArgs, + defaultProcessEventErrorHandler, } from './SocketModeFunctions'; +import { type ReceiverRoutes, buildReceiverRoutes } from './custom-routes'; +import { verifyRedirectOpts } from './verify-redirect-opts'; // TODO: we throw away the key names for endpoints, so maybe we should use this interface. is it better for migrations? // if that's the reason, let's document that with a comment. @@ -33,6 +38,7 @@ export interface SocketModeReceiverOptions { installerOptions?: InstallerOptions; appToken: string; // App Level Token customRoutes?: CustomRoute[]; + // biome-ignore lint/suspicious/noExplicitAny: user-provided custom properties can be anything customPropertiesExtractor?: (args: any) => StringIndexed; processEventErrorHandler?: (args: SocketModeReceiverProcessEventErrorHandlerArgs) => Promise; } @@ -98,7 +104,7 @@ export default class SocketModeReceiver implements Receiver { installerOptions = {}, customRoutes = [], customPropertiesExtractor = (_args) => ({}), - processEventErrorHandler = socketModeFunc.defaultProcessEventErrorHandler, + processEventErrorHandler = defaultProcessEventErrorHandler, }: SocketModeReceiverOptions) { this.client = new SocketModeClient({ appToken, @@ -107,11 +113,13 @@ export default class SocketModeReceiver implements Receiver { clientOptions: installerOptions.clientOptions, }); - this.logger = logger ?? (() => { - const defaultLogger = new ConsoleLogger(); - defaultLogger.setLevel(logLevel); - return defaultLogger; - })(); + this.logger = + logger ?? + (() => { + const defaultLogger = new ConsoleLogger(); + defaultLogger.setLevel(logLevel); + return defaultLogger; + })(); this.routes = buildReceiverRoutes(customRoutes); this.processEventErrorHandler = processEventErrorHandler; @@ -121,9 +129,9 @@ export default class SocketModeReceiver implements Receiver { if ( clientId !== undefined && clientSecret !== undefined && - (installerOptions.stateVerification === false || // state store not needed - stateSecret !== undefined || - installerOptions.stateStore !== undefined) // user provided state store + (installerOptions.stateVerification === false || // state store not needed + stateSecret !== undefined || + installerOptions.stateStore !== undefined) // user provided state store ) { this.installer = new InstallProvider({ clientId, @@ -150,7 +158,7 @@ export default class SocketModeReceiver implements Receiver { const installPath = installerOptions.installPath === undefined ? '/slack/install' : installerOptions.installPath; this.httpServerPort = installerOptions.port === undefined ? 3000 : installerOptions.port; this.httpServer = createServer(async (req, res) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // biome-ignore lint/style/noNonNullAssertion: method should always be defined for an HTTP request right? const method = req.method!.toUpperCase(); // Handle OAuth-related requests @@ -163,8 +171,9 @@ export default class SocketModeReceiver implements Receiver { redirectUri, }; // Installation has been initiated - const redirectUriPath = installerOptions.redirectUriPath === undefined ? '/slack/oauth_redirect' : installerOptions.redirectUriPath; - if (req.url && req.url.startsWith(redirectUriPath)) { + const redirectUriPath = + installerOptions.redirectUriPath === undefined ? '/slack/oauth_redirect' : installerOptions.redirectUriPath; + if (req.url?.startsWith(redirectUriPath)) { const { stateVerification, callbackOptions } = installerOptions; if (stateVerification === false) { // if stateVerification is disabled make install options available to handler @@ -176,7 +185,7 @@ export default class SocketModeReceiver implements Receiver { return; } // Visiting the installation endpoint - if (req.url && req.url.startsWith(installPath)) { + if (req.url?.startsWith(installPath)) { const { installPathOptions } = installerOptions; await this.installer.handleInstallPath(req, res, installPathOptions, installUrlOptions); return; diff --git a/src/receivers/custom-routes.ts b/src/receivers/custom-routes.ts index 1ebaa751e..f5e5830b2 100644 --- a/src/receivers/custom-routes.ts +++ b/src/receivers/custom-routes.ts @@ -1,6 +1,6 @@ -import { ServerResponse } from 'http'; +import type { ServerResponse } from 'node:http'; import { CustomRouteInitializationError } from '../errors'; -import { ParamsIncomingMessage } from './ParamsIncomingMessage'; +import type { ParamsIncomingMessage } from './ParamsIncomingMessage'; export interface CustomRoute { path: string; @@ -19,12 +19,13 @@ export function buildReceiverRoutes(customRoutes: CustomRoute[]): ReceiverRoutes validateCustomRoutes(customRoutes); - customRoutes.forEach((r) => { - const methodObj = Array.isArray(r.method) ? - r.method.reduce((o, key) => ({ ...o, [key.toUpperCase()]: r.handler }), {}) : - { [r.method.toUpperCase()]: r.handler }; + for (const r of customRoutes) { + const methodObj = Array.isArray(r.method) + ? // biome-ignore lint/performance/noAccumulatingSpread: TODO: apparently this is a perf hit? + r.method.reduce((o, key) => ({ ...o, [key.toUpperCase()]: r.handler }), {}) + : { [r.method.toUpperCase()]: r.handler }; routes[r.path] = routes[r.path] ? { ...routes[r.path], ...methodObj } : methodObj; - }); + } return routes; } @@ -34,13 +35,13 @@ function validateCustomRoutes(customRoutes: CustomRoute[]): void { const missingKeys: (keyof CustomRoute)[] = []; // Check for missing required keys - customRoutes.forEach((route) => { - requiredKeys.forEach((key) => { + for (const route of customRoutes) { + for (const key of requiredKeys) { if (route[key] === undefined && !missingKeys.includes(key)) { missingKeys.push(key); } - }); - }); + } + } if (missingKeys.length > 0) { const errorMsg = `One or more routes in customRoutes are missing required keys: ${missingKeys.join(', ')}`; diff --git a/src/receivers/verify-redirect-opts.ts b/src/receivers/verify-redirect-opts.ts index a659b9454..7ca6820bd 100644 --- a/src/receivers/verify-redirect-opts.ts +++ b/src/receivers/verify-redirect-opts.ts @@ -1,29 +1,29 @@ /** * Helper to verify redirect uri and redirect uri path exist and are consistent * when supplied. -*/ + */ import { AppInitializationError } from '../errors'; -import { HTTPReceiverOptions, HTTPReceiverInstallerOptions } from './HTTPReceiver'; +import type { HTTPReceiverInstallerOptions, HTTPReceiverOptions } from './HTTPReceiver'; export interface RedirectOptions { - redirectUri?: HTTPReceiverOptions['redirectUri'], - redirectUriPath?: HTTPReceiverInstallerOptions['redirectUriPath'], + redirectUri?: HTTPReceiverOptions['redirectUri']; + redirectUriPath?: HTTPReceiverInstallerOptions['redirectUriPath']; } export function verifyRedirectOpts({ redirectUri, redirectUriPath }: RedirectOptions): void { // if redirectUri is supplied, redirectUriPath is required - if ((redirectUri && !redirectUriPath)) { + if (redirectUri && !redirectUriPath) { throw new AppInitializationError( ' You have set a redirectUri but not a matching redirectUriPath.' + - ' Please provide this via installerOptions.redirectUriPath' + - ' Note: These should be consistent, e.g. https://example.com/redirect and /redirect', + ' Please provide this via installerOptions.redirectUriPath' + + ' Note: These should be consistent, e.g. https://example.com/redirect and /redirect', ); } // if both redirectUri and redirectUri are supplied, they must be consistent if (redirectUri && redirectUriPath && !redirectUri?.endsWith(redirectUriPath)) { throw new AppInitializationError( 'redirectUri and installerOptions.redirectUriPath should be consistent' + - ' e.g. https://example.com/redirect and /redirect', + ' e.g. https://example.com/redirect and /redirect', ); } } diff --git a/src/receivers/verify-request.ts b/src/receivers/verify-request.ts index c63ad534c..fcb4f8769 100644 --- a/src/receivers/verify-request.ts +++ b/src/receivers/verify-request.ts @@ -1,5 +1,5 @@ -import { createHmac } from 'crypto'; -import { Logger } from '@slack/logger'; +import { createHmac } from 'node:crypto'; +import type { Logger } from '@slack/logger'; import tsscmp from 'tsscmp'; // ------------------------------ @@ -12,8 +12,8 @@ export interface SlackRequestVerificationOptions { signingSecret: string; body: string; headers: { - 'x-slack-signature': string, - 'x-slack-request-timestamp': number, + 'x-slack-signature': string; + 'x-slack-request-timestamp': number; }; nowMilliseconds?: number; logger?: Logger; @@ -41,8 +41,11 @@ export function verifySlackRequest(options: SlackRequestVerificationOptions): vo // Rule 1: Check staleness if (requestTimestampSec < fiveMinutesAgoSec) { - throw new Error(`${verifyErrorPrefix}: x-slack-request-timestamp must differ from system time by no more than ${requestTimestampMaxDeltaMin - } minutes or request is stale`); + throw new Error( + `${verifyErrorPrefix}: x-slack-request-timestamp must differ from system time by no more than ${ + requestTimestampMaxDeltaMin + } minutes or request is stale`, + ); } // Rule 2: Check signature diff --git a/src/test-helpers.ts b/src/test-helpers.ts deleted file mode 100644 index 6d10ce4ff..000000000 --- a/src/test-helpers.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -// eslint-disable-next-line import/no-extraneous-dependencies -import sinon, { SinonSpy } from 'sinon'; -import { Logger } from '@slack/logger'; - -export interface Override { - [packageName: string]: { - [exportName: string]: any; - }; -} - -export function mergeOverrides(...overrides: Override[]): Override { - let currentOverrides: Override = {}; - overrides.forEach((override) => { - currentOverrides = mergeObjProperties(currentOverrides, override); - }); - return currentOverrides; -} - -function mergeObjProperties(first: Override, second: Override): Override { - const merged: Override = {}; - const props = Object.keys(first).concat(Object.keys(second)); - props.forEach((prop) => { - if (second[prop] === undefined && first[prop] !== undefined) { - merged[prop] = first[prop]; - } else if (first[prop] === undefined && second[prop] !== undefined) { - merged[prop] = second[prop]; - } else { - // second always overwrites the first - merged[prop] = { ...first[prop], ...second[prop] }; - } - }); - return merged; -} - -export interface FakeLogger extends Logger { - setLevel: SinonSpy, ReturnType>; - getLevel: SinonSpy, ReturnType>; - setName: SinonSpy, ReturnType>; - debug: SinonSpy, ReturnType>; - info: SinonSpy, ReturnType>; - warn: SinonSpy, ReturnType>; - error: SinonSpy, ReturnType>; -} - -export function createFakeLogger(): FakeLogger { - return { - // 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. - // Property '0' is missing in type 'any[]' but required in type '[LogLevel]'. - // 49 setLevel: sinon.fake() as SinonSpy, ReturnType>, - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - setLevel: sinon.fake() as unknown as SinonSpy, ReturnType>, - getLevel: sinon.fake() as unknown as SinonSpy, ReturnType>, - setName: sinon.fake() as unknown as SinonSpy, ReturnType>, - debug: sinon.fake(), - info: sinon.fake(), - warn: sinon.fake(), - error: sinon.fake(), - }; -} - -export function delay(ms: number = 0): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} diff --git a/src/types/actions/block-action.spec.ts b/src/types/actions/block-action.spec.ts deleted file mode 100644 index 9b05efbdc..000000000 --- a/src/types/actions/block-action.spec.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { assert } from 'chai'; -import { - BlockAction, - MultiStaticSelectAction, - MultiChannelsSelectAction, - MultiUsersSelectAction, - MultiConversationsSelectAction, -} from './block-action'; - -describe('Interactivity payload types', () => { - describe('block-action action types', () => { - it('should be compatible with block_actions payloads', () => { - const payload: BlockAction = { - type: 'block_actions', - user: { - id: 'W111', - username: 'seratch', - team_id: 'T111', - }, - api_app_id: 'A02', - token: 'Shh_its_a_seekrit', - container: { - type: 'message', - text: 'The contents of the original message where the action originated', - }, - trigger_id: '12466734323.1395872398', - team: { - id: 'T111', - domain: 'foo', - enterprise_id: 'E111', - enterprise_name: 'Acme Corp', - }, - enterprise: { - id: 'E111', - name: 'Acme Corp', - }, - is_enterprise_install: false, - response_url: 'https://www.postresponsestome.com/T123567/1509734234', - // as of April 2021, actions have only one element though - actions: [ - { - type: 'multi_conversations_select', - block_id: 'b', - action_id: 'multi_conversations_select-action', - selected_conversations: ['C111', 'C222'], - action_ts: '1618009079.687263', - }, - { - type: 'multi_conversations_select', - block_id: 'b', - action_id: 'multi_conversations_select-action', - selected_conversations: ['C111', 'C222'], - action_ts: '1618009079.687263', - }, - ], - }; - assert.equal(payload.actions.length, 2); - }); - it('should be compatible with multi_users_select payloads', () => { - const payload: MultiUsersSelectAction = { - type: 'multi_users_select', - block_id: 'b', - action_id: 'a', - action_ts: '111', - selected_users: ['W111', 'W222'], - initial_users: ['W111', 'W222'], - }; - assert.equal(payload.selected_users.length, 2); - assert.equal(payload.initial_users?.length, 2); - }); - it('should be compatible with multi_conversations_select payloads', () => { - const payload: MultiConversationsSelectAction = { - type: 'multi_conversations_select', - block_id: 'b', - action_id: 'a', - action_ts: '111', - selected_conversations: ['C111', 'C222'], - initial_conversations: ['C111', 'C222'], - }; - assert.equal(payload.selected_conversations.length, 2); - assert.equal(payload.initial_conversations?.length, 2); - }); - it('should be compatible with multi_channels_select payloads', () => { - const payload: MultiChannelsSelectAction = { - type: 'multi_channels_select', - block_id: 'b', - action_id: 'a', - action_ts: '111', - selected_channels: ['C111', 'C222'], - initial_channels: ['C111', 'C222'], - }; - assert.equal(payload.selected_channels.length, 2); - assert.equal(payload.initial_channels?.length, 2); - }); - }); - - describe('block-action element types', () => { - it('should be compatible with multi_static_select payloads', () => { - const payload: MultiStaticSelectAction = { - type: 'multi_static_select', - block_id: 'b', - action_id: 'a', - action_ts: '111', - selected_options: [ - { - text: { - type: 'plain_text', - text: '*this is plain_text text*', - emoji: true, - }, - value: 'value-0', - }, - { - text: { - type: 'plain_text', - text: '*this is plain_text text*', - emoji: true, - }, - value: 'value-1', - }, - ], - initial_options: [ - { - text: { - type: 'plain_text', - text: '*this is plain_text text*', - emoji: true, - }, - value: 'value-0', - }, - { - text: { - type: 'plain_text', - text: '*this is plain_text text*', - emoji: true, - }, - value: 'value-1', - }, - ], - }; - assert.equal(payload.selected_options.length, 2); - assert.equal(payload.initial_options?.length, 2); - }); - it('should be compatible with multi_users_select payloads', () => { - const payload: MultiUsersSelectAction = { - type: 'multi_users_select', - block_id: 'b', - action_id: 'a', - action_ts: '111', - selected_users: ['W111', 'W222'], - initial_users: ['W111', 'W222'], - }; - assert.equal(payload.selected_users.length, 2); - assert.equal(payload.initial_users?.length, 2); - }); - it('should be compatible with multi_conversations_select payloads', () => { - const payload: MultiConversationsSelectAction = { - type: 'multi_conversations_select', - block_id: 'b', - action_id: 'a', - action_ts: '111', - selected_conversations: ['C111', 'C222'], - initial_conversations: ['C111', 'C222'], - }; - assert.equal(payload.selected_conversations.length, 2); - assert.equal(payload.initial_conversations?.length, 2); - }); - it('should be compatible with multi_channels_select payloads', () => { - const payload: MultiChannelsSelectAction = { - type: 'multi_channels_select', - block_id: 'b', - action_id: 'a', - action_ts: '111', - selected_channels: ['C111', 'C222'], - initial_channels: ['C111', 'C222'], - }; - assert.equal(payload.selected_channels.length, 2); - assert.equal(payload.initial_channels?.length, 2); - }); - }); -}); diff --git a/src/types/actions/block-action.ts b/src/types/actions/block-action.ts index 2ab61780a..6962e02e6 100644 --- a/src/types/actions/block-action.ts +++ b/src/types/actions/block-action.ts @@ -1,6 +1,6 @@ -import { PlainTextElement, Confirmation, Option, RichTextBlock } from '@slack/types'; -import { StringIndexed } from '../utilities'; -import { ViewOutput, ViewStateValue } from '../view'; +import type { Confirmation, Option, PlainTextElement, RichTextBlock } from '@slack/types'; +import type { StringIndexed } from '../utilities'; +import type { ViewOutput, ViewStateValue } from '../view'; /** * All known actions from in Slack's interactive elements @@ -248,12 +248,13 @@ export interface BlockAction { + type?: A['type']; + block_id?: A extends BlockAction ? string | RegExp : never; + action_id?: A extends BlockAction ? string | RegExp : never; + // TODO: callback ID doesn't apply to block actions, so the SlackAction generic above is too wide to apply here. + // biome-ignore lint/suspicious/noExplicitAny: TODO: for better type safety, we may want to revisit this + callback_id?: Extract extends any ? string | RegExp : never; +} + +// TODO: the words (terminology) that follow don't make much sense. What differentiates SlackAction, BlockAction, ElementAction and BasicElementAction? /** * Arguments which listeners and middleware receive to process an action from Slack's Block Kit interactive components, * message actions, dialogs, or legacy interactive messages. @@ -56,12 +66,12 @@ export type SlackActionMiddlewareArgs complete?: FunctionCompleteFn; fail?: FunctionFailFn; inputs?: FunctionInputs; -// TODO: remove workflow step stuff in bolt v5 + // TODO: remove workflow step stuff in bolt v5 } & (Action extends Exclude - // all action types except dialog submission and steps from apps have a channel context - ? { say: SayFn } - : unknown -); + ? // all action types except dialog submission and steps from apps have a channel context + // TODO: not exactly true: a block action could occur from a view. should improve this. + { say: SayFn } + : unknown); /** * Type function which given an action `A` returns a corresponding type for the `ack()` function. The function is used diff --git a/src/types/actions/interactive-message.spec.ts b/src/types/actions/interactive-message.spec.ts deleted file mode 100644 index 469cd4301..000000000 --- a/src/types/actions/interactive-message.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { assert } from 'chai'; -import { InteractiveMessage, ButtonClick } from './interactive-message'; - -describe('Message shortcut payload types', () => { - it('should be compatible with block_actions payloads', () => { - const payload: InteractiveMessage = { - type: 'interactive_message', - actions: [ - { - name: 'foo', - type: 'button', - value: 'bar', - }, - { - name: 'foo', - type: 'button', - value: 'bar', - }, - ], - callback_id: 'id', - enterprise: { - id: 'E111', - name: 'test-org', - }, - team: { - id: 'T111', - domain: 'team-domain', - enterprise_id: 'E111', - enterprise_name: 'test-org', - }, - channel: { - id: 'C111', - name: 'random', - }, - user: { - id: 'W111', - name: 'seratch', - team_id: 'T111', - }, - action_ts: '111.222', - message_ts: '222.333', - attachment_id: 'XXX', - token: 'verificationt-oken', - is_app_unfurl: false, - original_message: {}, - response_url: 'https://hooks.slack.com/xxx', - trigger_id: '1111111', - is_enterprise_install: false, - }; - assert.equal(payload.actions.length, 2); - }); -}); diff --git a/src/types/actions/workflow-step-edit.ts b/src/types/actions/workflow-step-edit.ts index f344c54ef..aa3c37bcd 100644 --- a/src/types/actions/workflow-step-edit.ts +++ b/src/types/actions/workflow-step-edit.ts @@ -29,12 +29,13 @@ export interface WorkflowStepEdit { workflow_step: { workflow_id: string; step_id: string; - inputs: { - [key: string]: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputs: Record< + string, + { + // biome-ignore lint/suspicious/noExplicitAny: input parameters can accept anything value: any; - }; - }; + } + >; outputs: { name: string; type: string; diff --git a/src/types/command/index.ts b/src/types/command/index.ts index 218b51959..9378bb390 100644 --- a/src/types/command/index.ts +++ b/src/types/command/index.ts @@ -1,4 +1,4 @@ -import { StringIndexed, SayFn, RespondFn, RespondArguments, AckFn } from '../utilities'; +import type { AckFn, RespondArguments, RespondFn, SayFn, StringIndexed } from '../utilities'; /** * Arguments which listeners and middleware receive to process a slash command from Slack. diff --git a/src/types/events/index.ts b/src/types/events/index.ts index 17e2c62d6..7b485e5fc 100644 --- a/src/types/events/index.ts +++ b/src/types/events/index.ts @@ -1,5 +1,5 @@ -import { SlackEvent } from '@slack/types'; -import { StringIndexed, SayFn } from '../utilities'; +import type { SlackEvent } from '@slack/types'; +import type { SayFn, StringIndexed } from '../utilities'; /** * Arguments which listeners and middleware receive to process an event from Slack's Events API. @@ -11,16 +11,15 @@ export type SlackEventMiddlewareArgs = { // Add `ack` as undefined for global middleware in TypeScript TODO: but why? spend some time digging into this ack?: undefined; } & (EventType extends 'message' - // If this is a message event, add a `message` property - ? { message: EventFromType } - : unknown -) & (EventFromType extends { channel: string } | { item: { channel: string } } - // If this event contains a channel, add a `say` utility function - ? { say: SayFn } - : unknown -); + ? // If this is a message event, add a `message` property + { message: EventFromType } + : unknown) & + (EventFromType extends { channel: string } | { item: { channel: string } } + ? // If this event contains a channel, add a `say` utility function + { say: SayFn } + : unknown); -interface BaseSlackEvent { +export interface BaseSlackEvent { type: T; } export type EventTypePattern = string | RegExp; @@ -58,9 +57,9 @@ interface Authorization { * When the string matches known event(s) from the `SlackEvent` union, only those types are returned (also as a union). * Otherwise, the `BasicSlackEvent` type is returned. */ -export type EventFromType = KnownEventFromType extends never ? - BaseSlackEvent : - KnownEventFromType; +export type EventFromType = KnownEventFromType extends never + ? BaseSlackEvent + : KnownEventFromType; export type KnownEventFromType = Extract; /** diff --git a/src/types/middleware.ts b/src/types/middleware.ts index ae4e5538c..8f2bec71f 100644 --- a/src/types/middleware.ts +++ b/src/types/middleware.ts @@ -1,12 +1,12 @@ -import { WebClient } from '@slack/web-api'; -import { Logger } from '@slack/logger'; -import { StringIndexed } from './utilities'; -import { FunctionInputs, SlackEventMiddlewareArgs } from './events'; -import { SlackActionMiddlewareArgs } from './actions'; -import { SlackCommandMiddlewareArgs } from './command'; -import { SlackOptionsMiddlewareArgs } from './options'; -import { SlackShortcutMiddlewareArgs } from './shortcuts'; -import { SlackViewMiddlewareArgs } from './view'; +import type { Logger } from '@slack/logger'; +import type { WebClient } from '@slack/web-api'; +import type { SlackActionMiddlewareArgs } from './actions'; +import type { SlackCommandMiddlewareArgs } from './command'; +import type { FunctionInputs, SlackEventMiddlewareArgs } from './events'; +import type { SlackOptionsMiddlewareArgs } from './options'; +import type { SlackShortcutMiddlewareArgs } from './shortcuts'; +import type { StringIndexed } from './utilities'; +import type { SlackViewMiddlewareArgs } from './view'; // TODO: rename this to AnyListenerArgs, and all the constituent types export type AnyMiddlewareArgs = @@ -26,9 +26,9 @@ export interface AllMiddlewareArgs { // NOTE: Args should extend AnyMiddlewareArgs, but because of contravariance for function types, including that as a // constraint would mess up the interface of App#event(), App#message(), etc. -export interface Middleware { - (args: Args & AllMiddlewareArgs): Promise; -} +export type Middleware = ( + args: Args & AllMiddlewareArgs, +) => Promise; /** * Context object, which provides contextual information associated with an incoming requests. @@ -71,7 +71,7 @@ export interface Context extends StringIndexed { /** * Is the app installed at an Enterprise level? */ - isEnterpriseInstall: boolean, + isEnterpriseInstall: boolean; /** * A JIT and function-specific token that, when used to make API calls, diff --git a/src/types/options/index.spec.ts b/src/types/options/index.spec.ts deleted file mode 100644 index affd6d2fb..000000000 --- a/src/types/options/index.spec.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { assert } from 'chai'; -import { BlockSuggestion, DialogSuggestion, InteractiveMessageSuggestion } from './index'; - -describe('External data source options event types', () => { - it('should be compatible with block_suggestion payloads', () => { - const payload: BlockSuggestion = { - type: 'block_suggestion', - user: { - id: 'W111', - name: 'primary-owner', - team_id: 'T111', - }, - container: { type: 'view', view_id: 'V111' }, - api_app_id: 'A111', - token: 'verification_token', - block_id: 'block-id-value', - action_id: 'action-id-value', - value: 'search word', - team: { - id: 'T111', - domain: 'workspace-domain', - enterprise_id: 'E111', - enterprise_name: 'Sandbox Org', - }, - view: { - id: 'V111', - team_id: 'T111', - type: 'modal', - blocks: [ - { - type: 'input', - block_id: '5ar+', - label: { type: 'plain_text', text: 'Label' }, - optional: false, - element: { type: 'plain_text_input', action_id: 'i5IpR' }, - }, - { - type: 'input', - block_id: 'block-id-value', - label: { type: 'plain_text', text: 'Search' }, - optional: false, - element: { - type: 'external_select', - action_id: 'action-id-value', - placeholder: { type: 'plain_text', text: 'Select an item' }, - }, - }, - { - type: 'input', - block_id: 'xxx', - label: { type: 'plain_text', text: 'Search (multi)' }, - optional: false, - element: { - type: 'multi_external_select', - action_id: 'yyy', - placeholder: { type: 'plain_text', text: 'Select an item' }, - }, - }, - ], - private_metadata: '', - callback_id: 'view-id', - state: { values: {} }, - hash: '111.xxx', - title: { type: 'plain_text', text: 'My App' }, - clear_on_close: false, - notify_on_close: false, - close: { type: 'plain_text', text: 'Cancel' }, - submit: { type: 'plain_text', text: 'Submit' }, - root_view_id: 'V111', - previous_view_id: null, - app_id: 'A111', - external_id: '', - app_installed_team_id: 'T111', - bot_id: 'B111', - }, - }; - assert.equal(payload.action_id, 'action-id-value'); - assert.equal(payload.value, 'search word'); - }); - - it('should be compatible with interactive_message payloads', () => { - const payload: InteractiveMessageSuggestion = { - name: 'bugs_list', - value: 'bot', - callback_id: 'select_remote_1234', - type: 'interactive_message', - team: { - id: 'T012AB0A1', - domain: 'pocket-calculator', - }, - channel: { - id: 'C012AB3CD', - name: 'general', - }, - user: { - id: 'U012A1BCJ', - name: 'bugcatcher', - }, - action_ts: '1481670445.010908', - message_ts: '1481670439.000007', - attachment_id: '1', - token: 'verification_token_string', - }; - assert.equal(payload.callback_id, 'select_remote_1234'); - assert.equal(payload.value, 'bot'); - }); - - it('should be compatible with dialog_suggestion payloads', () => { - const payload: DialogSuggestion = { - type: 'dialog_suggestion', - token: 'verification_token', - action_ts: '1596603332.676855', - team: { - id: 'T111', - domain: 'workspace-domain', - enterprise_id: 'E111', - enterprise_name: 'Sandbox Org', - }, - user: { id: 'W111', name: 'primary-owner', team_id: 'T111' }, - channel: { id: 'C111', name: 'test-channel' }, - name: 'types', - value: 'search keyword', - callback_id: 'dialog-callback-id', - state: 'Limo', - }; - assert.equal(payload.callback_id, 'dialog-callback-id'); - assert.equal(payload.value, 'search keyword'); - }); -}); diff --git a/src/types/options/index.ts b/src/types/options/index.ts index 7e3d0ebbb..5522f9a4c 100644 --- a/src/types/options/index.ts +++ b/src/types/options/index.ts @@ -1,6 +1,6 @@ -import { Option, PlainTextElement } from '@slack/types'; -import { StringIndexed, XOR, AckFn } from '../utilities'; -import { ViewOutput } from '../view/index'; +import type { Option, PlainTextElement } from '@slack/types'; +import type { AckFn, StringIndexed, XOR } from '../utilities'; +import type { ViewOutput } from '../view/index'; /** * Arguments which listeners and middleware receive to process an options request from Slack @@ -12,18 +12,29 @@ export interface SlackOptionsMiddlewareArgs; } +export type SlackOptions = BlockSuggestion | InteractiveMessageSuggestion | DialogSuggestion; + +// TODO: more strict typing to allow block/action_id for block_suggestion - not all of these properties apply to all of the members of the SlackOptions union +export interface OptionsConstraints { + type?: A['type']; + block_id?: A extends SlackOptions ? string | RegExp : never; + action_id?: A extends SlackOptions ? string | RegExp : never; + // biome-ignore lint/suspicious/noExplicitAny: TODO: for better type safety, we may want to revisit this + callback_id?: Extract extends any ? string | RegExp : never; +} + +// TODO: why call this 'source'? shouldn't it be Type, since it is just the type value? /** * All sources from which Slack sends options requests. */ -export type OptionsSource = 'interactive_message' | 'dialog_suggestion' | 'block_suggestion'; - -export type SlackOptions = BlockSuggestion | InteractiveMessageSuggestion | DialogSuggestion; +export type OptionsSource = SlackOptions['type']; // TODO: the following three utility typies could be DRYed up w/ the similar KnownEventFromType utility used in events types export interface BasicOptionsPayload { type: Type; value: string; } +// TODO: Is this useful? Events have something similar export type OptionsPayloadFromType = KnownOptionsPayloadFromType extends never ? BasicOptionsPayload : KnownOptionsPayloadFromType; @@ -146,6 +157,7 @@ type OptionsAckFn = Source extends 'block_suggesti ? AckFn>> : AckFn>>; +// TODO: why are the next two interfaces identical? export interface BlockOptions { options: Option[]; } @@ -160,7 +172,7 @@ export interface DialogOptions { } export interface OptionGroups { option_groups: ({ - label: PlainTextElement + label: PlainTextElement; } & Options)[]; } export interface DialogOptionGroups { diff --git a/src/types/receiver.ts b/src/types/receiver.ts index e7e8f0932..3edf509e2 100644 --- a/src/types/receiver.ts +++ b/src/types/receiver.ts @@ -1,7 +1,6 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import App from '../App'; -import { AckFn } from './index'; -import { StringIndexed } from './utilities'; +import type App from '../App'; +import type { AckFn } from './index'; +import type { StringIndexed } from './utilities'; export interface ReceiverEvent { // Parsed HTTP request body / Socket Mode message body @@ -20,12 +19,14 @@ export interface ReceiverEvent { // The function to acknowledge incoming requests // The details of implementation is encapsulated in a receiver - // TODO: Make the argument type more specific + // biome-ignore lint/suspicious/noExplicitAny: TODO: Make the argument type more specific ack: AckFn; } export interface Receiver { init(app: App): void; + // biome-ignore lint/suspicious/noExplicitAny: different receivers may have different types of arguments start(...args: any[]): Promise; + // biome-ignore lint/suspicious/noExplicitAny: different receivers may have different types of arguments stop(...args: any[]): Promise; } diff --git a/src/types/shortcuts/global-shortcut.ts b/src/types/shortcuts/global-shortcut.ts index 93c4fd9f9..101caf91e 100644 --- a/src/types/shortcuts/global-shortcut.ts +++ b/src/types/shortcuts/global-shortcut.ts @@ -3,6 +3,7 @@ * * This describes the entire JSON-encoded body of a request from Slack global shortcuts. */ +// TODO: move this to slack/types export interface GlobalShortcut { type: 'shortcut'; callback_id: string; diff --git a/src/types/shortcuts/index.ts b/src/types/shortcuts/index.ts index d809df972..f609faacb 100644 --- a/src/types/shortcuts/index.ts +++ b/src/types/shortcuts/index.ts @@ -1,6 +1,6 @@ -import { MessageShortcut } from './message-shortcut'; -import { GlobalShortcut } from './global-shortcut'; -import { SayFn, RespondFn, AckFn } from '../utilities'; +import type { AckFn, RespondFn, SayFn } from '../utilities'; +import type { GlobalShortcut } from './global-shortcut'; +import type { MessageShortcut } from './message-shortcut'; // export * from './message-action'; export * from './global-shortcut'; @@ -11,6 +11,11 @@ export * from './message-shortcut'; */ export type SlackShortcut = GlobalShortcut | MessageShortcut; +export interface ShortcutConstraints { + type?: S['type']; + callback_id?: string | RegExp; +} + /** * Arguments which listeners and middleware receive to process a shortcut from Slack. * @@ -22,7 +27,4 @@ export type SlackShortcutMiddlewareArgs; -} & (Shortcut extends MessageShortcut - ? { say: SayFn } - : unknown -); +} & (Shortcut extends MessageShortcut ? { say: SayFn } : unknown); diff --git a/src/types/shortcuts/message-shortcut.ts b/src/types/shortcuts/message-shortcut.ts index e40cf6e7c..484dd6fac 100644 --- a/src/types/shortcuts/message-shortcut.ts +++ b/src/types/shortcuts/message-shortcut.ts @@ -3,6 +3,7 @@ * * This describes the entire JSON-encoded body of a request from Slack message actions. */ +// TODO: move this to slack/types export interface MessageShortcut { type: 'message_action'; callback_id: string; @@ -15,7 +16,7 @@ export interface MessageShortcut { user?: string; // undocumented that this is optional, it won't be there for bot messages ts: string; text?: string; // undocumented that this is optional, but how could it exist on block kit based messages? - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: TODO: should try to type this more specifically for messages, maybe? [key: string]: any; }; user: { diff --git a/src/types/utilities.spec.ts b/src/types/utilities.spec.ts deleted file mode 100644 index 7c334b093..000000000 --- a/src/types/utilities.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { assert } from 'chai'; -import { RespondArguments } from './utilities'; - -describe('RespondArguments', () => { - it('has expected properties', () => { - const args: RespondArguments = { - response_type: 'in_channel', - text: 'Hey!', - // Verifying this parameter compiles - // See https://github.com/slackapi/bolt-python/pull/844 for the context - thread_ts: '111.222', - }; - assert.exists(args); - }); - it('has metadata', () => { - const args: RespondArguments = { - response_type: 'in_channel', - text: 'Hey!', - metadata: { - event_type: 'test-event', - event_payload: { foo: 'bar' }, - }, - }; - assert.exists(args); - }); -}); diff --git a/src/types/utilities.ts b/src/types/utilities.ts index 92024a42c..c70f5e692 100644 --- a/src/types/utilities.ts +++ b/src/types/utilities.ts @@ -1,31 +1,27 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { ChatPostMessageArguments, ChatPostMessageResponse } from '@slack/web-api'; +import type { ChatPostMessageArguments, ChatPostMessageResponse } from '@slack/web-api'; // TODO: breaking change: remove, unnecessary abstraction, just use Record directly /** * Extend this interface to build a type that is treated as an open set of properties, where each key is a string. */ +// biome-ignore lint/suspicious/noExplicitAny: we're being quite explicit here export type StringIndexed = Record; // TODO: unclear if this is helpful or just complicates further /** * Type function which allows either types `T` or `U`, but not both. */ -export type XOR = T | U extends Record - ? (Without & U) | (Without & T) - : T | U; +export type XOR = T | U extends Record ? (Without & U) | (Without & T) : T | U; type Without = { [P in Exclude]?: never }; -/* eslint-disable @typescript-eslint/no-explicit-any */ /** Type predicate for use with `Promise.allSettled` for filtering for resolved results. */ -export const isFulfilled = (p:PromiseSettledResult): p is PromiseFulfilledResult => p.status === 'fulfilled'; +export const isFulfilled = (p: PromiseSettledResult): p is PromiseFulfilledResult => p.status === 'fulfilled'; /** Type predicate for use with `Promise.allSettled` for filtering for rejected results. */ -export const isRejected = (p:PromiseSettledResult): p is PromiseRejectedResult => p.status === 'rejected'; +export const isRejected = (p: PromiseSettledResult): p is PromiseRejectedResult => p.status === 'rejected'; /** Using type parameter T (generic), can distribute the Omit over a union set. */ -type DistributiveOmit = T extends any - ? Omit - : never; +// biome-ignore lint/suspicious/noExplicitAny: any is the opposite of never +type DistributiveOmit = T extends any ? Omit : never; // The say() utility function binds the message to the same channel as the incoming message that triggered the // listener. Therefore, specifying the `channel` argument is not required. @@ -33,12 +29,9 @@ export type SayArguments = DistributiveOmit // TODO: This will be overwritten in the `createSay` factory method in App.ts anyways, so why include it? channel?: string; }; -export interface SayFn { - (message: string | SayArguments): Promise; -} +export type SayFn = (message: string | SayArguments) => Promise; -export type RespondArguments = DistributiveOmit -& { +export type RespondArguments = DistributiveOmit & { /** Response URLs can be used to send ephemeral messages or in-channel messages using this argument */ response_type?: 'in_channel' | 'ephemeral'; replace_original?: boolean; @@ -46,10 +39,7 @@ export type RespondArguments = DistributiveOmit; -} +// biome-ignore lint/suspicious/noExplicitAny: TODO: check if we can type this more strictly than any +export type RespondFn = (message: string | RespondArguments) => Promise; -export interface AckFn { - (response?: Response): Promise; -} +export type AckFn = (response?: Response) => Promise; diff --git a/src/types/view/index.ts b/src/types/view/index.ts index 20b66597a..0ca0ec1c7 100644 --- a/src/types/view/index.ts +++ b/src/types/view/index.ts @@ -1,6 +1,7 @@ -import { Block, KnownBlock, PlainTextElement, RichTextBlock, View } from '@slack/types'; -import { AckFn, RespondFn } from '../utilities'; +import type { Block, KnownBlock, PlainTextElement, RichTextBlock, View } from '@slack/types'; +import type { AckFn, RespondFn } from '../utilities'; +// TODO: terminology. 'action' does not belong here. /** * Known view action types */ @@ -10,6 +11,12 @@ export type SlackViewAction = | ViewWorkflowStepSubmitAction // TODO: remove workflow step stuff in bolt v5 | ViewWorkflowStepClosedAction; // +// TODO: add a type parameter here, just like the other constraint interfaces have. +export interface ViewConstraints { + callback_id?: string | RegExp; + type?: 'view_closed' | 'view_submission'; +} + /** * Arguments which listeners and middleware receive to process a view submission event from Slack. */ diff --git a/test/types/action.test-d.ts b/test/types/action.test-d.ts new file mode 100644 index 000000000..3958806e5 --- /dev/null +++ b/test/types/action.test-d.ts @@ -0,0 +1,42 @@ +import { expectAssignable, expectError, expectType } from 'tsd'; +import type { BlockElementAction, DialogSubmitAction, InteractiveAction, SayFn, SlackAction } from '../../'; +import App from '../../src/App'; + +const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); + +// calling action method with incorrect type constraint value should not work +expectError( + app.action({ type: 'Something wrong' }, async ({ action }) => { + await Promise.resolve(action); + }), +); + +app.action({ type: 'block_actions' }, async ({ action, say }) => { + expectType(action); + expectType(say); +}); + +app.action({ type: 'interactive_message' }, async ({ action, say }) => { + expectType(action); + expectType(say); +}); + +app.action({ type: 'dialog_submission' }, async ({ action }) => { + expectType(action); +}); + +expectError(app.action({ type: 'dialog_submission' }, async ({ say }) => say())); + +interface MyContext { + doesnt: 'matter'; +} +// Ensure custom context assigned to individual middleware is honoured +app.action('action_id', async ({ context }) => { + expectAssignable(context); +}); + +// Ensure custom context assigned to the entire app is honoured +const typedContextApp = new App(); +typedContextApp.action('action_id', async ({ context }) => { + expectAssignable(context); +}); diff --git a/test/types/command.test-d.ts b/test/types/command.test-d.ts new file mode 100644 index 000000000..647971698 --- /dev/null +++ b/test/types/command.test-d.ts @@ -0,0 +1,23 @@ +import { expectAssignable, expectType } from 'tsd'; +import type { SlashCommand } from '../..'; +import App from '../../src/App'; + +const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); + +app.command('/hello', async ({ command }) => { + expectType(command); +}); + +interface MyContext { + doesnt: 'matter'; +} +// Ensure custom context assigned to individual middleware is honoured +app.command('/action', async ({ context }) => { + expectAssignable(context); +}); + +// Ensure custom context assigned to the entire app is honoured +const typedContextApp = new App(); +typedContextApp.command('/action', async ({ context }) => { + expectAssignable(context); +}); diff --git a/types-tests/error.test-d.ts b/test/types/error.test-d.ts similarity index 69% rename from types-tests/error.test-d.ts rename to test/types/error.test-d.ts index db25f3e07..08cda23a9 100644 --- a/types-tests/error.test-d.ts +++ b/test/types/error.test-d.ts @@ -1,8 +1,8 @@ -import App from '../src/App'; +import type { IncomingMessage, ServerResponse } from 'node:http'; import { expectType } from 'tsd'; -import { CodedError } from '../src/errors'; -import { IncomingMessage, ServerResponse } from 'http'; -import { BufferedIncomingMessage } from '../src/receivers/BufferedIncomingMessage'; +import App from '../../src/App'; +import type { CodedError } from '../../src/errors'; +import type { BufferedIncomingMessage } from '../../src/receivers/BufferedIncomingMessage'; const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); @@ -12,31 +12,31 @@ app.error(async (error) => { expectType(error); expectType(error.original); - if (error.original != undefined) { + if (error.original !== undefined) { expectType(error.original); console.log(error.original.message); } expectType(error.originals); - if (error.originals != undefined) { + if (error.originals !== undefined) { expectType(error.originals); console.log(error.originals); } expectType(error.missingProperty); - if (error.missingProperty != undefined) { + if (error.missingProperty !== undefined) { expectType(error.missingProperty); console.log(error.missingProperty); } expectType(error.req); - if (error.req != undefined) { + if (error.req !== undefined) { expectType(error.req); console.log(error.req); } expectType(error.res); - if (error.res != undefined) { + if (error.res !== undefined) { expectType(error.res); console.log(error.res); } diff --git a/test/types/event.test-d.ts b/test/types/event.test-d.ts new file mode 100644 index 000000000..89e6faeda --- /dev/null +++ b/test/types/event.test-d.ts @@ -0,0 +1,69 @@ +import type { + AppMentionEvent, + MessageEvent, + PinAddedEvent, + PinRemovedEvent, + ReactionAddedEvent, + ReactionRemovedEvent, + UserHuddleChangedEvent, + UserProfileChangedEvent, + UserStatusChangedEvent, +} from '@slack/types'; +import { expectType } from 'tsd'; +import type { SayFn } from '../../'; +import App from '../../src/App'; + +const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); + +app.event('message', async ({ event, say, message }) => { + expectType(event); + expectType(message); + expectType(say); +}); + +app.event('app_mention', async ({ event }) => { + expectType(event); +}); + +app.event('reaction_added', async ({ event }) => { + expectType(event); +}); + +app.event('reaction_removed', async ({ event }) => { + expectType(event); +}); + +app.event('user_huddle_changed', async ({ event }) => { + expectType(event); +}); + +app.event('user_profile_changed', async ({ event }) => { + expectType(event); +}); + +app.event('user_status_changed', async ({ event }) => { + expectType(event); +}); + +app.event('pin_added', async ({ say, event }) => { + expectType(say); + expectType(event); +}); + +app.event('pin_removed', async ({ say, event }) => { + expectType(say); + expectType(event); +}); + +app.event('reaction_added', async ({ say, event }) => { + expectType(say); + expectType(event); +}); + +app.event('reaction_removed', async ({ say, event }) => { + expectType(say); + expectType(event); +}); + +// TODO: we should not allow providing bogus event names +// app.event('garbage', async ({ event }) => {}); diff --git a/types-tests/message.test-d.ts b/test/types/message.test-d.ts similarity index 80% rename from types-tests/message.test-d.ts rename to test/types/message.test-d.ts index 0a3c5c032..94c0177df 100644 --- a/types-tests/message.test-d.ts +++ b/test/types/message.test-d.ts @@ -1,25 +1,24 @@ -import { expectNotType, expectType, expectError } from 'tsd'; -// eslint-disable-next-line -import App from '../src/App'; -import { - type MessageEvent, - type GenericMessageEvent, - type BotMessageEvent, - type MessageRepliedEvent, - type MeMessageEvent, - type MessageDeletedEvent, - type ThreadBroadcastMessageEvent, - type MessageChangedEvent, - type EKMAccessDeniedMessageEvent, +import type { AllMessageEvents, -} from '..'; + BotMessageEvent, + EKMAccessDeniedMessageEvent, + GenericMessageEvent, + MeMessageEvent, + MessageChangedEvent, + MessageDeletedEvent, + MessageEvent, + MessageRepliedEvent, + ThreadBroadcastMessageEvent, +} from '@slack/types'; +import { expectAssignable, expectError, expectNotType, expectType } from 'tsd'; +import App from '../../src/App'; const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); // TODO: asserting on the types of event sub-properties is a responsibility of the `@slack/types` package, not bolt. // e.g. message.user, message.team, etc. // -// Types for generic message listeners, i.e. MessageEvent aka GenericMessageEvent +// Types for generic message listeners, i.e. MessageEvent app.message(async ({ message }) => { expectType(message); @@ -96,3 +95,17 @@ app.message(async ({ message }) => { await Promise.resolve(message); }); + +interface MyContext { + doesnt: 'matter'; +} +// Ensure custom context assigned to individual middleware is honoured +app.message(async ({ context }) => { + expectAssignable(context); +}); + +// Ensure custom context assigned to the entire app is honoured +const typedContextApp = new App(); +typedContextApp.message(async ({ context }) => { + expectAssignable(context); +}); diff --git a/test/types/options.test-d.ts b/test/types/options.test-d.ts new file mode 100644 index 000000000..07abe7478 --- /dev/null +++ b/test/types/options.test-d.ts @@ -0,0 +1,102 @@ +import type { Option } from '@slack/types'; +import { expectAssignable, expectType } from 'tsd'; +import type { + AckFn, + BlockOptions, + BlockSuggestion, + DialogOptionGroups, + DialogOptions, + DialogSuggestion, + InteractiveMessageSuggestion, + MessageOptions, + OptionGroups, +} from '../..'; +import App from '../../src/App'; + +const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); + +app.options('action-id-or-callback-id', async ({ options, ack }) => { + // TODO: should BlockSuggestion belong in types package? if so, assertions on its contents should also move to types package. + // defaults options to block_suggestion + expectType(options); + // biome-ignore lint/suspicious/noExplicitAny: TODO: should the callback ID be any? seems wrong + expectType(options.callback_id); + options.block_id; + options.action_id; + // ack should allow either BlockOptions or OptionGroups + // https://github.com/slackapi/bolt-js/issues/720 + expectAssignable>(ack); + expectAssignable>>(ack); +}); + +// FIXME: app.options({ type: 'block_suggestion', action_id: 'a' } does not constrain the arguments of the handler down to `block_suggestion` + +// interactive_message (attachments) +app.options<'interactive_message'>({ callback_id: 'a' }, async ({ options, ack }) => { + expectType(options); + // ack should allow either MessageOptions or OptionGroups + // https://github.com/slackapi/bolt-js/issues/720 + expectAssignable>(ack); + expectAssignable>>(ack); +}); + +// FIXME: app.options({ type: 'interactive_message', callback_id: 'a' } does not constrain the arguments of the handler down to `interactive_message` + +// dialog_suggestion (dialog) +app.options<'dialog_suggestion'>({ callback_id: 'a' }, async ({ options, ack }) => { + expectType(options); + // ack should allow either MessageOptions or OptionGroups + // https://github.com/slackapi/bolt-js/issues/720 + expectAssignable>(ack); + expectAssignable>>(ack); +}); +// FIXME: app.options({ type: 'dialog_suggestion', callback_id: 'a' } does not constrain the arguments of the handler down to `dialog_sggestion` + +const db = { + get: (_teamId: string) => { + return [{ label: 'l', value: 'v' }]; + }, +}; + +// Taken from https://slack.dev/bolt-js/concepts#options +// Example of responding to an external_select options request +app.options('external_action', async ({ options, ack }) => { + // Get information specific to a team or channel + // TODO: modified to satisfy TS compiler; should team be optional? + const results = options.team != null ? db.get(options.team.id) : []; + + if (results) { + // (modified to satisfy TS compiler) + const options: Option[] = []; + // Collect information in options array to send in Slack ack response + for (const result of results) { + options.push({ + text: { + type: 'plain_text', + text: result.label, + }, + value: result.value, + }); + } + + await ack({ + options: options, + }); + } else { + await ack(); + } +}); + +interface MyContext { + doesnt: 'matter'; +} +// Ensure custom context assigned to individual middleware is honoured +app.options<'block_suggestion', MyContext>('suggest', async ({ context }) => { + expectAssignable(context); +}); + +// Ensure custom context assigned to the entire app is honoured +const typedContextApp = new App(); +typedContextApp.options('suggest', async ({ context }) => { + expectAssignable(context); +}); diff --git a/test/types/shortcut.test-d.ts b/test/types/shortcut.test-d.ts new file mode 100644 index 000000000..1682568ba --- /dev/null +++ b/test/types/shortcut.test-d.ts @@ -0,0 +1,62 @@ +import { expectAssignable, expectError, expectType } from 'tsd'; +import type { GlobalShortcut, MessageShortcut, SayFn, SlackShortcut } from '../..'; +import App from '../../src/App'; + +const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); + +// calling shortcut method with incorrect type constraint value should not work +expectError( + app.shortcut({ type: 'Something wrong' }, async ({ shortcut }) => { + await Promise.resolve(shortcut); + }), +); + +// Shortcut in listener should be MessageShortcut if constraint is type:message_action +app.shortcut({ type: 'message_action' }, async ({ shortcut, say }) => { + expectType(shortcut); + expectType(say); +}); + +// If shortcut is parameterized with MessageShortcut, shortcut argument in callback should be type MessageShortcut +app.shortcut({}, async ({ shortcut, say }) => { + expectType(shortcut); + expectType(say); +}); + +// If the constraint is unspecific, say will be unavailable +expectError(app.shortcut({}, async ({ say }) => say())); + +// If the constraint is unspecific, the shortcut is the more general SlackShortcut type +app.shortcut({}, async ({ shortcut }) => { + expectType(shortcut); +}); + +// `say` in listener should be unavailable if constraint is type:shortcut +expectError(app.shortcut({ type: 'shortcut' }, async ({ say }) => say())); + +// Shortcut in listener should be GlobalShortcut if constraint is type:shortcut +app.shortcut({ type: 'shortcut' }, async ({ shortcut }) => { + expectType(shortcut); +}); + +// If shortcut is parameterized with GlobalShortcut, say argument in callback should not be available +expectError(app.shortcut({}, async ({ say }) => say())); + +// If shortcut is parameterized with GlobalShortcut, shortcut parameter should be of type GlobalShortcut +app.shortcut({}, async ({ shortcut }) => { + expectType(shortcut); +}); + +interface MyContext { + doesnt: 'matter'; +} +// Ensure custom context assigned to individual middleware is honoured +app.shortcut('callback_id', async ({ context }) => { + expectAssignable(context); +}); + +// Ensure custom context assigned to the entire app is honoured +const typedContextApp = new App(); +typedContextApp.shortcut('callback_id', async ({ context }) => { + expectAssignable(context); +}); diff --git a/test/types/use.test-d.ts b/test/types/use.test-d.ts new file mode 100644 index 000000000..ce349b9e0 --- /dev/null +++ b/test/types/use.test-d.ts @@ -0,0 +1,31 @@ +import { expectAssignable } from 'tsd'; +import App from '../../src/App'; +import { onlyCommands, onlyViewActions } from '../../src/middleware/builtin'; + +const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); + +// Ensure you can use some of the built-in middleware as global middleware +// https://github.com/slackapi/bolt-js/issues/911 +app.use(onlyViewActions); +app.use(onlyCommands); +app.use(async ({ ack, next }) => { + if (ack) { + await ack(); + return; + } + await next(); +}); + +interface MyContext { + doesnt: 'matter'; +} +// Ensure custom context assigned to individual middleware is honoured +app.use(async ({ context }) => { + expectAssignable(context); +}); + +// Ensure custom context assigned to the entire app is honoured +const typedContextApp = new App(); +typedContextApp.use(async ({ context }) => { + expectAssignable(context); +}); diff --git a/test/types/view.test-d.ts b/test/types/view.test-d.ts new file mode 100644 index 000000000..7fe502af3 --- /dev/null +++ b/test/types/view.test-d.ts @@ -0,0 +1,63 @@ +import { expectAssignable, expectError, expectType } from 'tsd'; +import type { SlackViewAction, ViewOutput } from '../..'; +import App from '../../src/App'; + +const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); + +// invalid view constraints +expectError( + app.view( + { + callback_id: 'foo', + type: 'view_submission', + unknown_key: 'should be detected', + }, + async () => {}, + ), +); +expectError( + app.view( + { + callback_id: 'foo', + type: undefined, + unknown_key: 'should be detected', + }, + async () => {}, + ), +); +// view_submission +app.view('modal-id', async ({ body, view }) => { + // TODO: the body can be more specific (ViewSubmitAction) here + expectType(body); + expectType(view); + // TODO: assert on type assignability for `ack` +}); + +app.view({ type: 'view_submission', callback_id: 'modal-id' }, async ({ body, view }) => { + // TODO: the body can be more specific (ViewSubmitAction) here. need to add a type parameter (generic) to view() and 'link' constraint w/ view types. + expectType(body); + expectType(view); + // TODO: assert on type assignability for `ack` +}); + +// view_closed +app.view({ type: 'view_closed', callback_id: 'modal-id' }, async ({ body, view }) => { + // TODO: the body can be more specific (ViewClosedAction) here. need to add a type parameter (generic) to view() and 'link' constraint w/ view types. + expectType(body); + expectType(view); + // TODO: assert on type assignability for `ack` +}); + +interface MyContext { + doesnt: 'matter'; +} +// Ensure custom context assigned to individual middleware is honoured +app.view('view-id', async ({ context }) => { + expectAssignable(context); +}); + +// Ensure custom context assigned to the entire app is honoured +const typedContextApp = new App(); +typedContextApp.view('view-id', async ({ context }) => { + expectAssignable(context); +}); diff --git a/.mocharc.json b/test/unit/.mocharc.json similarity index 69% rename from .mocharc.json rename to test/unit/.mocharc.json index 09b509473..3958af49e 100644 --- a/.mocharc.json +++ b/test/unit/.mocharc.json @@ -1,4 +1,5 @@ { "require": ["ts-node/register", "source-map-support/register"], + "spec": ["test/unit/**/*.spec.ts"], "timeout": 3000 } diff --git a/test/unit/App/basic.spec.ts b/test/unit/App/basic.spec.ts new file mode 100644 index 000000000..271283100 --- /dev/null +++ b/test/unit/App/basic.spec.ts @@ -0,0 +1,377 @@ +import { LogLevel } from '@slack/logger'; +import { assert } from 'chai'; +import sinon from 'sinon'; +import { ErrorCode } from '../../../src/errors'; +import SocketModeReceiver from '../../../src/receivers/SocketModeReceiver'; +import { + FakeReceiver, + createFakeConversationStore, + createFakeLogger, + importApp, + mergeOverrides, + noop, + noopMiddleware, + withConversationContext, + withMemoryStore, + withNoopAppMetadata, + withNoopWebClient, + withSuccessfulBotUserFetchingWebClient, +} from '../helpers'; + +const fakeAppToken = 'xapp-1234'; +const fakeBotId = 'B_FAKE_BOT_ID'; +const fakeBotUserId = 'U_FAKE_BOT_USER_ID'; + +describe('App basic features', () => { + const overrides = mergeOverrides( + withNoopAppMetadata(), + withSuccessfulBotUserFetchingWebClient(fakeBotId, fakeBotUserId), + ); + + describe('constructor', () => { + describe('with a custom port value in HTTP Mode', () => { + it('should accept a port value at the top-level', async () => { + const MockApp = await importApp(overrides); + const app = new MockApp({ token: '', signingSecret: '', port: 9999 }); + // biome-ignore lint/complexity/useLiteralKeys: reaching into private fields + assert.propertyVal(app['receiver'], 'port', 9999); + }); + it('should accept a port value under installerOptions', async () => { + const MockApp = await importApp(overrides); + const app = new MockApp({ token: '', signingSecret: '', port: 7777, installerOptions: { port: 9999 } }); + // biome-ignore lint/complexity/useLiteralKeys: reaching into private fields + assert.propertyVal(app['receiver'], 'port', 9999); + }); + }); + + describe('with a custom port value in Socket Mode', () => { + const installationStore = { + storeInstallation: async () => {}, + fetchInstallation: async () => { + throw new Error('Failed fetching installation'); + }, + deleteInstallation: async () => {}, + }; + it('should accept a port value at the top-level', async () => { + const MockApp = await importApp(overrides); + const app = new MockApp({ + socketMode: true, + appToken: fakeAppToken, + port: 9999, + clientId: '', + clientSecret: '', + stateSecret: '', + installerOptions: {}, + installationStore, + }); + // biome-ignore lint/complexity/useLiteralKeys: reaching into private fields + assert.propertyVal(app['receiver'], 'httpServerPort', 9999); + }); + it('should accept a port value under installerOptions', async () => { + const MockApp = await importApp(overrides); + const app = new MockApp({ + socketMode: true, + appToken: fakeAppToken, + port: 7777, + clientId: '', + clientSecret: '', + stateSecret: '', + installerOptions: { + port: 9999, + }, + installationStore, + }); + // biome-ignore lint/complexity/useLiteralKeys: reaching into private fields + assert.propertyVal(app['receiver'], 'httpServerPort', 9999); + }); + }); + + // TODO: test when the single team authorization results fail. that should still succeed but warn. it also means + // that the `ignoreSelf` middleware will fail (or maybe just warn) a bunch. + describe('with successful single team authorization results', () => { + it('should succeed with a token for single team authorization', async () => { + const MockApp = await importApp(overrides); + const app = new MockApp({ token: '', signingSecret: '' }); + // TODO: verify that the fake bot ID and fake bot user ID are retrieved + assert.instanceOf(app, MockApp); + }); + it('should pass the given token to app.client', async () => { + const MockApp = await importApp(overrides); + const app = new MockApp({ token: 'xoxb-foo-bar', signingSecret: '' }); + assert.isDefined(app.client); + assert.equal(app.client.token, 'xoxb-foo-bar'); + }); + }); + it('should succeed with an authorize callback', async () => { + const authorizeCallback = sinon.fake(); + const MockApp = await importApp(); + new MockApp({ authorize: authorizeCallback, signingSecret: '' }); + assert(authorizeCallback.notCalled, 'Should not call the authorize callback on instantiation'); + }); + it('should fail without a token for single team authorization, authorize callback, nor oauth installer', async () => { + const MockApp = await importApp(); + try { + new MockApp({ signingSecret: '' }); + assert.fail(); + } catch (error) { + assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); + } + }); + it('should fail when both a token and authorize callback are specified', async () => { + const authorizeCallback = sinon.fake(); + const MockApp = await importApp(); + try { + new MockApp({ token: '', authorize: authorizeCallback, signingSecret: '' }); + assert.fail(); + } catch (error) { + assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); + assert(authorizeCallback.notCalled); + } + }); + it('should fail when both a token is specified and OAuthInstaller is initialized', async () => { + const authorizeCallback = sinon.fake(); + const MockApp = await importApp(); + try { + new MockApp({ token: '', clientId: '', clientSecret: '', stateSecret: '', signingSecret: '' }); + assert.fail(); + } catch (error) { + assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); + assert(authorizeCallback.notCalled); + } + }); + it('should fail when both a authorize callback is specified and OAuthInstaller is initialized', async () => { + const authorizeCallback = sinon.fake(); + const MockApp = await importApp(); + try { + new MockApp({ + authorize: authorizeCallback, + clientId: '', + clientSecret: '', + stateSecret: '', + signingSecret: '', + }); + assert.fail(); + } catch (error) { + assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); + assert(authorizeCallback.notCalled); + } + }); + describe('with a custom receiver', () => { + it('should succeed with no signing secret', async () => { + const MockApp = await importApp(); + new MockApp({ + receiver: new FakeReceiver(), + authorize: noop, + }); + }); + }); + it('should fail when no signing secret for the default receiver is specified', async () => { + const MockApp = await importApp(); + try { + new MockApp({ authorize: noop }); + assert.fail(); + } catch (error) { + assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); + } + }); + it('should fail when both socketMode and a custom receiver are specified', async () => { + const fakeReceiver = new FakeReceiver(); + const MockApp = await importApp(); + try { + new MockApp({ token: '', signingSecret: '', socketMode: true, receiver: fakeReceiver }); + assert.fail(); + } catch (error) { + assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); + } + }); + it('should succeed when both socketMode and SocketModeReceiver are specified', async () => { + const MockApp = await importApp(overrides); + const socketModeReceiver = new SocketModeReceiver({ appToken: fakeAppToken }); + new MockApp({ token: '', signingSecret: '', socketMode: true, receiver: socketModeReceiver }); + }); + it('should initialize MemoryStore conversation store by default', async () => { + const fakeMemoryStore = sinon.fake(); + const fakeConversationContext = sinon.fake.returns(noopMiddleware); + const overrides = mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + withMemoryStore(fakeMemoryStore), + withConversationContext(fakeConversationContext), + ); + const MockApp = await importApp(overrides); + + new MockApp({ authorize: noop, signingSecret: '' }); + assert(fakeMemoryStore.calledWithNew); + assert(fakeConversationContext.called); + }); + describe('conversation store', () => { + const fakeConversationContext = sinon.fake.returns(noopMiddleware); + const overrides = mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + withConversationContext(fakeConversationContext), + ); + it('should initialize without a conversation store when option is false', async () => { + const MockApp = await importApp(overrides); + new MockApp({ convoStore: false, authorize: noop, signingSecret: '' }); + assert(fakeConversationContext.notCalled); + }); + it('should initialize the conversation store', async () => { + const dummyConvoStore = createFakeConversationStore(); + const MockApp = await importApp(overrides); + const app = new MockApp({ convoStore: dummyConvoStore, authorize: noop, signingSecret: '' }); + assert.instanceOf(app, MockApp); + assert(fakeConversationContext.firstCall.calledWith(dummyConvoStore)); + }); + }); + describe('with custom redirectUri supplied', () => { + it('should fail when missing installerOptions', async () => { + const MockApp = await importApp(); + try { + new MockApp({ token: '', signingSecret: '', redirectUri: 'http://example.com/redirect' }); // eslint-disable-line no-new + assert.fail(); + } catch (error) { + assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); + } + }); + it('should fail when missing installerOptions.redirectUriPath', async () => { + const MockApp = await importApp(); + try { + new MockApp({ + token: '', + signingSecret: '', + redirectUri: 'http://example.com/redirect', + installerOptions: {}, + }); + assert.fail(); + } catch (error) { + assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); + } + }); + }); + it('with WebClientOptions', async () => { + const fakeConstructor = sinon.fake(); + const overrides = mergeOverrides(withNoopAppMetadata(), { + '@slack/web-api': { + WebClient: class { + // biome-ignore lint/suspicious/noExplicitAny: test overrides can be anything + public constructor(...args: any[]) { + fakeConstructor(...args); + } + }, + }, + }); + + const MockApp = await importApp(overrides); + const clientOptions = { slackApiUrl: 'proxy.slack.com' }; + new MockApp({ clientOptions, authorize: noop, signingSecret: '', logLevel: LogLevel.ERROR }); + assert.ok(fakeConstructor.called); + const [token, options] = fakeConstructor.lastCall.args; + assert.strictEqual(token, undefined, 'token should be undefined'); + assert.strictEqual(clientOptions.slackApiUrl, options.slackApiUrl); + assert.strictEqual(LogLevel.ERROR, options.logLevel, 'override logLevel'); + }); + describe('with auth.test failure', () => { + const fakeConstructor = sinon.fake(); + const exception = 'This API method call should not be performed'; + const overrides = mergeOverrides(withNoopAppMetadata(), { + '@slack/web-api': { + WebClient: class { + // biome-ignore lint/suspicious/noExplicitAny: test overrides can be anything + public constructor(...args: any[]) { + fakeConstructor(args); + } + + public auth = { + test: () => { + throw new Error(exception); + }, + }; + }, + }, + }); + it('should not perform auth.test API call if tokenVerificationEnabled is false', async () => { + const MockApp = await importApp(overrides); + new MockApp({ + token: 'xoxb-completely-invalid-token', + signingSecret: 'invalid-one', + tokenVerificationEnabled: false, + }); + }); + + it('should fail in await App#init()', async () => { + const MockApp = await importApp(overrides); + const app = new MockApp({ + token: 'xoxb-completely-invalid-token', + signingSecret: 'invalid-one', + deferInitialization: true, + }); + assert.instanceOf(app, MockApp); + try { + await app.start(); + assert.fail('The start() method should fail before init() call'); + } catch (err) { + assert.propertyVal( + err, + 'message', + 'This App instance is not yet initialized. Call `await App#init()` before starting the app.', + ); + } + try { + await app.init(); + assert.fail('The init() method should fail here'); + } catch (err) { + console.log(err); + assert.propertyVal(err, 'message', exception); + } + }); + }); + + describe('with developerMode', () => { + it('should accept developerMode: true', async () => { + const overrides = mergeOverrides( + withNoopAppMetadata(), + withSuccessfulBotUserFetchingWebClient('B_FAKE_BOT_ID', 'U_FAKE_BOT_USER_ID'), + ); + const fakeLogger = createFakeLogger(); + const MockApp = await importApp(overrides); + const app = new MockApp({ logger: fakeLogger, token: '', appToken: fakeAppToken, developerMode: true }); + assert.propertyVal(app, 'logLevel', LogLevel.DEBUG); + assert.propertyVal(app, 'socketMode', true); + }); + }); + + // TODO: tests for logger and logLevel option + // TODO: tests for providing botId and botUserId options + // TODO: tests for providing endpoints option + }); + + describe('#start', () => { + it('should pass calls through to receiver', async () => { + // Arrange + const dummyReturn = Symbol(); + const fakeReceiver = new FakeReceiver(); + const MockApp = await importApp(); + const app = new MockApp({ receiver: fakeReceiver, authorize: noop }); + fakeReceiver.start = sinon.fake.returns(dummyReturn); + await app.start(1337); + assert.deepEqual(fakeReceiver.start.firstCall.args, [1337]); + }); + }); + + describe('#stop', () => { + it('should pass calls through to receiver', async () => { + const dummyReturn = Symbol(); + const dummyParams = [Symbol(), Symbol()]; + const fakeReceiver = new FakeReceiver(); + const MockApp = await importApp(); + fakeReceiver.stop = sinon.fake.returns(dummyReturn); + + const app = new MockApp({ receiver: fakeReceiver, authorize: noop }); + const actualReturn = await app.stop(...dummyParams); + + assert.deepEqual(actualReturn, dummyReturn); + assert.deepEqual(dummyParams, fakeReceiver.stop.firstCall.args); + }); + }); +}); diff --git a/test/unit/App/middleware.spec.ts b/test/unit/App/middleware.spec.ts new file mode 100644 index 000000000..4d87fc203 --- /dev/null +++ b/test/unit/App/middleware.spec.ts @@ -0,0 +1,1085 @@ +import type { WebClient } from '@slack/web-api'; +import { assert } from 'chai'; +import sinon, { type SinonSpy } from 'sinon'; +import type App from '../../../src/App'; +import { type ExtendedErrorHandlerArgs, LogLevel } from '../../../src/App'; +import { AuthorizationError, type CodedError, ErrorCode, UnknownError, isCodedError } from '../../../src/errors'; +import type { NextFn, ReceiverEvent, SayFn } from '../../../src/types'; +import { + FakeReceiver, + type Override, + createDummyAppMentionEventMiddlewareArgs, + createDummyBlockActionEventMiddlewareArgs, + createDummyMessageEventMiddlewareArgs, + createDummyReceiverEvent, + createDummyViewSubmissionMiddlewareArgs, + createFakeLogger, + delay, + importApp, + mergeOverrides, + noop, + noopMiddleware, + noopVoid, + withAxiosPost, + withConversationContext, + withMemoryStore, + withNoopAppMetadata, + withNoopWebClient, + withPostMessage, + withSuccessfulBotUserFetchingWebClient, +} from '../helpers'; + +describe('App middleware processing', () => { + let fakeReceiver: FakeReceiver; + let fakeErrorHandler: SinonSpy; + let dummyAuthorizationResult: { botToken: string; botId: string }; + + beforeEach(() => { + fakeReceiver = new FakeReceiver(); + fakeErrorHandler = sinon.fake(); + dummyAuthorizationResult = { botToken: '', botId: '' }; + }); + + // TODO: verify that authorize callback is called with the correct properties and responds correctly to + // various return values + + function createInvalidReceiverEvents(): ReceiverEvent[] { + // TODO: create many more invalid receiver events (fuzzing) + return [ + { + body: {}, + ack: sinon.fake.resolves(undefined), + }, + ]; + } + // TODO: tests for ignoreSelf option + + it('should warn and skip when processing a receiver event with unknown type (never crash)', async () => { + const fakeLogger = createFakeLogger(); + const fakeMiddleware = sinon.fake(noopMiddleware); + const invalidReceiverEvents = createInvalidReceiverEvents(); + const MockApp = await importApp(); + + const app = new MockApp({ receiver: fakeReceiver, logger: fakeLogger, authorize: noop }); + app.use(fakeMiddleware); + await Promise.all(invalidReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); + + assert(fakeErrorHandler.notCalled); + assert(fakeMiddleware.notCalled); + assert.isAtLeast(fakeLogger.warn.callCount, invalidReceiverEvents.length); + }); + + it('should warn, send to global error handler, and skip when a receiver event fails authorization', async () => { + const fakeLogger = createFakeLogger(); + const fakeMiddleware = sinon.fake(noopMiddleware); + const dummyOrigError = new Error('auth failed'); + const dummyAuthorizationError = new AuthorizationError('auth failed', dummyOrigError); + const dummyReceiverEvent = createDummyReceiverEvent(); + const MockApp = await importApp(); + + const app = new MockApp({ + receiver: fakeReceiver, + logger: fakeLogger, + authorize: sinon.fake.rejects(dummyAuthorizationError), + }); + app.use(fakeMiddleware); + app.error(fakeErrorHandler); + await fakeReceiver.sendEvent(dummyReceiverEvent); + + assert(fakeMiddleware.notCalled); + assert(fakeLogger.warn.called); + assert.instanceOf(fakeErrorHandler.firstCall.args[0], Error); + assert.propertyVal(fakeErrorHandler.firstCall.args[0], 'code', ErrorCode.AuthorizationError); + assert.propertyVal(fakeErrorHandler.firstCall.args[0], 'original', dummyAuthorizationError.original); + }); + + describe('global middleware', () => { + let fakeFirstMiddleware: SinonSpy; + let fakeSecondMiddleware: SinonSpy; + let app: App; + let dummyReceiverEvent: ReceiverEvent; + + beforeEach(async () => { + const fakeConversationContext = sinon.fake.returns(noopMiddleware); + const overrides = mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + withMemoryStore(sinon.fake()), + withConversationContext(fakeConversationContext), + ); + const MockApp = await importApp(overrides); + + dummyReceiverEvent = createDummyReceiverEvent(); + fakeFirstMiddleware = sinon.fake(noopMiddleware); + fakeSecondMiddleware = sinon.fake(noopMiddleware); + + app = new MockApp({ + logger: createFakeLogger(), + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + }); + + it('should error if next called multiple times', async () => { + // Arrange + app.use(fakeFirstMiddleware); + app.use(async ({ next }) => { + await next(); + await next(); + }); + app.use(fakeSecondMiddleware); + app.error(fakeErrorHandler); + + // Act + await fakeReceiver.sendEvent(dummyReceiverEvent); + + // Assert + assert.instanceOf(fakeErrorHandler.firstCall.args[0], Error); + }); + + it('correctly waits for async listeners', async () => { + let changed = false; + + app.use(async ({ next }) => { + await delay(10); + changed = true; + + await next(); + }); + + await fakeReceiver.sendEvent(dummyReceiverEvent); + assert.isTrue(changed); + assert(fakeErrorHandler.notCalled); + }); + + it('throws errors which can be caught by upstream async listeners', async () => { + const thrownError = new Error('Error handling the message :('); + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything + let caughtError: any; + + app.use(async ({ next }) => { + try { + await next(); + } catch (err) { + caughtError = err; + } + }); + + app.use(async () => { + throw thrownError; + }); + + app.error(fakeErrorHandler); + + await fakeReceiver.sendEvent(dummyReceiverEvent); + + assert.equal(caughtError, thrownError); + assert(fakeErrorHandler.notCalled); + }); + + it('calls async middleware in declared order', async () => { + const message = ':wave:'; + let middlewareCount = 0; + + /** + * Middleware that, when called, asserts that it was called in the correct order + * @param orderDown The order it should be called when processing middleware down the chain + * @param orderUp The order it should be called when processing middleware up the chain + */ + const assertOrderMiddleware = + (orderDown: number, orderUp: number) => + async ({ next }: { next?: NextFn }) => { + await delay(10); + middlewareCount += 1; + assert.equal(middlewareCount, orderDown); + if (next !== undefined) { + await next(); + } + middlewareCount += 1; + assert.equal(middlewareCount, orderUp); + }; + + app.use(assertOrderMiddleware(1, 8)); + app.message(message, assertOrderMiddleware(3, 6), assertOrderMiddleware(4, 5)); + app.use(assertOrderMiddleware(2, 7)); + app.error(fakeErrorHandler); + + await fakeReceiver.sendEvent({ + ...dummyReceiverEvent, + body: { + type: 'event_callback', + event: { + type: 'message', + text: message, + }, + }, + }); + + assert.equal(middlewareCount, 8); + assert(fakeErrorHandler.notCalled); + }); + + it('should, on error, call the global error handler, not extended', async () => { + const error = new Error('Everything is broke, you probably should restart, if not then good luck'); + + app.use(() => { + throw error; + }); + + app.error(async (codedError: CodedError) => { + assert.instanceOf(codedError, UnknownError); + assert.equal(codedError.message, error.message); + }); + + await fakeReceiver.sendEvent(dummyReceiverEvent); + }); + + it('should, on error, call the global error handler, extended', async () => { + const error = new Error('Everything is broke, you probably should restart, if not then good luck'); + // biome-ignore lint/complexity/useLiteralKeys: Accessing through bracket notation because it is private (for testing purposes) + app['extendedErrorHandler'] = true; + + app.use(() => { + throw error; + }); + + app.error(async (args: ExtendedErrorHandlerArgs) => { + assert.property(args, 'error'); + assert.property(args, 'body'); + assert.property(args, 'context'); + assert.property(args, 'logger'); + assert.isDefined(args.error); + assert.isDefined(args.body); + assert.isDefined(args.context); + assert.isDefined(args.logger); + assert.equal(args.error.message, error.message); + }); + + await fakeReceiver.sendEvent(dummyReceiverEvent); + + // biome-ignore lint/complexity/useLiteralKeys: Accessing through bracket notation because it is private (for testing purposes) + app['extendedErrorHandler'] = false; + }); + + it('with a default global error handler, rejects App#ProcessEvent', async () => { + const error = new Error('The worst has happened, bot is beyond saving, always hug servers'); + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything + let actualError: any; + + app.use(() => { + throw error; + }); + + try { + await fakeReceiver.sendEvent(dummyReceiverEvent); + } catch (err) { + actualError = err; + } + + assert.instanceOf(actualError, UnknownError); + assert.equal(actualError.message, error.message); + }); + }); + + describe('listener middleware', () => { + let app: App; + const eventType = 'some_event_type'; + const dummyReceiverEvent = createDummyReceiverEvent(eventType); + + beforeEach(async () => { + const MockAppNoOverrides = await importApp(); + app = new MockAppNoOverrides({ + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + app.error(fakeErrorHandler); + }); + + it('should bubble up errors in listeners to the global error handler', async () => { + const errorToThrow = new Error('listener error'); + + app.event(eventType, async () => { + throw errorToThrow; + }); + await fakeReceiver.sendEvent(dummyReceiverEvent); + + assert(fakeErrorHandler.calledOnce); + const error = fakeErrorHandler.firstCall.args[0]; + assert.equal(error.code, ErrorCode.UnknownError); + assert.equal(error.original, errorToThrow); + }); + + it('should aggregate multiple errors in listeners for the same incoming event', async () => { + const errorsToThrow = [new Error('first listener error'), new Error('second listener error')]; + function createThrowingListener(toBeThrown: Error): () => Promise { + return async () => { + throw toBeThrown; + }; + } + + app.event(eventType, createThrowingListener(errorsToThrow[0])); + app.event(eventType, createThrowingListener(errorsToThrow[1])); + await fakeReceiver.sendEvent(dummyReceiverEvent); + + assert(fakeErrorHandler.calledOnce); + const error = fakeErrorHandler.firstCall.args[0]; + assert.ok(isCodedError(error)); + assert(error.code === ErrorCode.MultipleListenerError); + assert.isArray(error.originals); + if (error.originals) assert.sameMembers(error.originals, errorsToThrow); + }); + + // https://github.com/slackapi/bolt-js/issues/1457 + it('should not cause a runtime exception if the last listener middleware invokes next()', async () => { + await new Promise((resolve, reject) => { + app.event('app_mention', async ({ next }) => { + try { + await next(); + resolve(); + } catch (e) { + reject(e); + } + }); + fakeReceiver.sendEvent(createDummyReceiverEvent('app_mention')); + }); + }); + }); + + describe('middleware and listener arguments', () => { + let overrides: Override; + const dummyChannelId = 'CHANNEL_ID'; + const baseEvent = createDummyReceiverEvent(); + + function buildOverrides(secondOverrides: Override[]): Override { + overrides = mergeOverrides( + withNoopAppMetadata(), + ...secondOverrides, + withMemoryStore(sinon.fake()), + withConversationContext(sinon.fake.returns(noopMiddleware)), + ); + return overrides; + } + + describe('authorize', () => { + it('should extract valid enterprise_id in a shared channel #935', async () => { + const fakeAxiosPost = sinon.fake.resolves({}); + overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); + const MockApp = await importApp(overrides); + + let workedAsExpected = false; + const app = new MockApp({ + receiver: fakeReceiver, + authorize: async ({ enterpriseId }) => { + if (enterpriseId !== undefined) { + throw new Error('the enterprise_id must be undefined in this scenario'); + } + return dummyAuthorizationResult; + }, + }); + app.event('message', async () => { + workedAsExpected = true; + }); + await fakeReceiver.sendEvent({ + ack: noopVoid, + ...createDummyMessageEventMiddlewareArgs( + {}, + { + authorizations: [ + { + enterprise_id: null, + team_id: 'T_this_non_grid_workspace', + user_id: 'U_authed_user', + is_bot: true, + is_enterprise_install: false, + }, + ], + }, + ), + }); + + assert.isTrue(workedAsExpected); + }); + it('should be skipped for tokens_revoked events #674', async () => { + const fakeAxiosPost = sinon.fake.resolves({}); + overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); + const MockApp = await importApp(overrides); + + let workedAsExpected = false; + let authorizeCallCount = 0; + const app = new MockApp({ + receiver: fakeReceiver, + authorize: async () => { + authorizeCallCount += 1; + return {}; + }, + }); + app.event('tokens_revoked', async () => { + workedAsExpected = true; + }); + + // The authorize must be called for other events + await fakeReceiver.sendEvent({ + ack: noopVoid, + ...createDummyAppMentionEventMiddlewareArgs(), + }); + assert.equal(authorizeCallCount, 1); + + await fakeReceiver.sendEvent({ + ack: noopVoid, + body: { + enterprise_id: 'E_org_id', + api_app_id: 'A111', + event: { + type: 'tokens_revoked', + tokens: { + oauth: ['P'], + bot: ['B'], + }, + }, + type: 'event_callback', + }, + }); + + assert.equal(authorizeCallCount, 1); // still 1 + assert.isTrue(workedAsExpected); + }); + it('should be skipped for app_uninstalled events #674', async () => { + const fakeAxiosPost = sinon.fake.resolves({}); + overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); + const MockApp = await importApp(overrides); + + let workedAsExpected = false; + let authorizeCallCount = 0; + const app = new MockApp({ + receiver: fakeReceiver, + authorize: async () => { + authorizeCallCount += 1; + return {}; + }, + }); + app.event('app_uninstalled', async () => { + workedAsExpected = true; + }); + + // The authorize must be called for other events + await fakeReceiver.sendEvent({ + ack: noopVoid, + ...createDummyAppMentionEventMiddlewareArgs(), + }); + assert.equal(authorizeCallCount, 1); + + await fakeReceiver.sendEvent({ + ack: noopVoid, + body: { + enterprise_id: 'E_org_id', + api_app_id: 'A111', + event: { + type: 'app_uninstalled', + }, + type: 'event_callback', + }, + }); + + assert.equal(authorizeCallCount, 1); // still 1 + assert.isTrue(workedAsExpected); + }); + }); + + describe('respond()', () => { + it('should respond to events with a response_url', async () => { + const responseText = 'response'; + const response_url = 'https://fake.slack/response_url'; + const action_id = 'block_action_id'; + const fakeAxiosPost = sinon.fake.resolves({}); + overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); + const MockApp = await importApp(overrides); + + const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); + app.action(action_id, async ({ respond }) => { + await respond(responseText); + }); + app.error(fakeErrorHandler); + await fakeReceiver.sendEvent( + createDummyBlockActionEventMiddlewareArgs( + { + action: { + type: 'button', + action_id, + block_id: 'bid', + action_ts: '1', + text: { type: 'plain_text', text: 'hi' }, + }, + }, + { + response_url, + }, + ), + ); + + assert(fakeErrorHandler.notCalled); + assert.equal(fakeAxiosPost.callCount, 1); + // Assert that each call to fakeAxiosPost had the right arguments + sinon.assert.calledWith(fakeAxiosPost, response_url, { text: responseText }); + }); + + it('should respond with a response object', async () => { + const responseObject = { text: 'response' }; + const response_url = 'https://fake.slack/response_url'; + const action_id = 'block_action_id'; + const fakeAxiosPost = sinon.fake.resolves({}); + overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); + const MockApp = await importApp(overrides); + + const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); + app.action(action_id, async ({ respond }) => { + await respond(responseObject); + }); + app.error(fakeErrorHandler); + await fakeReceiver.sendEvent( + createDummyBlockActionEventMiddlewareArgs( + { + action: { + type: 'button', + action_id, + block_id: 'bid', + action_ts: '1', + text: { type: 'plain_text', text: 'hi' }, + }, + }, + { + response_url, + }, + ), + ); + + assert.equal(fakeAxiosPost.callCount, 1); + // Assert that each call to fakeAxiosPost had the right arguments + sinon.assert.calledWith(fakeAxiosPost, response_url, responseObject); + }); + it('should be able to use respond for view_submission payloads', async () => { + const responseObject = { text: 'response' }; + const responseUrl = 'https://fake.slack/response_url'; + const fakeAxiosPost = sinon.fake.resolves({}); + overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); + const MockApp = await importApp(overrides); + + const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); + app.view('view-id', async ({ respond }) => { + await respond(responseObject); + }); + app.error(fakeErrorHandler); + await fakeReceiver.sendEvent( + createDummyViewSubmissionMiddlewareArgs( + { + id: 'V111', + type: 'modal', + callback_id: 'view-id', + }, + { + response_urls: [ + { + block_id: 'b', + action_id: 'a', + channel_id: 'C111', + response_url: 'https://fake.slack/response_url', + }, + ], + }, + ), + ); + + assert.equal(fakeAxiosPost.callCount, 1); + // Assert that each call to fakeAxiosPost had the right arguments + assert(fakeAxiosPost.calledWith(responseUrl, responseObject)); + }); + }); + + describe('logger', () => { + it('should be available in middleware/listener args', async () => { + const MockApp = await importApp(overrides); + const fakeLogger = createFakeLogger(); + const app = new MockApp({ + logger: fakeLogger, + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + app.use(async ({ logger, body, next }) => { + logger.info(body); + await next(); + }); + + app.event('app_home_opened', async ({ logger, event }) => { + logger.debug(event); + }); + + const receiverEvents = [ + { + body: { + type: 'event_callback', + token: 'XXYYZZ', + team_id: 'TXXXXXXXX', + api_app_id: 'AXXXXXXXXX', + event: { + type: 'app_home_opened', + event_ts: '1234567890.123456', + user: 'UXXXXXXX1', + text: 'hello friends!', + tab: 'home', + view: {}, + }, + }, + respond: noop, + ack: noopVoid, + }, + ]; + + await Promise.all(receiverEvents.map((event) => fakeReceiver.sendEvent(event))); + + assert.isTrue(fakeLogger.info.called); + assert.isTrue(fakeLogger.debug.called); + }); + + it('should work in the case both logger and logLevel are given', async () => { + const MockApp = await importApp(overrides); + const fakeLogger = createFakeLogger(); + const app = new MockApp({ + logger: fakeLogger, + logLevel: LogLevel.DEBUG, + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + app.use(async ({ logger, body, next }) => { + logger.info(body); + await next(); + }); + + app.event('app_home_opened', async ({ logger, event }) => { + logger.debug(event); + }); + + const receiverEvents = [ + { + body: { + type: 'event_callback', + token: 'XXYYZZ', + team_id: 'TXXXXXXXX', + api_app_id: 'AXXXXXXXXX', + event: { + type: 'app_home_opened', + event_ts: '1234567890.123456', + user: 'UXXXXXXX1', + text: 'hello friends!', + tab: 'home', + view: {}, + }, + }, + respond: noop, + ack: noopVoid, + }, + ]; + + await Promise.all(receiverEvents.map((event) => fakeReceiver.sendEvent(event))); + + assert.isTrue(fakeLogger.info.called); + assert.isTrue(fakeLogger.debug.called); + assert.isTrue(fakeLogger.setLevel.called); + }); + }); + + describe('client', () => { + it('should be available in middleware/listener args', async () => { + const MockApp = await importApp( + mergeOverrides(withNoopAppMetadata(), withSuccessfulBotUserFetchingWebClient('B123', 'U123')), + ); + const tokens = ['xoxb-123', 'xoxp-456', 'xoxb-123']; + const app = new MockApp({ + receiver: fakeReceiver, + authorize: () => { + const token = tokens.pop(); + if (typeof token === 'undefined') { + return Promise.resolve({ botId: 'B123' }); + } + if (token.startsWith('xoxb-')) { + return Promise.resolve({ botToken: token, botId: 'B123' }); + } + return Promise.resolve({ userToken: token, botId: 'B123' }); + }, + }); + app.use(async ({ client, next }) => { + await client.auth.test(); + await next(); + }); + const clients: WebClient[] = []; + app.event('app_home_opened', async ({ client }) => { + clients.push(client); + await client.auth.test(); + }); + + const event = { + body: { + type: 'event_callback', + token: 'legacy', + team_id: 'T123', + api_app_id: 'A123', + event: { + type: 'app_home_opened', + event_ts: '123.123', + user: 'U123', + text: 'Hi there!', + tab: 'home', + view: {}, + }, + }, + respond: noop, + ack: noopVoid, + }; + const receiverEvents = [event, event, event]; + + await Promise.all(receiverEvents.map((evt) => fakeReceiver.sendEvent(evt))); + + assert.isUndefined(app.client.token); + assert.equal(clients[0].token, 'xoxb-123'); + assert.equal(clients[1].token, 'xoxp-456'); + assert.equal(clients[2].token, 'xoxb-123'); + assert.notEqual(clients[0], clients[1]); + assert.strictEqual(clients[0], clients[2]); + }); + + it("should be set to the global app client when authorization doesn't produce a token", async () => { + const MockApp = await importApp(); + const app = new MockApp({ + receiver: fakeReceiver, + authorize: noop, + ignoreSelf: false, + }); + const globalClient = app.client; + + let clientArg: WebClient | undefined; + app.use(async ({ client }) => { + clientArg = client; + }); + await fakeReceiver.sendEvent(createDummyReceiverEvent()); + + assert.equal(globalClient, clientArg); + }); + }); + + describe('say()', () => { + function createChannelContextualReceiverEvents(channelId: string): ReceiverEvent[] { + return [ + // IncomingEventType.Event with channel in payload + { + ...baseEvent, + body: { + event: { + channel: channelId, + }, + team_id: 'TEAM_ID', + }, + }, + // IncomingEventType.Event with channel in item + { + ...baseEvent, + body: { + event: { + item: { + channel: channelId, + }, + }, + team_id: 'TEAM_ID', + }, + }, + // IncomingEventType.Command + { + ...baseEvent, + body: { + command: '/COMMAND_NAME', + channel_id: channelId, + team_id: 'TEAM_ID', + }, + }, + // IncomingEventType.Action from block action, interactive message, or message action + { + ...baseEvent, + body: { + actions: [{}], + channel: { + id: channelId, + }, + user: { + id: 'USER_ID', + }, + team: { + id: 'TEAM_ID', + }, + }, + }, + // IncomingEventType.Action from dialog submission + { + ...baseEvent, + body: { + type: 'dialog_submission', + channel: { + id: channelId, + }, + user: { + id: 'USER_ID', + }, + team: { + id: 'TEAM_ID', + }, + }, + }, + ]; + } + describe('for events that should include say() utility', () => { + it('should send a simple message to a channel where the incoming event originates', async () => { + const fakePostMessage = sinon.fake.resolves({}); + overrides = buildOverrides([withPostMessage(fakePostMessage)]); + const MockApp = await importApp(overrides); + + const dummyMessage = 'test'; + const dummyReceiverEvents = createChannelContextualReceiverEvents(dummyChannelId); + + const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); + app.use(async (args) => { + // biome-ignore lint/suspicious/noExplicitAny: By definition, these events should all produce a say function, so we cast args.say into a SayFn + const say = (args as any).say as SayFn; + await say(dummyMessage); + }); + app.error(fakeErrorHandler); + await Promise.all(dummyReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); + + assert.equal(fakePostMessage.callCount, dummyReceiverEvents.length); + // Assert that each call to fakePostMessage had the right arguments + for (const call of fakePostMessage.getCalls()) { + const firstArg = call.args[0]; + assert.propertyVal(firstArg, 'text', dummyMessage); + assert.propertyVal(firstArg, 'channel', dummyChannelId); + } + assert(fakeErrorHandler.notCalled); + }); + + it('should send a complex message to a channel where the incoming event originates', async () => { + const fakePostMessage = sinon.fake.resolves({}); + overrides = buildOverrides([withPostMessage(fakePostMessage)]); + const MockApp = await importApp(overrides); + + const dummyMessage = { text: 'test' }; + const dummyReceiverEvents = createChannelContextualReceiverEvents(dummyChannelId); + + const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); + app.use(async (args) => { + // biome-ignore lint/suspicious/noExplicitAny: By definition, these events should all produce a say function, so we cast args.say into a SayFn + const say = (args as any).say as SayFn; + await say(dummyMessage); + }); + app.error(fakeErrorHandler); + await Promise.all(dummyReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); + + assert.equal(fakePostMessage.callCount, dummyReceiverEvents.length); + // Assert that each call to fakePostMessage had the right arguments + for (const call of fakePostMessage.getCalls()) { + const firstArg = call.args[0]; + assert.propertyVal(firstArg, 'channel', dummyChannelId); + assert.propertyVal(firstArg, 'text', dummyMessage.text); + } + assert(fakeErrorHandler.notCalled); + }); + }); + + describe('for events that should not include say() utility', () => { + function createReceiverEventsWithoutSay(channelId: string): ReceiverEvent[] { + return [ + // IncomingEventType.Options from block action + { + ...baseEvent, + body: { + type: 'block_suggestion', + channel: { + id: channelId, + }, + user: { + id: 'USER_ID', + }, + team: { + id: 'TEAM_ID', + }, + }, + }, + // IncomingEventType.Options from interactive message or dialog + { + ...baseEvent, + body: { + name: 'select_field_name', + channel: { + id: channelId, + }, + user: { + id: 'USER_ID', + }, + team: { + id: 'TEAM_ID', + }, + }, + }, + // IncomingEventType.Event without a channel context + { + ...baseEvent, + body: { + event: {}, + team_id: 'TEAM_ID', + }, + }, + ]; + } + + it("should not exist in the arguments on incoming events that don't support say", async () => { + overrides = buildOverrides([withNoopWebClient()]); + const MockApp = await importApp(overrides); + + const assertionAggregator = sinon.fake(); + const dummyReceiverEvents = createReceiverEventsWithoutSay(dummyChannelId); + + const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); + app.use(async (args) => { + assert.notProperty(args, 'say'); + // If the above assertion fails, then it would throw an AssertionError and the following line will not be + // called + assertionAggregator(); + }); + + await Promise.all(dummyReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); + + assert.equal(assertionAggregator.callCount, dummyReceiverEvents.length); + }); + + it("should handle failures through the App's global error handler", async () => { + const fakePostMessage = sinon.fake.rejects(new Error('fake error')); + overrides = buildOverrides([withPostMessage(fakePostMessage)]); + const MockApp = await importApp(overrides); + + const dummyMessage = { text: 'test' }; + const dummyReceiverEvents = createChannelContextualReceiverEvents(dummyChannelId); + + const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); + app.use(async (args) => { + // biome-ignore lint/suspicious/noExplicitAny: By definition, these events should all produce a say function, so we cast args.say into a SayFn + const say = (args as any).say as SayFn; + await say(dummyMessage); + }); + app.error(fakeErrorHandler); + await Promise.all(dummyReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); + + assert.equal(fakeErrorHandler.callCount, dummyReceiverEvents.length); + }); + }); + }); + + describe('ack()', () => { + it('should be available in middleware/listener args', async () => { + const MockApp = await importApp(overrides); + const fakeLogger = createFakeLogger(); + const app = new MockApp({ + logger: fakeLogger, + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + app.use(async ({ ack, next }) => { + if (ack) { + // this should be called even if app.view listeners do not exist + await ack(); + return; + } + fakeLogger.info('Events API'); + await next(); + }); + + app.event('app_home_opened', async ({ logger, event }) => { + logger.debug(event); + }); + + let ackInMiddlewareCalled = false; + + const receiverEvents = [ + { + body: { + type: 'event_callback', + token: 'XXYYZZ', + team_id: 'TXXXXXXXX', + api_app_id: 'AXXXXXXXXX', + event: { + type: 'app_home_opened', + event_ts: '1234567890.123456', + user: 'UXXXXXXX1', + text: 'hello friends!', + tab: 'home', + view: {}, + }, + }, + respond: noop, + ack: noopVoid, + }, + { + body: { + type: 'view_submission', + team: {}, + user: {}, + view: { + id: 'V111', + type: 'modal', + callback_id: 'view-id', + state: {}, + title: {}, + close: {}, + submit: {}, + }, + }, + respond: noop, + ack: async () => { + ackInMiddlewareCalled = true; + }, + }, + ]; + + await Promise.all(receiverEvents.map((event) => fakeReceiver.sendEvent(event))); + + assert.isTrue(fakeLogger.info.called); + assert.isTrue(ackInMiddlewareCalled); + }); + }); + + describe('context', () => { + it('should be able to use the app_installed_team_id when provided by the payload', async () => { + const fakeAxiosPost = sinon.fake.resolves({}); + overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); + const MockApp = await importApp(overrides); + const callback_id = 'view-id'; + const app_installed_team_id = 'T-installed-workspace'; + + const app = new MockApp({ + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + + let ackCalled = false; + app.view(callback_id, async ({ ack, context, view }) => { + assert.equal(context.teamId, app_installed_team_id); + assert.notEqual(view.team_id, app_installed_team_id); + await ack(); + ackCalled = true; + }); + app.error(fakeErrorHandler); + + await fakeReceiver.sendEvent( + createDummyViewSubmissionMiddlewareArgs({ + callback_id, + app_installed_team_id, + }), + ); + + assert.isTrue(ackCalled); + }); + }); + }); +}); diff --git a/test/unit/App/routing-action.spec.ts b/test/unit/App/routing-action.spec.ts new file mode 100644 index 000000000..53c0db7f0 --- /dev/null +++ b/test/unit/App/routing-action.spec.ts @@ -0,0 +1,81 @@ +import sinon, { type SinonSpy } from 'sinon'; +import type App from '../../../src/App'; +import { + FakeReceiver, + type Override, + createDummyBlockActionEventMiddlewareArgs, + createFakeLogger, + importApp, + mergeOverrides, + noopMiddleware, + withConversationContext, + withMemoryStore, + withNoopAppMetadata, + withNoopWebClient, +} from '../helpers'; + +function buildOverrides(secondOverrides: Override[]): Override { + return mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + ...secondOverrides, + withMemoryStore(sinon.fake()), + withConversationContext(sinon.fake.returns(noopMiddleware)), + ); +} + +describe('App action() routing', () => { + let fakeReceiver: FakeReceiver; + let fakeHandler: SinonSpy; + const fakeLogger = createFakeLogger(); + let dummyAuthorizationResult: { botToken: string; botId: string }; + let MockApp: Awaited>; + let app: App; + + beforeEach(async () => { + fakeLogger.error.reset(); + fakeReceiver = new FakeReceiver(); + fakeHandler = sinon.fake(); + dummyAuthorizationResult = { botToken: '', botId: '' }; + MockApp = await importApp(buildOverrides([])); + app = new MockApp({ + logger: fakeLogger, + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + }); + + it('should route a block action event to a handler registered with `action(string)` that matches the action ID', async () => { + app.action('my_id', fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyBlockActionEventMiddlewareArgs({ action_id: 'my_id' }), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a block action event to a handler registered with `action(RegExp)` that matches the action ID', async () => { + app.action(/my_action/, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyBlockActionEventMiddlewareArgs({ action_id: 'my_action' }), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a block action event to a handler registered with `action({block_id})` that matches the block ID', async () => { + app.action({ block_id: 'my_id' }, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyBlockActionEventMiddlewareArgs({ block_id: 'my_id' }), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a block action event to a handler registered with `action({type:block_actions})`', async () => { + app.action({ type: 'block_actions' }, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyBlockActionEventMiddlewareArgs(), + }); + sinon.assert.called(fakeHandler); + }); + it('should throw if provided a constraint with unknown action constraint keys', async () => { + // @ts-ignore providing known invalid action constraint parameter + app.action({ id: 'boom' }, fakeHandler); + sinon.assert.calledWithMatch(fakeLogger.error, 'unknown constraint keys'); + }); +}); diff --git a/test/unit/App/routing-command.spec.ts b/test/unit/App/routing-command.spec.ts new file mode 100644 index 000000000..8c6315685 --- /dev/null +++ b/test/unit/App/routing-command.spec.ts @@ -0,0 +1,63 @@ +import sinon, { type SinonSpy } from 'sinon'; +import type App from '../../../src/App'; +import { + FakeReceiver, + type Override, + createDummyCommandMiddlewareArgs, + createFakeLogger, + importApp, + mergeOverrides, + noopMiddleware, + noopVoid, + withConversationContext, + withMemoryStore, + withNoopAppMetadata, + withNoopWebClient, +} from '../helpers'; + +function buildOverrides(secondOverrides: Override[]): Override { + return mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + ...secondOverrides, + withMemoryStore(sinon.fake()), + withConversationContext(sinon.fake.returns(noopMiddleware)), + ); +} + +describe('App command() routing', () => { + let fakeReceiver: FakeReceiver; + let fakeHandler: SinonSpy; + let dummyAuthorizationResult: { botToken: string; botId: string }; + let MockApp: Awaited>; + let app: App; + + beforeEach(async () => { + fakeReceiver = new FakeReceiver(); + fakeHandler = sinon.fake(); + dummyAuthorizationResult = { botToken: '', botId: '' }; + MockApp = await importApp(buildOverrides([])); + app = new MockApp({ + logger: createFakeLogger(), + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + }); + + it('should route a command to a handler registered with `command(string)` if command name matches', async () => { + app.command('/yo', fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyCommandMiddlewareArgs({ command: '/yo' }), + ack: noopVoid, + }); + sinon.assert.called(fakeHandler); + }); + it('should route a command to a handler registered with `command(RegExp)` if comand name matches', async () => { + app.command(/hi/, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyCommandMiddlewareArgs({ command: '/hiya' }), + ack: noopVoid, + }); + sinon.assert.called(fakeHandler); + }); +}); diff --git a/test/unit/App/routing-event.spec.ts b/test/unit/App/routing-event.spec.ts new file mode 100644 index 000000000..f9680fa57 --- /dev/null +++ b/test/unit/App/routing-event.spec.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert'; +import sinon, { type SinonSpy } from 'sinon'; +import type App from '../../../src/App'; +import { + FakeReceiver, + type Override, + createDummyAppMentionEventMiddlewareArgs, + createFakeLogger, + importApp, + mergeOverrides, + noopMiddleware, + noopVoid, + withConversationContext, + withMemoryStore, + withNoopAppMetadata, + withNoopWebClient, +} from '../helpers'; + +function buildOverrides(secondOverrides: Override[]): Override { + return mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + ...secondOverrides, + withMemoryStore(sinon.fake()), + withConversationContext(sinon.fake.returns(noopMiddleware)), + ); +} + +describe('App event() routing', () => { + let fakeReceiver: FakeReceiver; + let fakeHandler: SinonSpy; + let dummyAuthorizationResult: { botToken: string; botId: string }; + let MockApp: Awaited>; + let app: App; + + beforeEach(async () => { + fakeReceiver = new FakeReceiver(); + fakeHandler = sinon.fake(); + dummyAuthorizationResult = { botToken: '', botId: '' }; + MockApp = await importApp(buildOverrides([])); + app = new MockApp({ + logger: createFakeLogger(), + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + }); + + it('should route a Slack event to a handler registered with `event(string)`', async () => { + app.event('app_mention', fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyAppMentionEventMiddlewareArgs(), + ack: noopVoid, + }); + sinon.assert.called(fakeHandler); + }); + it('should route a Slack event to a handler registered with `event(RegExp)`', async () => { + app.event(/app_mention/, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyAppMentionEventMiddlewareArgs(), + ack: noopVoid, + }); + sinon.assert.called(fakeHandler); + }); + it('should throw if provided invalid message subtype event names', async () => { + app.event('app_mention', async () => {}); + app.event('message', async () => {}); + assert.throws(() => app.event('message.channels', async () => {})); + assert.throws(() => app.event(/message\..+/, async () => {})); + }); +}); diff --git a/test/unit/App/routing-message.spec.ts b/test/unit/App/routing-message.spec.ts new file mode 100644 index 000000000..85b00c0cb --- /dev/null +++ b/test/unit/App/routing-message.spec.ts @@ -0,0 +1,63 @@ +import sinon, { type SinonSpy } from 'sinon'; +import type App from '../../../src/App'; +import { + FakeReceiver, + type Override, + createDummyMessageEventMiddlewareArgs, + createFakeLogger, + importApp, + mergeOverrides, + noopMiddleware, + noopVoid, + withConversationContext, + withMemoryStore, + withNoopAppMetadata, + withNoopWebClient, +} from '../helpers'; + +function buildOverrides(secondOverrides: Override[]): Override { + return mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + ...secondOverrides, + withMemoryStore(sinon.fake()), + withConversationContext(sinon.fake.returns(noopMiddleware)), + ); +} + +describe('App message() routing', () => { + let fakeReceiver: FakeReceiver; + let fakeHandler: SinonSpy; + let dummyAuthorizationResult: { botToken: string; botId: string }; + let MockApp: Awaited>; + let app: App; + + beforeEach(async () => { + fakeReceiver = new FakeReceiver(); + fakeHandler = sinon.fake(); + dummyAuthorizationResult = { botToken: '', botId: '' }; + MockApp = await importApp(buildOverrides([])); + app = new MockApp({ + logger: createFakeLogger(), + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + }); + + it('should route a message event to a handler registered with `message(string)` if message contents match', async () => { + app.message('yo', fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyMessageEventMiddlewareArgs({ text: 'yo' }), + ack: noopVoid, + }); + sinon.assert.called(fakeHandler); + }); + it('should route a message event to a handler registered with `message(RegExp)` if message contents match', async () => { + app.message(/hi/, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyMessageEventMiddlewareArgs({ text: 'hiya' }), + ack: noopVoid, + }); + sinon.assert.called(fakeHandler); + }); +}); diff --git a/test/unit/App/routing-options.spec.ts b/test/unit/App/routing-options.spec.ts new file mode 100644 index 000000000..af23355d9 --- /dev/null +++ b/test/unit/App/routing-options.spec.ts @@ -0,0 +1,76 @@ +import sinon, { type SinonSpy } from 'sinon'; +import type App from '../../../src/App'; +import { + FakeReceiver, + type Override, + createDummyBlockSuggestionsMiddlewareArgs, + createFakeLogger, + importApp, + mergeOverrides, + noopMiddleware, + withConversationContext, + withMemoryStore, + withNoopAppMetadata, + withNoopWebClient, +} from '../helpers'; + +function buildOverrides(secondOverrides: Override[]): Override { + return mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + ...secondOverrides, + withMemoryStore(sinon.fake()), + withConversationContext(sinon.fake.returns(noopMiddleware)), + ); +} + +describe('App options() routing', () => { + let fakeReceiver: FakeReceiver; + let fakeHandler: SinonSpy; + const fakeLogger = createFakeLogger(); + let dummyAuthorizationResult: { botToken: string; botId: string }; + let MockApp: Awaited>; + let app: App; + + beforeEach(async () => { + fakeLogger.error.reset(); + fakeReceiver = new FakeReceiver(); + fakeHandler = sinon.fake(); + dummyAuthorizationResult = { botToken: '', botId: '' }; + MockApp = await importApp(buildOverrides([])); + app = new MockApp({ + logger: fakeLogger, + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + }); + + it('should route a block suggestion event to a handler registered with `options(string)` that matches the action ID', async () => { + app.options('my_id', fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyBlockSuggestionsMiddlewareArgs({ action_id: 'my_id' }), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a block suggestion event to a handler registered with `options(RegExp)` that matches the action ID', async () => { + app.options(/my_action/, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyBlockSuggestionsMiddlewareArgs({ action_id: 'my_action' }), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a block suggestion event to a handler registered with `options({block_id})` that matches the block ID', async () => { + app.options({ block_id: 'my_id' }, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyBlockSuggestionsMiddlewareArgs({ block_id: 'my_id' }), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a block suggestion event to a handler registered with `options({type:block_suggestion})`', async () => { + app.options({ type: 'block_suggestion' }, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyBlockSuggestionsMiddlewareArgs(), + }); + sinon.assert.called(fakeHandler); + }); +}); diff --git a/test/unit/App/routing-shortcut.spec.ts b/test/unit/App/routing-shortcut.spec.ts new file mode 100644 index 000000000..8accb6aeb --- /dev/null +++ b/test/unit/App/routing-shortcut.spec.ts @@ -0,0 +1,88 @@ +import sinon, { type SinonSpy } from 'sinon'; +import type App from '../../../src/App'; +import { + FakeReceiver, + type Override, + createDummyMessageShortcutMiddlewareArgs, + createFakeLogger, + importApp, + mergeOverrides, + noopMiddleware, + withConversationContext, + withMemoryStore, + withNoopAppMetadata, + withNoopWebClient, +} from '../helpers'; + +function buildOverrides(secondOverrides: Override[]): Override { + return mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + ...secondOverrides, + withMemoryStore(sinon.fake()), + withConversationContext(sinon.fake.returns(noopMiddleware)), + ); +} + +describe('App shortcut() routing', () => { + let fakeReceiver: FakeReceiver; + let fakeHandler: SinonSpy; + const fakeLogger = createFakeLogger(); + let dummyAuthorizationResult: { botToken: string; botId: string }; + let MockApp: Awaited>; + let app: App; + + beforeEach(async () => { + fakeLogger.error.reset(); + fakeReceiver = new FakeReceiver(); + fakeHandler = sinon.fake(); + dummyAuthorizationResult = { botToken: '', botId: '' }; + MockApp = await importApp(buildOverrides([])); + app = new MockApp({ + logger: fakeLogger, + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + }); + + it('should route a Slack shortcut event to a handler registered with `shortcut(string)` that matches the callback ID', async () => { + app.shortcut('my_callback_id', fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyMessageShortcutMiddlewareArgs('my_callback_id'), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a Slack shortcut event to a handler registered with `shortcut(RegExp)` that matches the callback ID', async () => { + app.shortcut(/my_call/, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyMessageShortcutMiddlewareArgs('my_callback_id'), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a Slack shortcut event to a handler registered with `shortcut({callback_id})` that matches the callback ID', async () => { + app.shortcut({ callback_id: 'my_callback_id' }, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyMessageShortcutMiddlewareArgs('my_callback_id'), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a Slack shortcut event to a handler registered with `shortcut({type})` that matches the type', async () => { + app.shortcut({ type: 'message_action' }, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyMessageShortcutMiddlewareArgs(), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a Slack shortcut event to a handler registered with `shortcut({type, callback_id})` that matches both the type and the callback_id', async () => { + app.shortcut({ type: 'message_action', callback_id: 'my_id' }, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyMessageShortcutMiddlewareArgs('my_id'), + }); + sinon.assert.called(fakeHandler); + }); + it('should throw if provided a constraint with unknown shortcut constraint keys', async () => { + // @ts-ignore providing known invalid shortcut constraint parameter + app.shortcut({ id: 'boom' }, fakeHandler); + sinon.assert.calledWithMatch(fakeLogger.error, 'unknown constraint keys'); + }); +}); diff --git a/test/unit/App/routing-view.spec.ts b/test/unit/App/routing-view.spec.ts new file mode 100644 index 000000000..983c6d2ac --- /dev/null +++ b/test/unit/App/routing-view.spec.ts @@ -0,0 +1,101 @@ +import sinon, { type SinonSpy } from 'sinon'; +import type App from '../../../src/App'; +import { + FakeReceiver, + type Override, + createDummyViewClosedMiddlewareArgs, + createDummyViewSubmissionMiddlewareArgs, + createFakeLogger, + importApp, + mergeOverrides, + noopMiddleware, + withConversationContext, + withMemoryStore, + withNoopAppMetadata, + withNoopWebClient, +} from '../helpers'; + +function buildOverrides(secondOverrides: Override[]): Override { + return mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + ...secondOverrides, + withMemoryStore(sinon.fake()), + withConversationContext(sinon.fake.returns(noopMiddleware)), + ); +} + +describe('App view() routing', () => { + let fakeReceiver: FakeReceiver; + let fakeHandler: SinonSpy; + const fakeLogger = createFakeLogger(); + let dummyAuthorizationResult: { botToken: string; botId: string }; + let MockApp: Awaited>; + let app: App; + + beforeEach(async () => { + fakeLogger.error.reset(); + fakeReceiver = new FakeReceiver(); + fakeHandler = sinon.fake(); + dummyAuthorizationResult = { botToken: '', botId: '' }; + MockApp = await importApp(buildOverrides([])); + app = new MockApp({ + logger: fakeLogger, + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + }); + + it('should throw if provided a constraint with unknown view constraint keys', async () => { + // @ts-ignore providing known invalid view constraint parameter + app.view({ id: 'boom' }, fakeHandler); + sinon.assert.calledWithMatch(fakeLogger.error, 'unknown constraint keys'); + }); + describe('for view submission events', () => { + it('should route a view submission event to a handler registered with `view(string)` that matches the callback ID', async () => { + app.view('my_id', fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyViewSubmissionMiddlewareArgs({ callback_id: 'my_id' }), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a view submission event to a handler registered with `view(RegExp)` that matches the callback ID', async () => { + app.view(/my_action/, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyViewSubmissionMiddlewareArgs({ callback_id: 'my_action' }), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a view submission event to a handler registered with `view({callback_id})` that matches callback ID', async () => { + app.view({ callback_id: 'my_id' }, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyViewSubmissionMiddlewareArgs({ callback_id: 'my_id' }), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a view submission event to a handler registered with `view({type:view_submission})`', async () => { + app.view({ type: 'view_submission' }, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyViewSubmissionMiddlewareArgs(), + }); + sinon.assert.called(fakeHandler); + }); + }); + + describe('for view closed events', () => { + it('should route a view closed event to a handler registered with `view({callback_id, type:view_closed})` that matches callback ID', async () => { + app.view({ callback_id: 'my_id', type: 'view_closed' }, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyViewClosedMiddlewareArgs({ callback_id: 'my_id', type: 'view_closed' }), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a view closed event to a handler registered with `view({type:view_closed})`', async () => { + app.view({ type: 'view_closed' }, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyViewClosedMiddlewareArgs(), + }); + sinon.assert.called(fakeHandler); + }); + }); +}); diff --git a/src/CustomFunction.spec.ts b/test/unit/CustomFunction.spec.ts similarity index 89% rename from src/CustomFunction.spec.ts rename to test/unit/CustomFunction.spec.ts index 24dc00059..13cebea2f 100644 --- a/src/CustomFunction.spec.ts +++ b/test/unit/CustomFunction.spec.ts @@ -1,21 +1,20 @@ -import 'mocha'; +import { WebClient } from '@slack/web-api'; import { assert } from 'chai'; -import sinon from 'sinon'; import rewiremock from 'rewiremock'; -import { WebClient } from '@slack/web-api'; +import sinon from 'sinon'; import { + type AllCustomFunctionMiddlewareArgs, CustomFunction, - SlackCustomFunctionMiddlewareArgs, - AllCustomFunctionMiddlewareArgs, - CustomFunctionMiddleware, - CustomFunctionExecuteMiddlewareArgs, -} from './CustomFunction'; -import { createFakeLogger, Override } from './test-helpers'; -import { AllMiddlewareArgs, Middleware } from './types'; -import { CustomFunctionInitializationError } from './errors'; - -async function importCustomFunction(overrides: Override = {}): Promise { - return rewiremock.module(() => import('./CustomFunction'), overrides); + type CustomFunctionExecuteMiddlewareArgs, + type CustomFunctionMiddleware, + type SlackCustomFunctionMiddlewareArgs, +} from '../../src/CustomFunction'; +import { CustomFunctionInitializationError } from '../../src/errors'; +import type { AllMiddlewareArgs, Middleware } from '../../src/types'; +import { type Override, createFakeLogger } from './helpers'; + +async function importCustomFunction(overrides: Override = {}): Promise { + return rewiremock.module(() => import('../../src/CustomFunction'), overrides); } const MOCK_FN = async () => {}; @@ -68,8 +67,7 @@ describe('CustomFunction class', () => { it('should call next if not a function executed event', async () => { const fn = new CustomFunction('test_view_callback_id', MOCK_MIDDLEWARE_SINGLE, {}); const middleware = fn.getMiddleware(); - const fakeViewArgs = createFakeViewEvent() as unknown as - SlackCustomFunctionMiddlewareArgs & AllMiddlewareArgs; + const fakeViewArgs = createFakeViewEvent() as unknown as SlackCustomFunctionMiddlewareArgs & AllMiddlewareArgs; const fakeNext = sinon.spy(); fakeViewArgs.next = fakeNext; @@ -107,10 +105,7 @@ describe('CustomFunction class', () => { const { validate } = await importCustomFunction(); // intentionally casting to CustomFunctionMiddleware to trigger failure - const badMiddleware = [ - async () => {}, - 'not-a-function', - ] as unknown as CustomFunctionMiddleware; + const badMiddleware = [async () => {}, 'not-a-function'] as unknown as CustomFunctionMiddleware; const validationFn = () => validate('callback_id', badMiddleware); const expectedMsg = 'All CustomFunction middleware must be functions'; @@ -167,7 +162,10 @@ describe('CustomFunction class', () => { it('complete should call functions.completeSuccess', async () => { const client = new WebClient('sometoken'); const completeMock = sinon.stub(client.functions, 'completeSuccess').resolves(); - const complete = CustomFunction.createFunctionComplete({ isEnterpriseInstall: false, functionExecutionId: 'Fx1234' }, client); + const complete = CustomFunction.createFunctionComplete( + { isEnterpriseInstall: false, functionExecutionId: 'Fx1234' }, + client, + ); await complete(); assert(completeMock.called, 'client.functions.completeSuccess not called!'); }); @@ -183,7 +181,10 @@ describe('CustomFunction class', () => { it('fail should call functions.completeError', async () => { const client = new WebClient('sometoken'); const completeMock = sinon.stub(client.functions, 'completeError').resolves(); - const complete = CustomFunction.createFunctionFail({ isEnterpriseInstall: false, functionExecutionId: 'Fx1234' }, client); + const complete = CustomFunction.createFunctionFail( + { isEnterpriseInstall: false, functionExecutionId: 'Fx1234' }, + client, + ); await complete({ error: 'boom' }); assert(completeMock.called, 'client.functions.completeError not called!'); }); @@ -213,8 +214,7 @@ describe('CustomFunction class', () => { const fn1 = sinon.spy((async ({ next: continuation }) => { await continuation(); }) as Middleware); - const fn2 = sinon.spy(async () => { - }); + const fn2 = sinon.spy(async () => {}); const fakeMiddleware = [fn1, fn2] as CustomFunctionMiddleware; await processFunctionMiddleware(fakeArgs, fakeMiddleware); @@ -272,14 +272,12 @@ function createFakeFunctionExecutedEvent(callbackId?: string): AllCustomFunction fail: () => Promise.resolve({ ok: true }), inputs, logger: createFakeLogger(), - message: undefined, next: () => Promise.resolve(), payload: { function: func, inputs: { message: 'test123', recipient: 'U012345' }, ...base, }, - say: undefined, }; } diff --git a/src/WorkflowStep.spec.ts b/test/unit/WorkflowStep.spec.ts similarity index 86% rename from src/WorkflowStep.spec.ts rename to test/unit/WorkflowStep.spec.ts index df9440f24..700a89a70 100644 --- a/src/WorkflowStep.spec.ts +++ b/test/unit/WorkflowStep.spec.ts @@ -1,38 +1,35 @@ -import 'mocha'; +import type { WebClient } from '@slack/web-api'; import { assert } from 'chai'; -import sinon from 'sinon'; import rewiremock from 'rewiremock'; -import { WebClient } from '@slack/web-api'; +import sinon from 'sinon'; import { + type AllWorkflowStepMiddlewareArgs, + type SlackWorkflowStepMiddlewareArgs, WorkflowStep, - SlackWorkflowStepMiddlewareArgs, - AllWorkflowStepMiddlewareArgs, - WorkflowStepMiddleware, - WorkflowStepConfig, - WorkflowStepEditMiddlewareArgs, - WorkflowStepSaveMiddlewareArgs, - WorkflowStepExecuteMiddlewareArgs, -} from './WorkflowStep'; -import { Override } from './test-helpers'; -import { AllMiddlewareArgs, AnyMiddlewareArgs, WorkflowStepEdit, Middleware } from './types'; -import { WorkflowStepInitializationError } from './errors'; - -async function importWorkflowStep(overrides: Override = {}): Promise { - return rewiremock.module(() => import('./WorkflowStep'), overrides); + type WorkflowStepConfig, + type WorkflowStepEditMiddlewareArgs, + type WorkflowStepExecuteMiddlewareArgs, + type WorkflowStepMiddleware, + type WorkflowStepSaveMiddlewareArgs, +} from '../../src/WorkflowStep'; +import { WorkflowStepInitializationError } from '../../src/errors'; +import type { AllMiddlewareArgs, AnyMiddlewareArgs, Middleware, WorkflowStepEdit } from '../../src/types'; +import { type Override, noopVoid } from './helpers'; + +async function importWorkflowStep(overrides: Override = {}): Promise { + return rewiremock.module(() => import('../../src/WorkflowStep'), overrides); } -const MOCK_FN = async () => {}; - const MOCK_CONFIG_SINGLE = { - edit: MOCK_FN, - save: MOCK_FN, - execute: MOCK_FN, + edit: noopVoid, + save: noopVoid, + execute: noopVoid, }; const MOCK_CONFIG_MULTIPLE = { - edit: [MOCK_FN, MOCK_FN], - save: [MOCK_FN], - execute: [MOCK_FN, MOCK_FN, MOCK_FN], + edit: [noopVoid, noopVoid], + save: [noopVoid], + execute: [noopVoid, noopVoid, noopVoid], }; describe('WorkflowStep class', () => { @@ -145,8 +142,8 @@ describe('WorkflowStep class', () => { it('should return true if recognized workflow step payload type', async () => { const fakeEditArgs = createFakeStepEditAction() as unknown as SlackWorkflowStepMiddlewareArgs & AllMiddlewareArgs; const fakeSaveArgs = createFakeStepSaveEvent() as unknown as SlackWorkflowStepMiddlewareArgs & AllMiddlewareArgs; - const fakeExecuteArgs = createFakeStepExecuteEvent() as unknown as SlackWorkflowStepMiddlewareArgs - & AllMiddlewareArgs; + const fakeExecuteArgs = createFakeStepExecuteEvent() as unknown as SlackWorkflowStepMiddlewareArgs & + AllMiddlewareArgs; const { isStepEvent } = await importWorkflowStep(); @@ -174,7 +171,8 @@ describe('WorkflowStep class', () => { it('should remove next() from all original event args', async () => { const fakeEditArgs = createFakeStepEditAction() as unknown as SlackWorkflowStepMiddlewareArgs & AllMiddlewareArgs; const fakeSaveArgs = createFakeStepSaveEvent() as unknown as SlackWorkflowStepMiddlewareArgs & AllMiddlewareArgs; - const fakeExecuteArgs = createFakeStepExecuteEvent() as unknown as SlackWorkflowStepMiddlewareArgs & AllMiddlewareArgs; // eslint-disable-line max-len + const fakeExecuteArgs = createFakeStepExecuteEvent() as unknown as SlackWorkflowStepMiddlewareArgs & + AllMiddlewareArgs; const { prepareStepArgs } = await importWorkflowStep(); @@ -191,31 +189,35 @@ describe('WorkflowStep class', () => { const fakeArgs = createFakeStepEditAction(); const { prepareStepArgs } = await importWorkflowStep(); // casting to returned type because prepareStepArgs isn't built to do so - const stepArgs = prepareStepArgs(fakeArgs) as AllWorkflowStepMiddlewareArgs; + const stepArgs = prepareStepArgs(fakeArgs as AllWorkflowStepMiddlewareArgs); assert.exists(stepArgs.step); - assert.exists(stepArgs.configure); + assert.property(stepArgs, 'configure'); }); it('should augment view_submission with step and update()', async () => { const fakeArgs = createFakeStepSaveEvent(); const { prepareStepArgs } = await importWorkflowStep(); // casting to returned type because prepareStepArgs isn't built to do so - const stepArgs = prepareStepArgs(fakeArgs) as AllWorkflowStepMiddlewareArgs; + const stepArgs = prepareStepArgs( + fakeArgs as unknown as AllWorkflowStepMiddlewareArgs, + ); assert.exists(stepArgs.step); - assert.exists(stepArgs.update); + assert.property(stepArgs, 'update'); }); it('should augment workflow_step_execute with step, complete() and fail()', async () => { const fakeArgs = createFakeStepExecuteEvent(); const { prepareStepArgs } = await importWorkflowStep(); // casting to returned type because prepareStepArgs isn't built to do so - const stepArgs = prepareStepArgs(fakeArgs) as AllWorkflowStepMiddlewareArgs; + const stepArgs = prepareStepArgs( + fakeArgs as unknown as AllWorkflowStepMiddlewareArgs, + ); assert.exists(stepArgs.step); - assert.exists(stepArgs.complete); - assert.exists(stepArgs.fail); + assert.property(stepArgs, 'complete'); + assert.property(stepArgs, 'fail'); }); }); @@ -255,7 +257,8 @@ describe('WorkflowStep class', () => { }); it('complete should call workflows.stepCompleted', async () => { - const fakeExecuteArgs = createFakeStepExecuteEvent() as unknown as SlackWorkflowStepMiddlewareArgs & AllMiddlewareArgs; // eslint-disable-line max-len + const fakeExecuteArgs = createFakeStepExecuteEvent() as unknown as SlackWorkflowStepMiddlewareArgs & + AllMiddlewareArgs; // eslint-disable-line max-len const fakeClient = { workflows: { stepCompleted: sinon.spy() } }; fakeExecuteArgs.client = fakeClient as unknown as WebClient; @@ -272,7 +275,8 @@ describe('WorkflowStep class', () => { }); it('fail should call workflows.stepFailed', async () => { - const fakeExecuteArgs = createFakeStepExecuteEvent() as unknown as SlackWorkflowStepMiddlewareArgs & AllMiddlewareArgs; // eslint-disable-line max-len + const fakeExecuteArgs = createFakeStepExecuteEvent() as unknown as SlackWorkflowStepMiddlewareArgs & + AllMiddlewareArgs; // eslint-disable-line max-len const fakeClient = { workflows: { stepFailed: sinon.spy() } }; fakeExecuteArgs.client = fakeClient as unknown as WebClient; @@ -308,6 +312,9 @@ describe('WorkflowStep class', () => { }); }); +// TODO: need middleware test utilities like wrapping in AllMiddleWareArgs (creating say, respond, context) +// same for other kinds of middleware +// this stuff probably already exists function createFakeStepEditAction() { return { body: { diff --git a/src/conversation-store.spec.ts b/test/unit/conversation-store.spec.ts similarity index 81% rename from src/conversation-store.spec.ts rename to test/unit/conversation-store.spec.ts index 624cb354f..1f7fe6d93 100644 --- a/src/conversation-store.spec.ts +++ b/test/unit/conversation-store.spec.ts @@ -1,13 +1,51 @@ -import 'mocha'; +import type { Logger } from '@slack/logger'; +import type { WebClient } from '@slack/web-api'; import { assert, AssertionError } from 'chai'; -import sinon, { SinonSpy } from 'sinon'; import rewiremock from 'rewiremock'; -import { Logger } from '@slack/logger'; -import { WebClient } from '@slack/web-api'; -import { Override, createFakeLogger, delay } from './test-helpers'; -import { ConversationStore } from './conversation-store'; -import { AnyMiddlewareArgs, NextFn, Context } from './types'; +import sinon, { type SinonSpy } from 'sinon'; +import type { AnyMiddlewareArgs, Context, NextFn } from '../../src/types'; +import { type Override, createFakeLogger, delay } from './helpers'; +/* Testing Harness */ + +type MiddlewareArgs = AnyMiddlewareArgs & { + next: NextFn; + context: Context; + logger: Logger; + client: WebClient; +}; + +interface DummyContext { + conversation?: ConversationState; + updateConversation?: (c: ConversationState, expiresAt?: number) => Promise; +} + +// Loading the system under test using overrides +async function importConversationStore( + overrides: Override = {}, +): Promise { + return rewiremock.module(() => import('../../src/conversation-store'), overrides); +} + +// Composable overrides +function withGetTypeAndConversation(spy: SinonSpy): Override { + return { + './helpers': { + getTypeAndConversation: spy, + }, + }; +} + +// Fakes +function createFakeStore( + getSpy: SinonSpy = sinon.fake.resolves(undefined), + setSpy: SinonSpy = sinon.fake.resolves({}), +) { + return { + set: setSpy, + get: getSpy, + }; +} describe('conversationContext middleware', () => { it('should forward events that have no conversation ID', async () => { // Arrange @@ -198,7 +236,7 @@ describe('MemoryStore', () => { try { await store.get('CONVERSATION_ID'); assert.fail(); - } catch (error: any) { + } catch (error) { // Assert assert.instanceOf(error, Error); assert.notInstanceOf(error, AssertionError); @@ -219,7 +257,7 @@ describe('MemoryStore', () => { try { await store.get(dummyConversationId); assert.fail(); - } catch (error: any) { + } catch (error) { // Assert assert.instanceOf(error, Error); assert.notInstanceOf(error, AssertionError); @@ -227,57 +265,3 @@ describe('MemoryStore', () => { }); }); }); - -/* Testing Harness */ - -type MiddlewareArgs = AnyMiddlewareArgs & { - next: NextFn; - context: Context; - logger: Logger; - client: WebClient; -}; - -interface DummyContext { - conversation?: ConversationState; - updateConversation?: (c: ConversationState, expiresAt?: number) => Promise; -} - -// Loading the system under test using overrides -async function importConversationStore(overrides: Override = {}): Promise { - return rewiremock.module(() => import('./conversation-store'), overrides); -} - -// Composable overrides -function withGetTypeAndConversation(spy: SinonSpy): Override { - return { - './helpers': { - getTypeAndConversation: spy, - }, - }; -} - -// Fakes -interface FakeStore extends ConversationStore { - set: SinonSpy, ReturnType>; - get: SinonSpy, ReturnType>; -} - -function createFakeStore( - getSpy: SinonSpy = sinon.fake.resolves(undefined), - setSpy: SinonSpy = sinon.fake.resolves({}), -): 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. - // Types of property 'firstCall' are incompatible. - // 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>, - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - set: setSpy as unknown as SinonSpy, ReturnType>, - get: getSpy as unknown as SinonSpy, ReturnType>, - }; -} diff --git a/src/errors.spec.ts b/test/unit/errors.spec.ts similarity index 91% rename from src/errors.spec.ts rename to test/unit/errors.spec.ts index 7667e26b3..ec907eaa2 100644 --- a/src/errors.spec.ts +++ b/test/unit/errors.spec.ts @@ -1,15 +1,15 @@ import { assert } from 'chai'; import { - asCodedError, - ErrorCode, - CodedError, AppInitializationError, AuthorizationError, + type CodedError, ContextMissingPropertyError, + ErrorCode, ReceiverAuthenticityError, ReceiverMultipleAckError, UnknownError, -} from './errors'; + asCodedError, +} from '../../src/errors'; describe('Errors', () => { it('has errors matching codes', () => { @@ -22,9 +22,9 @@ describe('Errors', () => { [ErrorCode.UnknownError]: new UnknownError(new Error('It errored')), }; - Object.entries(errorMap).forEach(([code, error]) => { + for (const [code, error] of Object.entries(errorMap)) { assert.equal((error as CodedError).code, code); - }); + } }); it('wraps non-coded errors', () => { diff --git a/src/helpers.spec.ts b/test/unit/helpers.spec.ts similarity index 86% rename from src/helpers.spec.ts rename to test/unit/helpers.spec.ts index 9daab84ca..fefc4ea42 100644 --- a/src/helpers.spec.ts +++ b/test/unit/helpers.spec.ts @@ -1,7 +1,11 @@ -import 'mocha'; import { assert } from 'chai'; -import { isBodyWithTypeEnterpriseInstall, getTypeAndConversation, IncomingEventType, isEventTypeToSkipAuthorize } from './helpers'; -import { AnyMiddlewareArgs, ReceiverEvent, SlackEventMiddlewareArgs } from './types'; +import { + IncomingEventType, + getTypeAndConversation, + isBodyWithTypeEnterpriseInstall, + isEventTypeToSkipAuthorize, +} from '../../src/helpers'; +import type { AnyMiddlewareArgs, ReceiverEvent, SlackEventMiddlewareArgs } from '../../src/types'; describe('Helpers', () => { describe('getTypeAndConversation()', () => { @@ -43,7 +47,7 @@ describe('Helpers', () => { // Arrange const conversationId = 'CONVERSATION_ID'; const dummyActionBodies = createFakeOptions(conversationId); - dummyActionBodies.forEach((option) => { + for (const option of dummyActionBodies) { it(`should find Option type for ${option.type}`, () => { // Act const typeAndConversation = getTypeAndConversation(option); @@ -51,13 +55,13 @@ describe('Helpers', () => { assert(typeAndConversation.type === IncomingEventType.Options); assert(typeAndConversation.conversationId === conversationId); }); - }); + } }); describe('action types', () => { // Arrange const conversationId = 'CONVERSATION_ID'; const dummyActionBodies = createFakeActions(conversationId); - dummyActionBodies.forEach((action) => { + for (const action of dummyActionBodies) { it(`should find Action type for ${action.type}`, () => { // Act const typeAndConversation = getTypeAndConversation(action); @@ -65,13 +69,13 @@ describe('Helpers', () => { assert(typeAndConversation.type === IncomingEventType.Action); assert(typeAndConversation.conversationId === conversationId); }); - }); + } }); describe('shortcut types', () => { // Arrange const conversationId = 'CONVERSATION_ID'; const dummyShortcutBodies = createFakeShortcuts(conversationId); - dummyShortcutBodies.forEach((shortcut) => { + for (const shortcut of dummyShortcutBodies) { it(`should find Shortcut type for ${shortcut.type}`, () => { // Act const typeAndConversation = getTypeAndConversation(shortcut); @@ -81,19 +85,19 @@ describe('Helpers', () => { assert(typeAndConversation.conversationId === conversationId); } }); - }); + } }); describe('view types', () => { // Arrange const dummyViewBodies = createFakeViews(); - dummyViewBodies.forEach((viewBody) => { + for (const viewBody of dummyViewBodies) { it(`should find Action type for ${viewBody.type}`, () => { // Act const typeAndConversation = getTypeAndConversation(viewBody); // Assert assert(typeAndConversation.type === IncomingEventType.ViewAction); }); - }); + } }); describe('invalid events', () => { // Arrange @@ -128,13 +132,15 @@ describe('Helpers', () => { channel: '', event_ts: '', }, - authorizations: [{ - enterprise_id: '', - is_bot: true, - team_id: '', - user_id: '', - is_enterprise_install: true, - }], + authorizations: [ + { + enterprise_id: '', + is_bot: true, + team_id: '', + user_id: '', + is_enterprise_install: true, + }, + ], }; it('should resolve the is_enterprise_install field', () => { @@ -197,7 +203,7 @@ describe('Helpers', () => { describe('receiver events that can be skipped', () => { it('should return truthy when event can be skipped', () => { // Arrange - const dummyEventBody = { ack: async () => { }, body: { event: { type: 'app_uninstalled' } } } as ReceiverEvent; + const dummyEventBody = { ack: async () => {}, body: { event: { type: 'app_uninstalled' } } } as ReceiverEvent; // Act const isEnterpriseInstall = isEventTypeToSkipAuthorize(dummyEventBody); // Assert @@ -206,7 +212,7 @@ describe('Helpers', () => { it('should return falsy when event can not be skipped', () => { // Arrange - const dummyEventBody = { ack: async () => { }, body: { event: { type: '' } } } as ReceiverEvent; + const dummyEventBody = { ack: async () => {}, body: { event: { type: '' } } } as ReceiverEvent; // Act const isEnterpriseInstall = isEventTypeToSkipAuthorize(dummyEventBody); // Assert @@ -215,7 +221,7 @@ describe('Helpers', () => { it('should return falsy when event is invalid', () => { // Arrange - const dummyEventBody = { ack: async () => { }, body: {} } as ReceiverEvent; + const dummyEventBody = { ack: async () => {}, body: {} } as ReceiverEvent; // Act const isEnterpriseInstall = isEventTypeToSkipAuthorize(dummyEventBody); // Assert @@ -225,6 +231,7 @@ describe('Helpers', () => { }); }); +// biome-ignore lint/suspicious/noExplicitAny: test utilities can return anything function createFakeActions(conversationId: string): any[] { return [ // Body for a dialog submission @@ -255,6 +262,7 @@ function createFakeActions(conversationId: string): any[] { ]; } +// biome-ignore lint/suspicious/noExplicitAny: test utilities can return anything function createFakeShortcuts(conversationId: string): any[] { return [ // Body for a message shortcut @@ -269,6 +277,7 @@ function createFakeShortcuts(conversationId: string): any[] { ]; } +// biome-ignore lint/suspicious/noExplicitAny: test utilities can return anything function createFakeOptions(conversationId: string): any[] { return [ // Body for an options request in an interactive message @@ -291,6 +300,7 @@ function createFakeOptions(conversationId: string): any[] { ]; } +// biome-ignore lint/suspicious/noExplicitAny: test utilities can return anything function createFakeViews(): any[] { return [ // Body for a view_submission event diff --git a/test/unit/helpers/app.ts b/test/unit/helpers/app.ts new file mode 100644 index 000000000..0c11335db --- /dev/null +++ b/test/unit/helpers/app.ts @@ -0,0 +1,138 @@ +import type { AuthTestResponse, WebClientOptions } from '@slack/web-api'; +import rewiremock from 'rewiremock'; +import sinon, { type SinonSpy } from 'sinon'; + +/* + * Contains test helpers related to importing, mocking and overriding parts of the App class + */ + +// biome-ignore lint/suspicious/noExplicitAny: module overrides can be anything +export interface Override extends Record> {} + +export function mergeOverrides(...overrides: Override[]): Override { + let currentOverrides: Override = {}; + for (const override of overrides) { + currentOverrides = mergeObjProperties(currentOverrides, override); + } + return currentOverrides; +} + +function mergeObjProperties(first: Override, second: Override): Override { + const merged: Override = {}; + const props = Object.keys(first).concat(Object.keys(second)); + for (const prop of props) { + if (second[prop] === undefined && first[prop] !== undefined) { + merged[prop] = first[prop]; + } else if (first[prop] === undefined && second[prop] !== undefined) { + merged[prop] = second[prop]; + } else { + // second always overwrites the first + merged[prop] = { ...first[prop], ...second[prop] }; + } + } + return merged; +} + +/** + * Helps with importing the App class and overriding certain aspects of it, like its setting of request metadata and ensuring the API client within doesnt issue network requests. + */ +export async function importApp( + overrides: Override = mergeOverrides(withNoopAppMetadata(), withNoopWebClient()), +): Promise { + return (await rewiremock.module(() => import('../../../src/App'), overrides)).default; +} + +// Composable overrides +export function withNoopWebClient(authTestResponse?: AuthTestResponse): Override { + return { + '@slack/web-api': { + WebClient: authTestResponse + ? class { + public token?: string; + + public constructor(token?: string, _options?: WebClientOptions) { + this.token = token; + } + + public auth = { + test: sinon.fake.resolves(authTestResponse), + }; + } + : class {}, + }, + }; +} + +export function withNoopAppMetadata(): Override { + return { + '@slack/web-api': { + addAppMetadata: sinon.fake(), + }, + }; +} + +export function withMemoryStore(spy: SinonSpy): Override { + return { + './conversation-store': { + MemoryStore: spy, + }, + }; +} + +export function withConversationContext(spy: SinonSpy): Override { + return { + './conversation-store': { + conversationContext: spy, + }, + }; +} + +export function withPostMessage(spy: SinonSpy): Override { + return { + '@slack/web-api': { + WebClient: class { + public chat = { + postMessage: spy, + }; + }, + }, + }; +} + +export function withAxiosPost(spy: SinonSpy): Override { + return { + axios: { + create: () => ({ + post: spy, + }), + }, + }; +} + +export function withSuccessfulBotUserFetchingWebClient(botId: string, botUserId: string): Override { + return { + '@slack/web-api': { + WebClient: class { + public token?: string; + + public constructor(token?: string, _options?: WebClientOptions) { + this.token = token; + } + + public auth = { + test: sinon.fake.resolves({ user_id: botUserId }), + }; + + public users = { + info: sinon.fake.resolves({ + user: { + profile: { + bot_id: botId, + }, + }, + }), + }; + }, + }, + }; +} diff --git a/test/unit/helpers/events.ts b/test/unit/helpers/events.ts new file mode 100644 index 000000000..15a650338 --- /dev/null +++ b/test/unit/helpers/events.ts @@ -0,0 +1,450 @@ +import type { + AppHomeOpenedEvent, + AppMentionEvent, + Block, + KnownBlock, + MessageEvent, + ReactionAddedEvent, +} from '@slack/types'; +import { WebClient } from '@slack/web-api'; +import sinon, { type SinonSpy } from 'sinon'; +import { createFakeLogger } from '.'; +import type { + AckFn, + AllMiddlewareArgs, + AnyMiddlewareArgs, + BaseSlackEvent, + BlockAction, + BlockElementAction, + BlockSuggestion, + Context, + EnvelopedEvent, + GlobalShortcut, + MessageShortcut, + ReceiverEvent, + RespondFn, + SayFn, + SlackActionMiddlewareArgs, + SlackCommandMiddlewareArgs, + SlackEventMiddlewareArgs, + SlackOptionsMiddlewareArgs, + SlackShortcutMiddlewareArgs, + SlackViewMiddlewareArgs, + SlashCommand, + ViewClosedAction, + ViewOutput, + ViewSubmitAction, +} from '../../../src/types'; + +const ts = '1234.56'; +const user = 'U1234'; +const team = 'T1234'; +const channel = 'C1234'; +const token = 'xoxb-1234'; +const app_id = 'A1234'; +const say: SayFn = (_msg) => Promise.resolve({ ok: true }); +const respond: RespondFn = (_msg) => Promise.resolve(); +const ack: AckFn = (_r?) => Promise.resolve(); + +export function wrapMiddleware( + args: Args, + ctx?: Context, +): Args & AllMiddlewareArgs & { next: SinonSpy } { + const wrapped = { + ...args, + context: ctx || { isEnterpriseInstall: false }, + logger: createFakeLogger(), + client: new WebClient(), + next: sinon.fake(), + }; + return wrapped; +} + +interface DummyAppHomeOpenedOverrides { + channel?: string; + user?: string; +} +export function createDummyAppHomeOpenedEventMiddlewareArgs( + eventOverrides?: DummyAppHomeOpenedOverrides, + // biome-ignore lint/suspicious/noExplicitAny: allow mocking tools to provide any override + bodyOverrides?: Record, +): SlackEventMiddlewareArgs<'app_home_opened'> { + const event: AppHomeOpenedEvent = { + type: 'app_home_opened', + channel: eventOverrides?.channel || channel, + user: eventOverrides?.user || user, + tab: 'home', + event_ts: ts, + }; + return { + payload: event, + event, + body: envelopeEvent(event, bodyOverrides), + say, + }; +} + +interface DummyMemberChannelOverrides { + type: T; + channel?: string; + user?: string; + team?: string; +} +type MemberChannelEventTypes = 'member_joined_channel' | 'member_left_channel'; +export function createDummyMemberChannelEventMiddlewareArgs( + eventOverrides: DummyMemberChannelOverrides, + // biome-ignore lint/suspicious/noExplicitAny: allow mocking tools to provide any override + bodyOverrides?: Record, +): SlackEventMiddlewareArgs { + const event = { + type: eventOverrides.type, + user: eventOverrides?.user || user, + channel: eventOverrides?.channel || channel, + channel_type: 'channel', + team: eventOverrides?.team || team, + event_ts: ts, + }; + return { + payload: event, + event, + body: envelopeEvent(event, bodyOverrides), + say, + }; +} + +interface DummyReactionAddedOverrides { + channel?: string; + user?: string; + reaction?: string; +} +export function createDummyReactionAddedEventMiddlewareArgs( + eventOverrides?: DummyReactionAddedOverrides, + // biome-ignore lint/suspicious/noExplicitAny: allow mocking tools to provide any override + bodyOverrides?: Record, +): SlackEventMiddlewareArgs<'reaction_added'> { + const event: ReactionAddedEvent = { + type: 'reaction_added', + user: eventOverrides?.user || user, + reaction: eventOverrides?.reaction || 'lol', + item_user: 'wut', + item: { + type: 'message', + channel: eventOverrides?.channel || channel, + ts, + }, + event_ts: ts, + }; + return { + payload: event, + event, + body: envelopeEvent(event, bodyOverrides), + say, + }; +} + +interface DummyMessageOverrides { + message?: MessageEvent; + text?: string; + user?: string; + blocks?: (KnownBlock | Block)[]; +} +export function createDummyMessageEventMiddlewareArgs( + msgOverrides?: DummyMessageOverrides, + // biome-ignore lint/suspicious/noExplicitAny: allow mocking tools to provide any override + bodyOverrides?: Record, +): SlackEventMiddlewareArgs<'message'> { + const payload: MessageEvent = msgOverrides?.message || { + type: 'message', + subtype: undefined, + event_ts: ts, + channel, + channel_type: 'channel', + user: msgOverrides?.user || user, + ts, + text: msgOverrides?.text || 'hi', + blocks: msgOverrides?.blocks || [], + }; + return { + payload, + event: payload, + message: payload, + body: envelopeEvent(payload, bodyOverrides), + say, + }; +} + +interface DummyAppMentionOverrides { + event?: AppMentionEvent; + text?: string; +} +export function createDummyAppMentionEventMiddlewareArgs( + eventOverrides?: DummyAppMentionOverrides, + // biome-ignore lint/suspicious/noExplicitAny: allow mocking tools to provide any override + bodyOverrides?: Record, +): SlackEventMiddlewareArgs<'app_mention'> { + const payload: AppMentionEvent = eventOverrides?.event || { + type: 'app_mention', + text: eventOverrides?.text || 'hi', + channel, + ts, + event_ts: ts, + }; + return { + payload, + event: payload, + body: envelopeEvent(payload, bodyOverrides), + say, + }; +} + +interface DummyCommandOverride { + command?: string; + slashCommand?: SlashCommand; +} +export function createDummyCommandMiddlewareArgs(commandOverrides?: DummyCommandOverride): SlackCommandMiddlewareArgs { + const payload: SlashCommand = commandOverrides?.slashCommand || { + token, + command: commandOverrides?.command || '/slash', + text: 'yo', + response_url: 'https://slack.com', + trigger_id: ts, + user_id: user, + user_name: 'filmaj', + team_id: team, + team_domain: 'slack.com', + channel_id: channel, + channel_name: '#random', + api_app_id: app_id, + }; + return { + payload, + command: payload, + body: payload, + respond, + say, + ack: () => Promise.resolve(), + }; +} + +interface DummyBlockActionOverride { + action_id?: string; + block_id?: string; + action?: BlockElementAction; +} +export function createDummyBlockActionEventMiddlewareArgs( + actionOverrides?: DummyBlockActionOverride, + // biome-ignore lint/suspicious/noExplicitAny: allow mocking tools to provide any override + bodyOverrides?: Record, +): SlackActionMiddlewareArgs { + const act: BlockElementAction = actionOverrides?.action || { + type: 'button', + action_id: actionOverrides?.action_id || 'action_id', + block_id: actionOverrides?.block_id || 'block_id', + action_ts: ts, + text: { type: 'plain_text', text: 'hi' }, + }; + const payload: BlockAction = { + type: 'block_actions', + actions: [act], + team: { id: team, domain: 'slack.com' }, + user: { id: user, username: 'filmaj' }, + token, + response_url: 'https://slack.com', + trigger_id: ts, + api_app_id: app_id, + container: {}, + ...bodyOverrides, + }; + return { + payload: act, + action: act, + body: payload, + respond, + say, + ack, + }; +} + +interface DummyBlockSuggestionOverride { + action_id?: string; + block_id?: string; + options?: BlockSuggestion; +} +export function createDummyBlockSuggestionsMiddlewareArgs( + optionsOverrides?: DummyBlockSuggestionOverride, +): SlackOptionsMiddlewareArgs { + const options: BlockSuggestion = optionsOverrides?.options || { + type: 'block_suggestion', + action_id: optionsOverrides?.action_id || 'action_id', + block_id: optionsOverrides?.block_id || 'block_id', + value: 'value', + action_ts: ts, + api_app_id: app_id, + team: { id: team, domain: 'slack.com' }, + user: { id: user, name: 'filmaj' }, + token, + container: {}, + }; + return { + payload: options, + body: options, + options, + ack: () => Promise.resolve(), + }; +} + +function createDummyViewOutput(viewOverrides?: Partial): ViewOutput { + return { + type: 'view', + id: 'V1234', + callback_id: 'Cb1234', + team_id: team, + app_id, + bot_id: 'B1234', + title: { type: 'plain_text', text: 'hi' }, + blocks: [], + close: null, + submit: null, + hash: ts, + state: { values: {} }, + private_metadata: '', + root_view_id: null, + previous_view_id: null, + clear_on_close: false, + notify_on_close: false, + ...viewOverrides, + }; +} + +export function createDummyViewSubmissionMiddlewareArgs( + viewOverrides?: Partial, + // biome-ignore lint/suspicious/noExplicitAny: allow mocking tools to provide any override + bodyOverrides?: Record, +): SlackViewMiddlewareArgs { + const payload = createDummyViewOutput(viewOverrides); + const event: ViewSubmitAction = { + type: 'view_submission', + team: { id: team, domain: 'slack.com' }, + user: { id: user, name: 'filmaj' }, + view: payload, + api_app_id: app_id, + token, + trigger_id: ts, + ...bodyOverrides, + }; + return { + payload, + view: payload, + body: event, + respond, + ack: () => Promise.resolve(), + }; +} + +export function createDummyViewClosedMiddlewareArgs( + viewOverrides?: Partial, + // biome-ignore lint/suspicious/noExplicitAny: allow mocking tools to provide any override + bodyOverrides?: Record, +): SlackViewMiddlewareArgs { + const payload = createDummyViewOutput(viewOverrides); + const event: ViewClosedAction = { + type: 'view_closed', + team: { id: team, domain: 'slack.com' }, + user: { id: user, name: 'filmaj' }, + view: payload, + api_app_id: app_id, + token, + is_cleared: false, + ...bodyOverrides, + }; + return { + payload, + view: payload, + body: event, + respond, + ack: () => Promise.resolve(), + }; +} + +export function createDummyMessageShortcutMiddlewareArgs( + callback_id = 'Cb1234', + shortcut?: MessageShortcut, +): SlackShortcutMiddlewareArgs { + const payload: MessageShortcut = shortcut || { + type: 'message_action', + callback_id, + trigger_id: ts, + message_ts: ts, + response_url: 'https://slack.com', + message: { + type: 'message', + ts, + }, + user: { id: user, name: 'filmaj' }, + channel: { id: channel, name: '#random' }, + team: { id: team, domain: 'slack.com' }, + token, + action_ts: ts, + }; + return { + payload, + shortcut: payload, + body: payload, + respond, + ack: () => Promise.resolve(), + say, + }; +} + +export function createDummyGlobalShortcutMiddlewareArgs( + callback_id = 'Cb1234', + shortcut?: GlobalShortcut, +): SlackShortcutMiddlewareArgs { + const payload: GlobalShortcut = shortcut || { + type: 'shortcut', + callback_id, + trigger_id: ts, + user: { id: user, username: 'filmaj', team_id: team }, + team: { id: team, domain: 'slack.com' }, + token, + action_ts: ts, + }; + return { + payload, + shortcut: payload, + body: payload, + respond, + ack: () => Promise.resolve(), + }; +} + +function envelopeEvent( + evt: SlackEvent, + // biome-ignore lint/suspicious/noExplicitAny: allow mocking tools to provide any override + overrides?: Record, +): EnvelopedEvent { + const obj: EnvelopedEvent = { + token: 'xoxb-1234', + team_id: 'T1234', + api_app_id: 'A1234', + event: evt, + type: 'event_callback', + event_id: '1234', + event_time: 1234, + ...overrides, + }; + return obj; +} +// Dummies (values that have no real behavior but pass through the system opaquely) +export function createDummyReceiverEvent(type = 'dummy_event_type'): ReceiverEvent { + // NOTE: this is a degenerate ReceiverEvent that would successfully pass through the App. it happens to look like a + // IncomingEventType.Event + return { + body: { + event: { + type, + }, + }, + ack: () => Promise.resolve(), + }; +} diff --git a/test/unit/helpers/index.ts b/test/unit/helpers/index.ts new file mode 100644 index 000000000..35bdcbed8 --- /dev/null +++ b/test/unit/helpers/index.ts @@ -0,0 +1,34 @@ +import { ConsoleLogger } from '@slack/logger'; +import sinon from 'sinon'; +import type { ConversationStore } from '../../../src/conversation-store'; +import type { NextFn } from '../../../src/types'; + +export * from './app'; +export * from './events'; +export * from './receivers'; + +export function createFakeLogger() { + return sinon.createStubInstance(ConsoleLogger); +} + +export function delay(ms = 0): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +// biome-ignore lint/suspicious/noExplicitAny: mock function can accept anything +export const noop = (_args: any) => Promise.resolve({}); +// biome-ignore lint/suspicious/noExplicitAny: mock function can accept anything +export const noopVoid = (..._args: any[]) => Promise.resolve(); +export const noopMiddleware = async ({ next }: { next: NextFn }) => { + await next(); +}; + +export function createFakeConversationStore(): ConversationStore { + return { + get: (_id: string) => Promise.resolve({}), + // biome-ignore lint/suspicious/noExplicitAny: mocks can be anything + set: (_id: string, _val: any) => Promise.resolve({}), + }; +} diff --git a/test/unit/helpers/receivers.ts b/test/unit/helpers/receivers.ts new file mode 100644 index 000000000..3d5bd0f92 --- /dev/null +++ b/test/unit/helpers/receivers.ts @@ -0,0 +1,102 @@ +import crypto from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import sinon, { type SinonSpy } from 'sinon'; +import type App from '../../../src/App'; +import type { AwsEvent } from '../../../src/receivers/AwsLambdaReceiver'; +import type { Receiver, ReceiverEvent } from '../../../src/types'; +import type { Override } from './app'; + +export class FakeReceiver implements Receiver { + private bolt: App | undefined; + + public init = (bolt: App) => { + this.bolt = bolt; + }; + + public start = sinon.fake( + (...params: Parameters): Promise => Promise.resolve([...params]), + ); + + public stop = sinon.fake( + (...params: Parameters): Promise => Promise.resolve([...params]), + ); + + public async sendEvent(event: ReceiverEvent): Promise { + return this.bolt?.processEvent(event); + } +} + +export class FakeServer extends EventEmitter { + public on = sinon.fake(); + + public listen = sinon.fake((_opts: Record, cb: () => void) => { + if (this.listeningFailure !== undefined) { + this.emit('error', this.listeningFailure); + } + if (cb) cb(); + }); + + // biome-ignore lint/suspicious/noExplicitAny: event handlers could accept anything as parameters + public close = sinon.fake((...args: any[]) => { + setImmediate(() => { + this.emit('close'); + setImmediate(() => { + args[0](this.closingFailure); + }); + }); + }); + + public constructor( + private listeningFailure?: Error, + private closingFailure?: Error, + ) { + super(); + } +} +export function withHttpCreateServer(spy: SinonSpy): Override { + return { + 'node:http': { + createServer: spy, + }, + }; +} + +export function withHttpsCreateServer(spy: SinonSpy): Override { + return { + 'node:https': { + createServer: spy, + }, + }; +} + +export function createDummyAWSPayload( + // biome-ignore lint/suspicious/noExplicitAny: HTTP request bodies can be anything + body: any, + timestamp: number = Math.floor(Date.now() / 1000), + headers?: Record, + isBase64Encoded = false, +): AwsEvent { + const signature = crypto.createHmac('sha256', 'my-secret').update(`v0:${timestamp}:${body}`).digest('hex'); + const realBody = isBase64Encoded ? Buffer.from(body).toString('base64') : body; + return { + resource: '/slack/events', + path: '/slack/events', + httpMethod: 'POST', + headers: 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}`, + }, + multiValueHeaders: {}, + queryStringParameters: {}, + multiValueQueryStringParameters: {}, + pathParameters: null, + stageVariables: null, + requestContext: {}, + body: realBody, + isBase64Encoded, + }; +} diff --git a/test/unit/middleware/builtin.spec.ts b/test/unit/middleware/builtin.spec.ts new file mode 100644 index 000000000..1567f9ffc --- /dev/null +++ b/test/unit/middleware/builtin.spec.ts @@ -0,0 +1,414 @@ +// import type { SlackEvent } from '@slack/types'; +import { assert } from 'chai'; +import rewiremock from 'rewiremock'; +import sinon from 'sinon'; +import { ErrorCode } from '../../../src/errors'; +// import { matchCommandName, matchEventType, onlyCommands, onlyEvents, subtype } from '../../../src/middleware/builtin'; +import type { Context, /* NextFn, */ SlackEventMiddlewareArgs } from '../../../src/types'; +import { + type Override, + createDummyAppHomeOpenedEventMiddlewareArgs, + createDummyAppMentionEventMiddlewareArgs, + createDummyCommandMiddlewareArgs, + createDummyMemberChannelEventMiddlewareArgs, + createDummyMessageEventMiddlewareArgs, + createDummyReactionAddedEventMiddlewareArgs, + wrapMiddleware, +} from '../helpers'; + +interface DummyContext extends Context { + matches?: RegExpExecArray; +} + +// Dummy values +const dummyContext: DummyContext = { isEnterpriseInstall: false }; +const ts = '1234.56'; +const channel = 'C1234'; + +async function importBuiltin(overrides: Override = {}): Promise { + return rewiremock.module(() => import('../../../src/middleware/builtin'), overrides); +} + +describe('Built-in global middleware', () => { + let builtins: Awaited>; + beforeEach(async () => { + builtins = await importBuiltin(); + }); + describe('matchMessage()', () => { + function matchesPatternTestCase( + pattern: string | RegExp, + event: SlackEventMiddlewareArgs<'message' | 'app_mention'>, + ): Mocha.AsyncFunc { + return async () => { + const { matchMessage } = builtins; + const middleware = matchMessage(pattern); + const ctx = { ...dummyContext }; + const args = wrapMiddleware(event, ctx); + await middleware(args); + + sinon.assert.called(args.next); + // The following assertion(s) check behavior that is only targeted at RegExp patterns + if (typeof pattern !== 'string') { + if (ctx.matches !== undefined) { + assert.lengthOf(ctx.matches, 1); + } else { + assert.fail(); + } + } + }; + } + + function notMatchesPatternTestCase( + pattern: string | RegExp, + event: SlackEventMiddlewareArgs<'message' | 'app_mention'>, + ): Mocha.AsyncFunc { + return async () => { + const { matchMessage } = builtins; + const middleware = matchMessage(pattern); + const ctx = { ...dummyContext }; + const args = wrapMiddleware(event, ctx); + await middleware(args); + + sinon.assert.notCalled(args.next); + assert.notProperty(ctx, 'matches'); + }; + } + + describe('using a string pattern', () => { + const pattern = 'foo'; + const matchingText = 'foobar'; + const nonMatchingText = 'bar'; + it( + 'should match message events with a pattern that matches', + matchesPatternTestCase(pattern, createDummyMessageEventMiddlewareArgs({ text: matchingText })), + ); + it( + 'should match app_mention events with a pattern that matches', + matchesPatternTestCase(pattern, createDummyAppMentionEventMiddlewareArgs({ text: matchingText })), + ); + it( + 'should filter out message events with a pattern that does not match', + notMatchesPatternTestCase(pattern, createDummyMessageEventMiddlewareArgs({ text: nonMatchingText })), + ); + it( + 'should filter out app_mention events with a pattern that does not match', + notMatchesPatternTestCase(pattern, createDummyAppMentionEventMiddlewareArgs({ text: nonMatchingText })), + ); + it( + 'should filter out message events which do not have text (block kit)', + notMatchesPatternTestCase( + pattern, + createDummyMessageEventMiddlewareArgs({ + text: '', + blocks: [ + { + type: 'divider', + }, + ], + }), + ), + ); + }); + + describe('using a RegExp pattern', () => { + const pattern = /foo/; + const matchingText = 'foobar'; + const nonMatchingText = 'bar'; + it( + 'should match message events with a pattern that matches', + matchesPatternTestCase(pattern, createDummyMessageEventMiddlewareArgs({ text: matchingText })), + ); + it( + 'should match app_mention events with a pattern that matches', + matchesPatternTestCase(pattern, createDummyAppMentionEventMiddlewareArgs({ text: matchingText })), + ); + it( + 'should filter out message events with a pattern that does not match', + notMatchesPatternTestCase(pattern, createDummyMessageEventMiddlewareArgs({ text: nonMatchingText })), + ); + it( + 'should filter out app_mention events with a pattern that does not match', + notMatchesPatternTestCase(pattern, createDummyAppMentionEventMiddlewareArgs({ text: nonMatchingText })), + ); + it( + 'should filter out message events which do not have text (block kit)', + + notMatchesPatternTestCase( + pattern, + createDummyMessageEventMiddlewareArgs({ + text: '', + blocks: [ + { + type: 'divider', + }, + ], + }), + ), + ); + }); + + describe('directMention()', () => { + it('should bail when the context does not provide a bot user ID', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware(createDummyMessageEventMiddlewareArgs(), ctx); + + let error: Error | undefined = undefined; + try { + await builtins.directMention(args); + } catch (err) { + error = err as Error; + } + + assert.instanceOf(error, Error); + assert.propertyVal(error, 'code', ErrorCode.ContextMissingPropertyError); + assert.propertyVal(error, 'missingProperty', 'botUserId'); + }); + + it('should match message events that mention the bot user ID at the beginning of message text', async () => { + const fakeBotUserId = 'B123456'; + const messageText = `<@${fakeBotUserId}> hi`; + const ctx = { ...dummyContext, botUserId: fakeBotUserId }; + const args = wrapMiddleware(createDummyMessageEventMiddlewareArgs({ text: messageText }), ctx); + + await builtins.directMention(args); + + sinon.assert.called(args.next); + }); + + it('should not match message events that do not mention the bot user ID', async () => { + const fakeBotUserId = 'B123456'; + const messageText = 'hi'; + const ctx = { ...dummyContext, botUserId: fakeBotUserId }; + const args = wrapMiddleware(createDummyMessageEventMiddlewareArgs({ text: messageText }), ctx); + + await builtins.directMention(args); + + sinon.assert.notCalled(args.next); + }); + + it('should not match message events that mention the bot user ID NOT at the beginning of message text', async () => { + const fakeBotUserId = 'B123456'; + const messageText = `hi <@${fakeBotUserId}> `; + const ctx = { ...dummyContext, botUserId: fakeBotUserId }; + const args = wrapMiddleware(createDummyMessageEventMiddlewareArgs({ text: messageText }), ctx); + + await builtins.directMention(args); + + sinon.assert.notCalled(args.next); + }); + + it('should not match message events which do not have text (block kit)', async () => { + const fakeBotUserId = 'B123456'; + const ctx = { ...dummyContext, botUserId: fakeBotUserId }; + const args = wrapMiddleware(createDummyMessageEventMiddlewareArgs({ blocks: [{ type: 'divider' }] }), ctx); + + await builtins.directMention(args); + + sinon.assert.notCalled(args.next); + }); + + it('should not match message events that contain a link to a conversation at the beginning', async () => { + const fakeBotUserId = 'B123456'; + const messageText = '<#C1234> hi'; + const ctx = { ...dummyContext, botUserId: fakeBotUserId }; + const args = wrapMiddleware(createDummyMessageEventMiddlewareArgs({ text: messageText }), ctx); + + await builtins.directMention(args); + + sinon.assert.notCalled(args.next); + }); + }); + + describe('ignoreSelf()', () => { + const fakeBotUserId = 'BUSER1'; + it('should continue middleware processing for non-event payloads', async () => { + const ctx = { ...dummyContext, botUserId: fakeBotUserId, botId: fakeBotUserId }; + const args = wrapMiddleware(createDummyCommandMiddlewareArgs(), ctx); + await builtins.ignoreSelf(args); + sinon.assert.called(args.next); + }); + + it('should ignore message events identified as a bot message from the same bot ID as this app', async () => { + const ctx = { ...dummyContext, botUserId: fakeBotUserId, botId: fakeBotUserId }; + const args = wrapMiddleware( + createDummyMessageEventMiddlewareArgs({ + message: { + bot_id: fakeBotUserId, + channel, + channel_type: 'channel', + event_ts: ts, + text: 'hi', + type: 'message', + ts, + subtype: 'bot_message', + }, + }), + ctx, + ); + await builtins.ignoreSelf(args); + sinon.assert.notCalled(args.next); + }); + + it('should ignore events with only a botUserId', async () => { + const ctx = { ...dummyContext, botUserId: fakeBotUserId }; + const args = wrapMiddleware(createDummyReactionAddedEventMiddlewareArgs({ user: fakeBotUserId }), ctx); + await builtins.ignoreSelf(args); + sinon.assert.notCalled(args.next); + }); + + it('should ignore events that match own app', async () => { + const ctx = { ...dummyContext, botUserId: fakeBotUserId, botId: fakeBotUserId }; + const args = wrapMiddleware(createDummyReactionAddedEventMiddlewareArgs({ user: fakeBotUserId }), ctx); + await builtins.ignoreSelf(args); + sinon.assert.notCalled(args.next); + }); + + it('should not filter `member_joined_channel` and `member_left_channel` events originating from own app', async () => { + const eventsWhichShouldNotBeFilteredOut = ['member_joined_channel', 'member_left_channel'] as const; + const ctx = { ...dummyContext, botUserId: fakeBotUserId, botId: fakeBotUserId }; + + const listOfFakeArgs = eventsWhichShouldNotBeFilteredOut.map((type) => + wrapMiddleware(createDummyMemberChannelEventMiddlewareArgs({ type, user: fakeBotUserId }), ctx), + ); + + await Promise.all(listOfFakeArgs.map(builtins.ignoreSelf)); + for (const args of listOfFakeArgs) { + sinon.assert.called(args.next); + } + }); + }); + + describe('onlyCommands', () => { + it('should continue middleware processing for a command payload', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware(createDummyCommandMiddlewareArgs(), ctx); + await builtins.onlyCommands(args); + sinon.assert.called(args.next); + }); + + it('should ignore non-command payloads', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware(createDummyReactionAddedEventMiddlewareArgs(), ctx); + await builtins.onlyCommands(args); + sinon.assert.notCalled(args.next); + }); + }); + + describe('matchCommandName', () => { + it('should continue middleware processing for requests that match exactly', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware(createDummyCommandMiddlewareArgs({ command: '/hi' }), ctx); + await builtins.matchCommandName('/hi')(args); + sinon.assert.called(args.next); + }); + + it('should continue middleware processing for requests that match a pattern', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware(createDummyCommandMiddlewareArgs({ command: '/hi' }), ctx); + await builtins.matchCommandName(/h/)(args); + sinon.assert.called(args.next); + }); + + it('should skip other requests', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware(createDummyCommandMiddlewareArgs({ command: '/hi' }), ctx); + await builtins.matchCommandName('/will-not-match')(args); + sinon.assert.notCalled(args.next); + }); + }); + + describe('onlyEvents', () => { + it('should continue middleware processing for valid requests', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware(createDummyAppMentionEventMiddlewareArgs(), ctx); + await builtins.onlyEvents(args); + sinon.assert.called(args.next); + }); + + it('should skip other requests', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware(createDummyCommandMiddlewareArgs({ command: '/hi' }), ctx); + await builtins.onlyEvents(args); + sinon.assert.notCalled(args.next); + }); + }); + + describe('matchEventType', () => { + it('should continue middleware processing for when event type matches', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware(createDummyAppMentionEventMiddlewareArgs(), ctx); + await builtins.matchEventType('app_mention')(args); + sinon.assert.called(args.next); + }); + + it('should continue middleware processing for if RegExp match occurs on event type', async () => { + const ctx = { ...dummyContext }; + const appMentionArgs = wrapMiddleware(createDummyAppMentionEventMiddlewareArgs(), ctx); + const appHomeArgs = wrapMiddleware(createDummyAppHomeOpenedEventMiddlewareArgs(), ctx); + const middleware = builtins.matchEventType(/app_mention|app_home_opened/); + await middleware(appMentionArgs); + sinon.assert.called(appMentionArgs.next); + await middleware(appHomeArgs); + sinon.assert.called(appHomeArgs.next); + }); + + it('should skip non-matching event types', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware(createDummyAppMentionEventMiddlewareArgs(), ctx); + await builtins.matchEventType('app_home_opened')(args); + sinon.assert.notCalled(args.next); + }); + + it('should skip non-matching event types via RegExp', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware(createDummyAppMentionEventMiddlewareArgs(), ctx); + await builtins.matchEventType(/foo/)(args); + sinon.assert.notCalled(args.next); + }); + }); + + describe('subtype', () => { + it('should continue middleware processing for match message subtypes', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware( + createDummyMessageEventMiddlewareArgs({ + message: { + bot_id: 'B1234', + channel, + channel_type: 'channel', + event_ts: ts, + text: 'hi', + type: 'message', + ts, + subtype: 'bot_message', + }, + }), + ctx, + ); + await builtins.subtype('bot_message')(args); + sinon.assert.called(args.next); + }); + + it('should skip non-matching message subtypes', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware( + createDummyMessageEventMiddlewareArgs({ + message: { + bot_id: 'B1234', + channel, + channel_type: 'channel', + event_ts: ts, + text: 'hi', + type: 'message', + ts, + subtype: 'bot_message', + }, + }), + ctx, + ); + await builtins.subtype('me_message')(args); + sinon.assert.notCalled(args.next); + }); + }); + }); +}); diff --git a/test/unit/receivers/AwsLambdaReceiver.spec.ts b/test/unit/receivers/AwsLambdaReceiver.spec.ts new file mode 100644 index 000000000..38f1b6810 --- /dev/null +++ b/test/unit/receivers/AwsLambdaReceiver.spec.ts @@ -0,0 +1,285 @@ +import crypto from 'node:crypto'; +import { assert } from 'chai'; +import sinon from 'sinon'; +import AwsLambdaReceiver from '../../../src/receivers/AwsLambdaReceiver'; +import { + createDummyAWSPayload, + createDummyAppMentionEventMiddlewareArgs, + createFakeLogger, + importApp, + mergeOverrides, + noopVoid, + withNoopAppMetadata, + withNoopWebClient, +} from '../helpers'; + +const fakeAuthTestResponse = { + ok: true, + enterprise_id: 'E111', + team_id: 'T111', + bot_id: 'B111', + user_id: 'W111', +}; +const appOverrides = mergeOverrides(withNoopAppMetadata(), withNoopWebClient(fakeAuthTestResponse)); + +describe('AwsLambdaReceiver', () => { + const noopLogger = createFakeLogger(); + + it('should instantiate with default logger', async () => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + assert.isNotNull(awsReceiver); + }); + + it('should have start method', async () => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + const startedHandler = await awsReceiver.start(); + assert.isNotNull(startedHandler); + }); + + it('should have stop method', async () => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + await awsReceiver.start(); + await awsReceiver.stop(); + }); + + it('should accept events', async () => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + const handler = awsReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000); + const args = createDummyAppMentionEventMiddlewareArgs(); + const body = JSON.stringify(args.body); + const awsEvent = createDummyAWSPayload(body, timestamp); + const response1 = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response1.statusCode, 404); + const App = await importApp(appOverrides); + const app = new App({ + token: 'xoxb-', + receiver: awsReceiver, + }); + app.event('app_mention', noopVoid); + const response2 = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response2.statusCode, 200); + }); + + it('should accept proxy events with lowercase header properties', async () => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + const handler = awsReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000); + const args = createDummyAppMentionEventMiddlewareArgs(); + const body = JSON.stringify(args.body); + const signature = crypto.createHmac('sha256', 'my-secret').update(`v0:${timestamp}:${body}`).digest('hex'); + const awsEvent = createDummyAWSPayload(body, timestamp, { + 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}`, + }); + const response1 = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response1.statusCode, 404); + const App = await importApp(appOverrides); + const app = new App({ + token: 'xoxb-', + receiver: awsReceiver, + }); + app.event('app_mention', noopVoid); + const response2 = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response2.statusCode, 200); + }); + + it('should accept interactivity requests as form-encoded payload', async () => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + const handler = awsReceiver.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-aws-lambda-shortcut%22%2C%22trigger_id%22%3A%22111.222.xxx%22%7D'; + const signature = crypto.createHmac('sha256', 'my-secret').update(`v0:${timestamp}:${body}`).digest('hex'); + const awsEvent = createDummyAWSPayload(body, timestamp, { + Accept: 'application/json,*/*', + 'Content-Type': 'application/x-www-form-urlencoded', + 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}`, + }); + const response1 = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response1.statusCode, 404); + const App = await importApp(appOverrides); + const app = new App({ + token: 'xoxb-', + receiver: awsReceiver, + }); + app.shortcut('bolt-js-aws-lambda-shortcut', async ({ ack }) => { + await ack(); + }); + const response2 = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response2.statusCode, 200); + }); + + it('should accept slash commands with form-encoded body', async () => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + const handler = awsReceiver.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&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 signature = crypto.createHmac('sha256', 'my-secret').update(`v0:${timestamp}:${body}`).digest('hex'); + const awsEvent = createDummyAWSPayload(body, timestamp, { + Accept: 'application/json,*/*', + 'Content-Type': 'application/x-www-form-urlencoded', + 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}`, + }); + const response1 = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response1.statusCode, 404); + const App = await importApp(appOverrides); + const app = new App({ + token: 'xoxb-', + receiver: awsReceiver, + }); + app.command('/hello-bolt-js', async ({ ack }) => { + await ack(); + }); + const response2 = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response2.statusCode, 200); + }); + + it('should accept an event containing a base64 encoded body', async () => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + const handler = awsReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000); + const args = createDummyAppMentionEventMiddlewareArgs(); + const body = JSON.stringify(args.body); + const awsEvent = createDummyAWSPayload(body, timestamp, undefined, true); + const response1 = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response1.statusCode, 404); + }); + + it('should accept ssl_check requests', async () => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + const handler = awsReceiver.toHandler(); + const body = 'ssl_check=1&token=legacy-fixed-token'; + const timestamp = Math.floor(Date.now() / 1000); + const signature = crypto.createHmac('sha256', 'my-secret').update(`v0:${timestamp}:${body}`).digest('hex'); + const awsEvent = createDummyAWSPayload(body, timestamp, { + Accept: 'application/json,*/*', + 'Content-Type': 'application/x-www-form-urlencoded', + 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}`, + }); + const response = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response.statusCode, 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 awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + const handler = awsReceiver.toHandler(); + const awsEvent = createDummyAWSPayload(urlVerificationBody, timestamp); + const response = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response.statusCode, 200); + }); + + it('should detect invalid signature', async () => { + const spy = sinon.spy(); + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + invalidRequestSignatureHandler: spy, + }); + const handler = awsReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000); + const signature = crypto + .createHmac('sha256', 'my-secret') + .update(`v0:${timestamp}:${urlVerificationBody}`) + .digest('hex'); + const awsEvent = { + resource: '/slack/events', + path: '/slack/events', + httpMethod: 'POST', + 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 + }, + multiValueHeaders: {}, + queryStringParameters: {}, + multiValueQueryStringParameters: {}, + pathParameters: null, + stageVariables: null, + requestContext: {}, + body: urlVerificationBody, + isBase64Encoded: false, + }; + const response = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response.statusCode, 401); + assert(spy.calledOnce); + }); + + it('should detect too old request timestamp', async () => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + const handler = awsReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000) - 600; // 10 minutes ago + const awsEvent = createDummyAWSPayload(urlVerificationBody, timestamp); + const response = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response.statusCode, 401); + }); + + it('does not perform signature verification if signature verification flag is set to false', async () => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: '', + signatureVerification: false, + logger: noopLogger, + }); + const handler = awsReceiver.toHandler(); + const awsEvent = createDummyAWSPayload(urlVerificationBody); + const response = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response.statusCode, 200); + }); +}); diff --git a/src/receivers/ExpressReceiver.spec.ts b/test/unit/receivers/ExpressReceiver.spec.ts similarity index 51% rename from src/receivers/ExpressReceiver.spec.ts rename to test/unit/receivers/ExpressReceiver.spec.ts index af3a7c1b3..fabae72ef 100644 --- a/src/receivers/ExpressReceiver.spec.ts +++ b/test/unit/receivers/ExpressReceiver.spec.ts @@ -1,96 +1,70 @@ -import 'mocha'; -import { Readable } from 'stream'; -import { EventEmitter } from 'events'; -import sinon, { SinonFakeTimers, SinonSpy } from 'sinon'; +import type { Server } from 'node:http'; +import type { Server as HTTPSServer } from 'node:https'; +import { Readable } from 'node:stream'; +import type { InstallProvider } from '@slack/oauth'; import { assert } from 'chai'; +import type { Application, IRouter, Request, Response } from 'express'; import rewiremock from 'rewiremock'; -import { Logger, LogLevel } from '@slack/logger'; -import { Application, IRouter, Request, Response } from 'express'; -import { Override, mergeOverrides, createFakeLogger } from '../test-helpers'; -import { ErrorCode, CodedError, ReceiverInconsistentStateError, AppInitializationError, AuthorizationError } from '../errors'; -import { HTTPModuleFunctions as httpFunc } from './HTTPModuleFunctions'; -import App from '../App'; - +import sinon, { type SinonFakeTimers } from 'sinon'; +import App from '../../../src/App'; +import { + AppInitializationError, + AuthorizationError, + ErrorCode, + ReceiverInconsistentStateError, +} from '../../../src/errors'; import ExpressReceiver, { respondToSslCheck, respondToUrlVerification, verifySignatureAndParseRawBody, buildBodyParserMiddleware, -} from './ExpressReceiver'; - -// Fakes -class FakeServer extends EventEmitter { - public on = sinon.fake(); - - public listen = sinon.fake((...args: any[]) => { - if (this.listeningFailure !== undefined) { - this.emit('error', this.listeningFailure); - return; - } - setImmediate(() => { - args[1](); - }); - }); +} from '../../../src/receivers/ExpressReceiver'; +import * as httpFunc from '../../../src/receivers/HTTPModuleFunctions'; +import type { ReceiverEvent } from '../../../src/types'; +import { + FakeServer, + type Override, + createFakeLogger, + mergeOverrides, + withHttpCreateServer, + withHttpsCreateServer, +} from '../helpers'; - public close = sinon.fake((...args: any[]) => { - setImmediate(() => { - this.emit('close'); - setImmediate(() => { - args[0](this.closingFailure); - }); - }); - }); +// Loading the system under test using overrides +async function importExpressReceiver( + overrides: Override = {}, +): Promise { + return (await rewiremock.module(() => import('../../../src/receivers/ExpressReceiver'), overrides)).default; +} - public constructor(private listeningFailure?: Error, private closingFailure?: Error) { - super(); - } +// biome-ignore lint/suspicious/noExplicitAny: accept any kind of mock response +function buildResponseToVerify(result: any): Response { + return { + status: (code: number) => { + result.code = code; + return { + send: () => { + result.sent = true; + }, + } as Response; + }, + } as Response; } -describe('ExpressReceiver', function () { - beforeEach(function () { - this.fakeServer = new FakeServer(); - this.fakeCreateServer = sinon.fake.returns(this.fakeServer); +describe('ExpressReceiver', () => { + const noopLogger = createFakeLogger(); + let fakeServer: FakeServer; + let fakeCreateServer: sinon.SinonSpy; + let overrides: Override; + beforeEach(() => { + fakeServer = new FakeServer(); + fakeCreateServer = sinon.fake.returns(fakeServer); + overrides = mergeOverrides( + withHttpCreateServer(fakeCreateServer), + withHttpsCreateServer(sinon.fake.throws('Should not be used.')), + ); }); - const noopLogger: Logger = { - 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 { - /* noop */ - }, - }; - - function buildResponseToVerify(result: any): Response { - return { - status: (code: number) => { - // eslint-disable-next-line no-param-reassign - result.code = code; - return { - send: () => { - // eslint-disable-next-line no-param-reassign - result.sent = true; - }, - } as any as Response; - }, - } as any as Response; - } - describe('constructor', () => { // NOTE: it would be more informative to test known valid combinations of options, as well as invalid combinations it('should accept supported arguments', async () => { @@ -112,14 +86,14 @@ describe('ExpressReceiver', function () { assert.isNotNull(receiver); }); it('should accept custom Express app / router', async () => { - const app: Application = { + const app = { use: sinon.fake(), - } as unknown as Application; - const router: IRouter = { + }; + const router = { get: sinon.fake(), post: sinon.fake(), use: sinon.fake(), - } as unknown as IRouter; + }; const receiver = new ExpressReceiver({ signingSecret: 'my-secret', logger: noopLogger, @@ -133,15 +107,15 @@ describe('ExpressReceiver', function () { authVersion: 'v2', userScopes: ['chat:write'], }, - app, - router, + app: app as unknown as Application, + router: router as unknown as IRouter, }); assert.isNotNull(receiver); - assert((app.use as any).calledOnce); - assert((router.get as any).called); - assert((router.post as any).calledOnce); + sinon.assert.calledOnce(app.use); + sinon.assert.calledOnce(router.get); + sinon.assert.calledOnce(router.post); }); - it('should throw an error if redirect uri options supplied invalid or incomplete', async function () { + it('should throw an error if redirect uri options supplied invalid or incomplete', async () => { const clientId = 'my-clientId'; const clientSecret = 'my-clientSecret'; const signingSecret = 'secret'; @@ -163,81 +137,82 @@ describe('ExpressReceiver', function () { }); assert.isNotNull(receiver); // missing redirectUriPath - assert.throws(() => new ExpressReceiver({ - clientId, - clientSecret, - signingSecret, - stateSecret, - scopes, - redirectUri, - }), AppInitializationError); + assert.throws( + () => + new ExpressReceiver({ + clientId, + clientSecret, + signingSecret, + stateSecret, + scopes, + redirectUri, + }), + AppInitializationError, + ); // inconsistent redirectUriPath - assert.throws(() => new ExpressReceiver({ - clientId: 'my-clientId', - clientSecret, - signingSecret, - stateSecret, - scopes, - redirectUri, - installerOptions: { - redirectUriPath: '/hiya', - }, - }), AppInitializationError); + assert.throws( + () => + new ExpressReceiver({ + clientId: 'my-clientId', + clientSecret, + signingSecret, + stateSecret, + scopes, + redirectUri, + installerOptions: { + redirectUriPath: '/hiya', + }, + }), + AppInitializationError, + ); // inconsistent redirectUri - assert.throws(() => new ExpressReceiver({ - clientId: 'my-clientId', - clientSecret, - signingSecret, - stateSecret, - scopes, - redirectUri: 'http://example.com/hiya', - installerOptions, - }), AppInitializationError); + assert.throws( + () => + new ExpressReceiver({ + clientId: 'my-clientId', + clientSecret, + signingSecret, + stateSecret, + scopes, + redirectUri: 'http://example.com/hiya', + installerOptions, + }), + AppInitializationError, + ); }); }); - describe('#start()', function () { - it('should start listening for requests using the built-in HTTP server', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + describe('#start()', () => { + it('should start listening for requests using the built-in HTTP server', async () => { const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '' }); const port = 12345; - // Act const server = await receiver.start(port); - // Assert - assert(this.fakeCreateServer.calledOnce); - assert.strictEqual(server, this.fakeServer); - assert(this.fakeServer.listen.calledWith(port)); + sinon.assert.calledOnce(fakeCreateServer); + assert.strictEqual(server, fakeServer as unknown as Server); + sinon.assert.calledWith(fakeServer.listen, port); }); - it('should start listening for requests using the built-in HTTPS (TLS) server when given TLS server options', async function () { - // Arrange - const overrides = mergeOverrides( + it('should start listening for requests using the built-in HTTPS (TLS) server when given TLS server options', async () => { + overrides = mergeOverrides( withHttpCreateServer(sinon.fake.throws('Should not be used.')), - withHttpsCreateServer(this.fakeCreateServer), + withHttpsCreateServer(fakeCreateServer), ); const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '' }); const port = 12345; const tlsOptions = { key: '', cert: '' }; - // Act const server = await receiver.start(port, tlsOptions); - // Assert - assert(this.fakeCreateServer.calledOnceWith(tlsOptions)); - assert.strictEqual(server, this.fakeServer); - assert(this.fakeServer.listen.calledWith(port)); + sinon.assert.calledWith(fakeCreateServer, tlsOptions); + assert.strictEqual(server, fakeServer as unknown as HTTPSServer); + sinon.assert.calledWith(fakeServer.listen, port); }); - it('should reject with an error when the built-in HTTP server fails to listen (such as EADDRINUSE)', async function () { - // Arrange + it('should reject with an error when the built-in HTTP server fails to listen (such as EADDRINUSE)', async () => { const fakeCreateFailingServer = sinon.fake.returns(new FakeServer(new Error('fake listening error'))); - const overrides = mergeOverrides( + overrides = mergeOverrides( withHttpCreateServer(fakeCreateFailingServer), withHttpsCreateServer(sinon.fake.throws('Should not be used.')), ); @@ -245,21 +220,18 @@ describe('ExpressReceiver', function () { const receiver = new ER({ signingSecret: '' }); const port = 12345; - // Act let caughtError: Error | undefined; try { await receiver.start(port); - } catch (error: any) { - caughtError = error; + } catch (error) { + caughtError = error as Error; } - // Assert assert.instanceOf(caughtError, Error); }); - it('should reject with an error when the built-in HTTP server returns undefined', async function () { - // Arrange + it('should reject with an error when the built-in HTTP server returns undefined', async () => { const fakeCreateUndefinedServer = sinon.fake.returns(undefined); - const overrides = mergeOverrides( + overrides = mergeOverrides( withHttpCreateServer(fakeCreateUndefinedServer), withHttpsCreateServer(sinon.fake.throws('Should not be used.')), ); @@ -267,255 +239,190 @@ describe('ExpressReceiver', function () { const receiver = new ER({ signingSecret: '' }); const port = 12345; - // Act let caughtError: Error | undefined; try { await receiver.start(port); - } catch (error: any) { - caughtError = error; + } catch (error) { + caughtError = error as Error; } - // Assert assert.instanceOf(caughtError, ReceiverInconsistentStateError); - assert.equal((caughtError as CodedError).code, ErrorCode.ReceiverInconsistentStateError); + assert.propertyVal(caughtError, 'code', ErrorCode.ReceiverInconsistentStateError); }); - it('should reject with an error when starting and the server was already previously started', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + it('should reject with an error when starting and the server was already previously started', async () => { const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '' }); const port = 12345; - // Act let caughtError: Error | undefined; await receiver.start(port); try { await receiver.start(port); - } catch (error: any) { - caughtError = error; + } catch (error) { + caughtError = error as Error; } - // Assert assert.instanceOf(caughtError, ReceiverInconsistentStateError); - assert.equal((caughtError as CodedError).code, ErrorCode.ReceiverInconsistentStateError); + assert.propertyVal(caughtError, 'code', ErrorCode.ReceiverInconsistentStateError); }); }); - describe('#stop()', function () { - it('should stop listening for requests when a built-in HTTP server is already started', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + describe('#stop()', () => { + it('should stop listening for requests when a built-in HTTP server is already started', async () => { const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '' }); const port = 12345; await receiver.start(port); - // Act await receiver.stop(); - - // Assert - // As long as control reaches this point, the test passes - assert.isOk(true); }); - it('should reject when a built-in HTTP server is not started', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + it('should reject when a built-in HTTP server is not started', async () => { const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '' }); - // Act let caughtError: Error | undefined; try { await receiver.stop(); - } catch (error: any) { - caughtError = error; + } catch (error) { + caughtError = error as Error; } - // Assert - // As long as control reaches this point, the test passes assert.instanceOf(caughtError, ReceiverInconsistentStateError); - assert.equal((caughtError as CodedError).code, ErrorCode.ReceiverInconsistentStateError); + assert.propertyVal(caughtError, 'code', ErrorCode.ReceiverInconsistentStateError); }); - it('should reject when a built-in HTTP server raises an error when closing', async function () { - // Arrange - this.fakeServer = new FakeServer(undefined, new Error('this error will be raised by the underlying HTTP server during close()')); - this.fakeCreateServer = sinon.fake.returns(this.fakeServer); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), + it('should reject when a built-in HTTP server raises an error when closing', async () => { + fakeServer = new FakeServer( + undefined, + new Error('this error will be raised by the underlying HTTP server during close()'), + ); + fakeCreateServer = sinon.fake.returns(fakeServer); + overrides = mergeOverrides( + withHttpCreateServer(fakeCreateServer), withHttpsCreateServer(sinon.fake.throws('Should not be used.')), ); const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '' }); await receiver.start(12345); - // Act let caughtError: Error | undefined; try { await receiver.stop(); - } catch (error: any) { - caughtError = error; + } catch (error) { + caughtError = error as Error; } - // Assert - // As long as control reaches this point, the test passes assert.instanceOf(caughtError, Error); assert.equal(caughtError?.message, 'this error will be raised by the underlying HTTP server during close()'); }); }); - describe('#requestHandler()', function () { - before(function () { - this.extractRetryNumStub = sinon.stub(httpFunc, 'extractRetryNumFromHTTPRequest'); - this.extractRetryReasonStub = sinon.stub(httpFunc, 'extractRetryReasonFromHTTPRequest'); - this.buildNoBodyResponseStub = sinon.stub(httpFunc, 'buildNoBodyResponse'); - this.buildContentResponseStub = sinon.stub(httpFunc, 'buildContentResponse'); - this.processStub = sinon.stub().resolves({}); - this.ackStub = function ackStub() {}; - this.ackStub.prototype.bind = function () { return this; }; - this.ackStub.prototype.ack = sinon.spy(); + describe('#requestHandler()', () => { + const extractRetryNumStub = sinon.stub(httpFunc, 'extractRetryNumFromHTTPRequest'); + const extractRetryReasonStub = sinon.stub(httpFunc, 'extractRetryReasonFromHTTPRequest'); + const buildNoBodyResponseStub = sinon.stub(httpFunc, 'buildNoBodyResponse'); + const buildContentResponseStub = sinon.stub(httpFunc, 'buildContentResponse'); + const processStub = sinon.stub<[ReceiverEvent]>().resolves({}); + const ackStub = function ackStub() {}; + ackStub.prototype.bind = function () { + return this; + }; + ackStub.prototype.ack = sinon.spy(); + beforeEach(() => { + overrides = mergeOverrides( + withHttpCreateServer(fakeCreateServer), + withHttpsCreateServer(sinon.fake.throws('Should not be used.')), + { './HTTPResponseAck': { HTTPResponseAck: ackStub } }, + ); }); afterEach(() => { sinon.reset(); }); - after(function () { - this.extractRetryNumStub.restore(); - this.extractRetryReasonStub.restore(); - this.buildNoBodyResponseStub.restore(); - this.buildContentResponseStub.restore(); + after(() => { + extractRetryNumStub.restore(); + extractRetryReasonStub.restore(); + buildNoBodyResponseStub.restore(); + buildContentResponseStub.restore(); }); - it('should not build an HTTP response if processBeforeResponse=false', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - { './HTTPResponseAck': { HTTPResponseAck: this.ackStub } }, - ); + it('should not build an HTTP response if processBeforeResponse=false', async () => { const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '' }); - const app = sinon.createStubInstance(App, { processEvent: this.processStub }) as unknown as App; + const app = sinon.createStubInstance(App, { processEvent: processStub }) as unknown as App; receiver.init(app); - // Act - const req = { body: { } } as Request; - const resp = { send: () => { } } as Response; + const req = { body: {} } as Request; + const resp = { send: () => {} } as Response; await receiver.requestHandler(req, resp); - // Assert - assert(this.buildContentResponseStub.notCalled, 'HTTPFunction buildContentResponse called incorrectly'); + sinon.assert.notCalled(buildContentResponseStub); }); - it('should build an HTTP response if processBeforeResponse=true', async function () { - // Arrange - this.processStub.callsFake((event: any) => { - // eslint-disable-next-line no-param-reassign + it('should build an HTTP response if processBeforeResponse=true', async () => { + // biome-ignore lint/suspicious/noExplicitAny: TODO: dig in to see what this type actually is supposed to be + processStub.callsFake((event: any) => { event.ack.storedResponse = 'something'; return Promise.resolve({}); }); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - { './HTTPResponseAck': { HTTPResponseAck: this.ackStub } }, - ); const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '' }); - const app = sinon.createStubInstance(App, { processEvent: this.processStub }) as unknown as App; + const app = sinon.createStubInstance(App, { processEvent: processStub }) as unknown as App; receiver.init(app); - // Act - const req = { body: { } } as Request; - const resp = { send: () => { } } as Response; + const req = { body: {} } as Request; + const resp = { send: () => {} } as Response; await receiver.requestHandler(req, resp); - // Assert - assert(this.buildContentResponseStub.called, 'HTTPFunction buildContentResponse not called incorrectly'); + sinon.assert.called(buildContentResponseStub); }); - it('should throw and build an HTTP 500 response with no body if processEvent raises an uncoded Error or a coded, non-Authorization Error', async function () { - // Arrange - this.processStub.callsFake(() => Promise.reject(new Error('uh oh'))); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - { './HTTPResponseAck': { HTTPResponseAck: this.ackStub } }, - ); + it('should throw and build an HTTP 500 response with no body if processEvent raises an uncoded Error or a coded, non-Authorization Error', async () => { + processStub.callsFake(() => Promise.reject(new Error('uh oh'))); const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '' }); - const app = sinon.createStubInstance(App, { processEvent: this.processStub }) as unknown as App; + const app = sinon.createStubInstance(App, { processEvent: processStub }) as unknown as App; receiver.init(app); - // Act - const req = { body: { } } as Request; - let writeHeadStatus = 0; + const req = { body: {} } as Request; const resp = { - send: () => { }, - writeHead: (status: number) => { writeHeadStatus = status; }, - end: () => { }, - } as unknown as Response; - await receiver.requestHandler(req, resp); - - // Assert - assert.equal(writeHeadStatus, 500); + send: sinon.fake(), + writeHead: sinon.fake(), + end: sinon.fake(), + }; + await receiver.requestHandler(req, resp as unknown as Response); + sinon.assert.calledWith(resp.writeHead, 500); }); - it('should build an HTTP 401 response with no body and call ack() if processEvent raises a coded AuthorizationError', async function () { - // Arrange - this.processStub.callsFake(() => Promise.reject(new AuthorizationError('uh oh', new Error()))); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - { './HTTPResponseAck': { HTTPResponseAck: this.ackStub } }, - ); + it('should build an HTTP 401 response with no body and call ack() if processEvent raises a coded AuthorizationError', async () => { + processStub.callsFake(() => Promise.reject(new AuthorizationError('uh oh', new Error()))); const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '' }); - const app = sinon.createStubInstance(App, { processEvent: this.processStub }) as unknown as App; + const app = sinon.createStubInstance(App, { processEvent: processStub }) as unknown as App; receiver.init(app); - // Act - const req = { body: { } } as Request; - let writeHeadStatus = 0; + const req = { body: {} } as Request; const resp = { - send: () => { }, - writeHead: (status: number) => { writeHeadStatus = status; }, - end: () => { }, - } as unknown as Response; - await receiver.requestHandler(req, resp); - // Assert - assert.equal(writeHeadStatus, 401); + send: sinon.fake(), + writeHead: sinon.fake(), + end: sinon.fake(), + }; + await receiver.requestHandler(req, resp as unknown as Response); + sinon.assert.calledWith(resp.writeHead, 401); }); }); - describe('oauth support', function () { - describe('install path route', function () { - it('should call into installer.handleInstallPath when HTTP GET request hits the install path', async function () { - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + describe('oauth support', () => { + describe('install path route', () => { + it('should call into installer.handleInstallPath when HTTP GET request hits the install path', async () => { const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '', clientSecret: '', clientId: '', stateSecret: '' }); - const handleStub = sinon.stub(receiver.installer as any, 'handleInstallPath').resolves(); + const handleStub = sinon.stub(receiver.installer as InstallProvider, 'handleInstallPath').resolves(); - // Act - const req = { body: { }, url: 'http://localhost/slack/install', method: 'GET' } as Request; - const resp = { send: () => { } } as Response; + const req = { body: {}, url: 'http://localhost/slack/install', method: 'GET' } as Request; + const resp = { send: () => {} } as Response; const next = sinon.spy(); + // biome-ignore lint/suspicious/noExplicitAny: TODO: better way to get a reference to handle? dealing with express internals, unclear (receiver.router as any).handle(req, resp, next); - // Assert - assert(handleStub.calledWith(req, resp), 'installer.handleInstallPath not called'); + sinon.assert.calledWith(handleStub, req, resp); }); }); - describe('redirect path route', function () { - it('should call installer.handleCallback with callbackOptions when HTTP request hits the redirect URI path and stateVerification=true', async function () { - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + describe('redirect path route', () => { + it('should call installer.handleCallback with callbackOptions when HTTP request hits the redirect URI path and stateVerification=true', async () => { const ER = await importExpressReceiver(overrides); const callbackOptions = {}; const scopes = ['some']; @@ -523,22 +430,24 @@ describe('ExpressReceiver', function () { stateVerification: true, callbackOptions, }; - const receiver = new ER({ signingSecret: '', clientSecret: '', clientId: '', stateSecret: '', scopes, installerOptions }); - const handleStub = sinon.stub(receiver.installer as any, 'handleCallback').resolves('poop'); - - // Act - const req = { body: { }, url: 'http://localhost/slack/oauth_redirect', method: 'GET' } as Request; - const resp = { send: () => { } } as Response; + const receiver = new ER({ + signingSecret: '', + clientSecret: '', + clientId: '', + stateSecret: '', + scopes, + installerOptions, + }); + const handleStub = sinon.stub(receiver.installer as InstallProvider, 'handleCallback').resolves(); + + const req = { body: {}, url: 'http://localhost/slack/oauth_redirect', method: 'GET' } as Request; + const resp = { send: () => {} } as Response; + // biome-ignore lint/suspicious/noExplicitAny: TODO: better way to get a reference to handle? dealing with express internals, unclear (receiver.router as any).handle(req, resp, () => {}); - // Assert - assert(handleStub.calledWith(req, resp, callbackOptions), 'installer.handleCallback not called'); + sinon.assert.calledWith(handleStub, req, resp, callbackOptions); }); - it('should call installer.handleCallback with callbackOptions and installUrlOptions when HTTP request hits the redirect URI path and stateVerification=false', async function () { - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + it('should call installer.handleCallback with callbackOptions and installUrlOptions when HTTP request hits the redirect URI path and stateVerification=false', async () => { const ER = await importExpressReceiver(overrides); const callbackOptions = {}; const scopes = ['some']; @@ -546,132 +455,83 @@ describe('ExpressReceiver', function () { stateVerification: false, callbackOptions, }; - const receiver = new ER({ signingSecret: '', clientSecret: '', clientId: '', stateSecret: '', scopes, installerOptions }); - const handleStub = sinon.stub(receiver.installer as any, 'handleCallback').resolves('poop'); - - // Act - const req = { body: { }, url: 'http://localhost/slack/oauth_redirect', method: 'GET' } as Request; - const resp = { send: () => { } } as Response; + const receiver = new ER({ + signingSecret: '', + clientSecret: '', + clientId: '', + stateSecret: '', + scopes, + installerOptions, + }); + const handleStub = sinon.stub(receiver.installer as InstallProvider, 'handleCallback').resolves(); + + const req = { body: {}, url: 'http://localhost/slack/oauth_redirect', method: 'GET' } as Request; + const resp = { send: () => {} } as Response; + // biome-ignore lint/suspicious/noExplicitAny: TODO: better way to get a reference to handle? dealing with express internals, unclear (receiver.router as any).handle(req, resp, () => {}); - // Assert - assert(handleStub.calledWith(req, resp, callbackOptions, sinon.match({ scopes })), 'installer.handleCallback not called'); + sinon.assert.calledWith(handleStub, req, resp, callbackOptions, sinon.match({ scopes })); }); }); }); - describe('state management for built-in server', function () { - it('should be able to start after it was stopped', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + describe('state management for built-in server', () => { + it('should be able to start after it was stopped', async () => { const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '' }); const port = 12345; await receiver.start(port); await receiver.stop(); - - // Act await receiver.start(port); - - // Assert - // As long as control reaches this point, the test passes - assert.isOk(true); }); }); describe('built-in middleware', () => { describe('ssl_check request handler', () => { - it('should handle valid requests', async () => { - // Arrange + it('should handle valid ssl_check requests and not call next()', async () => { const req = { body: { ssl_check: 1 } } as Request; - let sent = false; const resp = { - send: () => { - sent = true; - }, - } as Response; - let errorResult: any; - const next = (error: any) => { - errorResult = error; + send: sinon.fake(), }; - - // Act - respondToSslCheck(req, resp, next); - - // Assert - assert.isTrue(sent); - assert.isUndefined(errorResult); + const next = sinon.spy(); + respondToSslCheck(req, resp as unknown as Response, next); + sinon.assert.called(resp.send); + sinon.assert.notCalled(next); }); it('should work with other requests', async () => { - // Arrange const req = { body: { type: 'block_actions' } } as Request; - let sent = false; const resp = { - send: () => { - sent = true; - }, - } as Response; - let errorResult: any; - const next = (error: any) => { - errorResult = error; + send: sinon.fake(), }; - - // Act - respondToSslCheck(req, resp, next); - - // Assert - assert.isFalse(sent); - assert.isUndefined(errorResult); + const next = sinon.spy(); + respondToSslCheck(req, resp as unknown as Response, next); + sinon.assert.notCalled(resp.send); + sinon.assert.called(next); }); }); describe('url_verification request handler', () => { it('should handle valid requests', async () => { - // Arrange const req = { body: { type: 'url_verification', challenge: 'this is it' } } as Request; - let sentBody; const resp = { - json: (body) => { - sentBody = body; - }, - } as Response; - let errorResult: any; - const next = (error: any) => { - errorResult = error; + json: sinon.fake(), }; - - // Act - respondToUrlVerification(req, resp, next); - - // Assert - assert.equal(JSON.stringify(sentBody), JSON.stringify({ challenge: 'this is it' })); - assert.isUndefined(errorResult); + const next = sinon.spy(); + respondToUrlVerification(req, resp as unknown as Response, next); + sinon.assert.calledWith(resp.json, sinon.match({ challenge: 'this is it' })); + sinon.assert.notCalled(next); }); it('should work with other requests', async () => { - // Arrange const req = { body: { ssl_check: 1 } } as Request; - let sentBody; const resp = { - json: (body) => { - sentBody = body; - }, - } as Response; - let errorResult: any; - const next = (error: any) => { - errorResult = error; + json: sinon.fake(), }; - - // Act - respondToUrlVerification(req, resp, next); - - // Assert - assert.isUndefined(sentBody); - assert.isUndefined(errorResult); + const next = sinon.spy(); + respondToUrlVerification(req, resp as unknown as Response, next); + sinon.assert.notCalled(resp.json); + sinon.assert.called(next); }); }); }); @@ -693,13 +553,15 @@ describe('ExpressReceiver', function () { const signingSecret = '8f742231b10e8888abcd99yyyzzz85a5'; const signature = 'v0=a2114d57b48eac39b9ad189dd8316235a7b4a8d21a10bd27519666489c69b503'; const requestTimestamp = 1531420618; - const body = 'token=xyzz0WbapA4vBCDEFasx0q6G&team_id=T1DC2JH3J&team_domain=testteamnow&channel_id=G8PSS9T3V&channel_name=foobar&user_id=U2CERLKJA&user_name=roadrunner&command=%2Fwebhook-collect&text=&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT1DC2JH3J%2F397700885554%2F96rGlfmibIGlgcZRskXaIFfN&trigger_id=398738663015.47445629121.803a0bc887a14d10d2c447fce8b6703c'; + const body = + 'token=xyzz0WbapA4vBCDEFasx0q6G&team_id=T1DC2JH3J&team_domain=testteamnow&channel_id=G8PSS9T3V&channel_name=foobar&user_id=U2CERLKJA&user_name=roadrunner&command=%2Fwebhook-collect&text=&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT1DC2JH3J%2F397700885554%2F96rGlfmibIGlgcZRskXaIFfN&trigger_id=398738663015.47445629121.803a0bc887a14d10d2c447fce8b6703c'; function buildExpressRequest(): Request { const reqAsStream = new Readable(); reqAsStream.push(body); reqAsStream.push(null); // indicate EOF - (reqAsStream as { [key: string]: any }).headers = { + // biome-ignore lint/suspicious/noExplicitAny: mock requests can be anything + (reqAsStream as Record).headers = { 'x-slack-signature': signature, 'x-slack-request-timestamp': requestTimestamp, 'content-type': 'application/x-www-form-urlencoded', @@ -709,7 +571,8 @@ describe('ExpressReceiver', function () { } function buildGCPRequest(): Request { - const untypedReq: { [key: string]: any } = { + // biome-ignore lint/suspicious/noExplicitAny: mock requests can be anything + const untypedReq: Record = { rawBody: body, headers: { 'x-slack-signature': signature, @@ -726,39 +589,37 @@ describe('ExpressReceiver', function () { async function runWithValidRequest( req: Request, + // biome-ignore lint/suspicious/noExplicitAny: mock requests can be anything state: any, signingSecretFn?: () => PromiseLike, ): Promise { - // Arrange const resp = buildResponseToVerify(state); + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything const next = (error: any) => { - // eslint-disable-next-line no-param-reassign state.error = error; }; - - // Act const verifier = verifySignatureAndParseRawBody(noopLogger, signingSecretFn || signingSecret); await verifier(req, resp, next); } it('should verify requests', async () => { + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything const state: any = {}; await runWithValidRequest(buildExpressRequest(), state); - // Assert assert.isUndefined(state.error); }); it('should verify requests on GCP', async () => { + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything const state: any = {}; await runWithValidRequest(buildGCPRequest(), state); - // Assert assert.isUndefined(state.error); }); it('should verify requests on GCP using async signingSecret', async () => { + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything const state: any = {}; await runWithValidRequest(buildGCPRequest(), state, () => Promise.resolve(signingSecret)); - // Assert assert.isUndefined(state.error); }); @@ -766,21 +627,21 @@ describe('ExpressReceiver', function () { // parse error it('should verify requests and then catch parse failures', async () => { + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything const state: any = {}; const req = buildExpressRequest(); req.headers['content-type'] = undefined; await runWithValidRequest(req, state); - // Assert assert.equal(state.code, 400); assert.equal(state.sent, true); }); it('should verify requests on GCP and then catch parse failures', async () => { + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything const state: any = {}; const req = buildGCPRequest(); req.headers['content-type'] = undefined; await runWithValidRequest(req, state); - // Assert assert.equal(state.code, 400); assert.equal(state.sent, true); }); @@ -789,17 +650,12 @@ describe('ExpressReceiver', function () { // verifyContentTypeAbsence async function verifyRequestsWithoutContentTypeHeader(req: Request): Promise { - // Arrange + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything const result: any = {}; const resp = buildResponseToVerify(result); - const next = sinon.fake(); - - // Act const verifier = verifySignatureAndParseRawBody(noopLogger, signingSecret); await verifier(req, resp, next); - - // Assert assert.equal(result.code, 400); assert.equal(result.sent, true); } @@ -808,7 +664,8 @@ describe('ExpressReceiver', function () { const reqAsStream = new Readable(); reqAsStream.push(body); reqAsStream.push(null); // indicate EOF - (reqAsStream as { [key: string]: any }).headers = { + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything + (reqAsStream as any).headers = { 'x-slack-signature': signature, 'x-slack-request-timestamp': requestTimestamp, // 'content-type': 'application/x-www-form-urlencoded', @@ -818,7 +675,8 @@ describe('ExpressReceiver', function () { }); it('should verify parse request body without content-type header on GCP', async () => { - const untypedReq: { [key: string]: any } = { + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything + const untypedReq: any = { rawBody: body, headers: { 'x-slack-signature': signature, @@ -834,16 +692,12 @@ describe('ExpressReceiver', function () { // verifyMissingHeaderDetection async function verifyMissingHeaderDetection(req: Request): Promise { - // Arrange + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything const result: any = {}; const resp = buildResponseToVerify(result); const next = sinon.fake(); - - // Act const verifier = verifySignatureAndParseRawBody(noopLogger, signingSecret); await verifier(req, resp, next); - - // Assert assert.equal(result.code, 401); assert.equal(result.sent, true); } @@ -852,7 +706,8 @@ describe('ExpressReceiver', function () { const reqAsStream = new Readable(); reqAsStream.push(body); reqAsStream.push(null); // indicate EOF - (reqAsStream as { [key: string]: any }).headers = { + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything + (reqAsStream as any).headers = { // 'x-slack-signature': signature , 'x-slack-request-timestamp': requestTimestamp, 'content-type': 'application/x-www-form-urlencoded', @@ -864,7 +719,8 @@ describe('ExpressReceiver', function () { const reqAsStream = new Readable(); reqAsStream.push(body); reqAsStream.push(null); // indicate EOF - (reqAsStream as { [key: string]: any }).headers = { + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything + (reqAsStream as any).headers = { 'x-slack-signature': signature, /* 'x-slack-request-timestamp': requestTimestamp, */ 'content-type': 'application/x-www-form-urlencoded', @@ -873,11 +729,11 @@ describe('ExpressReceiver', function () { }); it('should detect headers missing on GCP', async () => { - const untypedReq: { [key: string]: any } = { + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything + const untypedReq: any = { rawBody: body, headers: { 'x-slack-signature': signature, - /* 'x-slack-request-timestamp': requestTimestamp, */ 'content-type': 'application/x-www-form-urlencoded', }, }; @@ -888,17 +744,12 @@ describe('ExpressReceiver', function () { // verifyInvalidTimestampError async function verifyInvalidTimestampError(req: Request): Promise { - // Arrange + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything const result: any = {}; const resp = buildResponseToVerify(result); const next = sinon.fake(); - - // Act - const verifier = verifySignatureAndParseRawBody(noopLogger, signingSecret); await verifier(req, resp, next); - - // Assert assert.equal(result.code, 401); assert.equal(result.sent, true); } @@ -907,7 +758,8 @@ describe('ExpressReceiver', function () { const reqAsStream = new Readable(); reqAsStream.push(body); reqAsStream.push(null); // indicate EOF - (reqAsStream as { [key: string]: any }).headers = { + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything + (reqAsStream as any).headers = { 'x-slack-signature': signature, 'x-slack-request-timestamp': 'Hello there!', 'content-type': 'application/x-www-form-urlencoded', @@ -919,19 +771,14 @@ describe('ExpressReceiver', function () { // verifyTooOldTimestampError async function verifyTooOldTimestampError(req: Request): Promise { - // Arrange // restore the valid clock clock.restore(); - + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything const result: any = {}; const resp = buildResponseToVerify(result); const next = sinon.fake(); - - // Act const verifier = verifySignatureAndParseRawBody(noopLogger, signingSecret); await verifier(req, resp, next); - - // Assert assert.equal(result.code, 401); assert.equal(result.sent, true); } @@ -948,17 +795,13 @@ describe('ExpressReceiver', function () { // verifySignatureMismatch async function verifySignatureMismatch(req: Request): Promise { - // Arrange + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything const result: any = {}; const resp = buildResponseToVerify(result); const next = sinon.fake(); - - // Act const verifier = verifySignatureAndParseRawBody(noopLogger, signingSecret); verifier(req, resp, next); await verifier(req, resp, next); - - // Assert assert.equal(result.code, 401); assert.equal(result.sent, true); } @@ -967,7 +810,8 @@ describe('ExpressReceiver', function () { const reqAsStream = new Readable(); reqAsStream.push(body); reqAsStream.push(null); // indicate EOF - (reqAsStream as { [key: string]: any }).headers = { + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything + (reqAsStream as any).headers = { 'x-slack-signature': signature, 'x-slack-request-timestamp': requestTimestamp + 10, 'content-type': 'application/x-www-form-urlencoded', @@ -977,7 +821,8 @@ describe('ExpressReceiver', function () { }); it('should detect signature mismatch on GCP', async () => { - const untypedReq: { [key: string]: any } = { + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything + const untypedReq: any = { rawBody: body, headers: { 'x-slack-signature': signature, @@ -991,87 +836,67 @@ describe('ExpressReceiver', function () { }); describe('buildBodyParserMiddleware', () => { - beforeEach(function () { - this.req = { body: { }, headers: { 'content-type': 'application/json' } } as Request; - this.res = { send: () => { } } as Response; - this.next = sinon.spy(); + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything when testing + let req: any = { body: {}, headers: { 'content-type': 'application/json' } }; + const res = { send: sinon.spy() }; + const next = sinon.spy(); + beforeEach(() => { + req = { body: {}, headers: { 'content-type': 'application/json' } }; + res.send.resetHistory(); + next.resetHistory(); }); - it('should JSON.parse a stringified rawBody if exists on a application/json request', async function () { - this.req.rawBody = '{"awesome": true}'; + it('should JSON.parse a stringified rawBody if exists on a application/json request', async () => { + req.rawBody = '{"awesome": true}'; const parser = buildBodyParserMiddleware(createFakeLogger()); - await parser(this.req, this.res, this.next); - assert(this.next.called, 'next() was not called'); - assert.equal(this.req.body.awesome, true, 'request body JSON was not parsed'); + await parser(req, res as unknown as Response, next); + sinon.assert.called(next); + assert.equal(req.body.awesome, true, 'request body JSON was not parsed'); }); - it('should querystring.parse a stringified rawBody if exists on a application/x-www-form-urlencoded request', async function () { - this.req.headers['content-type'] = 'application/x-www-form-urlencoded'; - this.req.rawBody = 'awesome=true'; + it('should querystring.parse a stringified rawBody if exists on a application/x-www-form-urlencoded request', async () => { + req.headers['content-type'] = 'application/x-www-form-urlencoded'; + req.rawBody = 'awesome=true'; const parser = buildBodyParserMiddleware(createFakeLogger()); - await parser(this.req, this.res, this.next); - assert(this.next.called, 'next() was not called'); - assert.equal(this.req.body.awesome, 'true', 'request body form-urlencoded was not parsed'); + await parser(req, res as unknown as Response, next); + sinon.assert.called(next); + assert.equal(req.body.awesome, 'true', 'request body form-urlencoded was not parsed'); }); - it('should JSON.parse a stringified rawBody payload if exists on a application/x-www-form-urlencoded request', async function () { - this.req.headers['content-type'] = 'application/x-www-form-urlencoded'; - this.req.rawBody = 'payload=%7B%22awesome%22:true%7D'; + it('should JSON.parse a stringified rawBody payload if exists on a application/x-www-form-urlencoded request', async () => { + req.headers['content-type'] = 'application/x-www-form-urlencoded'; + req.rawBody = 'payload=%7B%22awesome%22:true%7D'; const parser = buildBodyParserMiddleware(createFakeLogger()); - await parser(this.req, this.res, this.next); - assert(this.next.called, 'next() was not called'); - assert.equal(this.req.body.awesome, true, 'request body form-urlencoded was not parsed'); - }); - it('should JSON.parse a body if exists on a application/json request', async function () { - this.req = new Readable(); - this.req.push('{"awesome": true}'); - this.req.push(null); - this.req.headers = { 'content-type': 'application/json' }; + await parser(req, res as unknown as Response, next); + sinon.assert.called(next); + assert.equal(req.body.awesome, true, 'request body form-urlencoded was not parsed'); + }); + it('should JSON.parse a body if exists on a application/json request', async () => { + req = new Readable(); + req.push('{"awesome": true}'); + req.push(null); + req.headers = { 'content-type': 'application/json' }; const parser = buildBodyParserMiddleware(createFakeLogger()); - await parser(this.req, this.res, this.next); - assert(this.next.called, 'next() was not called'); - assert.equal(this.req.body.awesome, true, 'request body JSON was not parsed'); - }); - it('should querystring.parse a body if exists on a application/x-www-form-urlencoded request', async function () { - this.req = new Readable(); - this.req.push('awesome=true'); - this.req.push(null); - this.req.headers = { 'content-type': 'application/x-www-form-urlencoded' }; + await parser(req, res as unknown as Response, next); + sinon.assert.called(next); + assert.equal(req.body.awesome, true, 'request body JSON was not parsed'); + }); + it('should querystring.parse a body if exists on a application/x-www-form-urlencoded request', async () => { + req = new Readable(); + req.push('awesome=true'); + req.push(null); + req.headers = { 'content-type': 'application/x-www-form-urlencoded' }; const parser = buildBodyParserMiddleware(createFakeLogger()); - await parser(this.req, this.res, this.next); - assert(this.next.called, 'next() was not called'); - assert.equal(this.req.body.awesome, 'true', 'request body form-urlencoded was not parsed'); - }); - it('should JSON.parse a body payload if exists on a application/x-www-form-urlencoded request', async function () { - this.req = new Readable(); - this.req.push('payload=%7B%22awesome%22:true%7D'); - this.req.push(null); - this.req.headers = { 'content-type': 'application/x-www-form-urlencoded' }; + await parser(req, res as unknown as Response, next); + assert(next.called, 'next() was not called'); + assert.equal(req.body.awesome, 'true', 'request body form-urlencoded was not parsed'); + }); + it('should JSON.parse a body payload if exists on a application/x-www-form-urlencoded request', async () => { + req = new Readable(); + req.push('payload=%7B%22awesome%22:true%7D'); + req.push(null); + req.headers = { 'content-type': 'application/x-www-form-urlencoded' }; const parser = buildBodyParserMiddleware(createFakeLogger()); - await parser(this.req, this.res, this.next); - assert(this.next.called, 'next() was not called'); - assert.equal(this.req.body.awesome, true, 'request body form-urlencoded was not parsed'); + await parser(req, res as unknown as Response, next); + sinon.assert.called(next); + assert.equal(req.body.awesome, true, 'request body form-urlencoded was not parsed'); }); }); }); - -/* Testing Harness */ - -// Loading the system under test using overrides -async function importExpressReceiver(overrides: Override = {}): Promise { - return (await rewiremock.module(() => import('./ExpressReceiver'), overrides)).default; -} - -// Composable overrides -function withHttpCreateServer(spy: SinonSpy): Override { - return { - http: { - createServer: spy, - }, - }; -} - -function withHttpsCreateServer(spy: SinonSpy): Override { - return { - https: { - createServer: spy, - }, - }; -} diff --git a/src/receivers/HTTPModuleFunctions.spec.ts b/test/unit/receivers/HTTPModuleFunctions.spec.ts similarity index 68% rename from src/receivers/HTTPModuleFunctions.spec.ts rename to test/unit/receivers/HTTPModuleFunctions.spec.ts index 0ab3da33d..7db177f25 100644 --- a/src/receivers/HTTPModuleFunctions.spec.ts +++ b/test/unit/receivers/HTTPModuleFunctions.spec.ts @@ -1,43 +1,29 @@ -import 'mocha'; -import { IncomingMessage, ServerResponse } from 'http'; -import { createHmac } from 'crypto'; -import sinon from 'sinon'; +import { createHmac } from 'node:crypto'; +import { IncomingMessage, ServerResponse } from 'node:http'; import { assert } from 'chai'; +import sinon from 'sinon'; -import { - ReceiverMultipleAckError, - HTTPReceiverDeferredRequestError, - AuthorizationError, -} from '../errors'; -import { HTTPModuleFunctions as func } from './HTTPModuleFunctions'; -import { createFakeLogger } from '../test-helpers'; -import { BufferedIncomingMessage } from './BufferedIncomingMessage'; +import { AuthorizationError, HTTPReceiverDeferredRequestError, ReceiverMultipleAckError } from '../../../src/errors'; +import type { BufferedIncomingMessage } from '../../../src/receivers/BufferedIncomingMessage'; +import * as func from '../../../src/receivers/HTTPModuleFunctions'; +import { createFakeLogger } from '../helpers'; describe('HTTPModuleFunctions', async () => { describe('Request header extraction', async () => { describe('extractRetryNumFromHTTPRequest', async () => { it('should work when the header does not exist', async () => { const req = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - if (req.headers === undefined) { // sinon on older Node.js may not return an object here - req.headers = {}; - } const result = func.extractRetryNumFromHTTPRequest(req); assert.isUndefined(result); }); it('should parse a single value header', async () => { const req = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - if (req.headers === undefined) { // sinon on older Node.js may not return an object here - req.headers = {}; - } req.headers['x-slack-retry-num'] = '2'; const result = func.extractRetryNumFromHTTPRequest(req); assert.equal(result, 2); }); it('should parse an array of value headers', async () => { const req = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - if (req.headers === undefined) { // sinon on older Node.js may not return an object here - req.headers = {}; - } req.headers['x-slack-retry-num'] = ['2']; const result = func.extractRetryNumFromHTTPRequest(req); assert.equal(result, 2); @@ -46,26 +32,17 @@ describe('HTTPModuleFunctions', async () => { describe('extractRetryReasonFromHTTPRequest', async () => { it('should work when the header does not exist', async () => { const req = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - if (req.headers === undefined) { // sinon on older Node.js may not return an object here - req.headers = {}; - } const result = func.extractRetryReasonFromHTTPRequest(req); assert.isUndefined(result); }); it('should parse a valid header', async () => { const req = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - if (req.headers === undefined) { // sinon on older Node.js may not return an object here - req.headers = {}; - } req.headers['x-slack-retry-reason'] = 'timeout'; const result = func.extractRetryReasonFromHTTPRequest(req); assert.equal(result, 'timeout'); }); it('should parse an array of value headers', async () => { const req = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - if (req.headers === undefined) { // sinon on older Node.js may not return an object here - req.headers = {}; - } req.headers['x-slack-retry-reason'] = ['timeout']; const result = func.extractRetryReasonFromHTTPRequest(req); assert.equal(result, 'timeout'); @@ -96,21 +73,15 @@ describe('HTTPModuleFunctions', async () => { describe('getHeader', async () => { it('should throw an exception when parsing a missing header', async () => { const req = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - if (req.headers === undefined) { // sinon on older Node.js may not return an object here - req.headers = {}; - } try { func.getHeader(req, 'Cookie'); assert.fail('Error should be thrown here'); } catch (e) { - assert.isTrue((e as any).message.length > 0); + assert.isTrue((e as Error).message.length > 0); } }); it('should parse a valid header', async () => { const req = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - if (req.headers === undefined) { // sinon on older Node.js may not return an object here - req.headers = {}; - } req.headers.Cookie = 'foo=bar'; const result = func.getHeader(req, 'Cookie'); assert.equal(result, 'foo=bar'); @@ -133,7 +104,7 @@ describe('HTTPModuleFunctions', async () => { 'x-slack-request-timestamp': timestamp, }, } as unknown as BufferedIncomingMessage; - const res: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; + const res = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; const result = await func.parseAndVerifyHTTPRequest({ signingSecret }, req, res); assert.isDefined(result.rawBody); }); @@ -156,7 +127,11 @@ describe('HTTPModuleFunctions', async () => { try { await func.parseAndVerifyHTTPRequest({ signingSecret }, req, res); } catch (e) { - assert.equal((e as any).message, 'Failed to verify authenticity: x-slack-request-timestamp must differ from system time by no more than 5 minutes or request is stale'); + assert.propertyVal( + e, + 'message', + 'Failed to verify authenticity: x-slack-request-timestamp must differ from system time by no more than 5 minutes or request is stale', + ); } }); it('should detect an invalid signature', async () => { @@ -175,7 +150,7 @@ describe('HTTPModuleFunctions', async () => { try { await func.parseAndVerifyHTTPRequest({ signingSecret }, req, res); } catch (e) { - assert.equal((e as any).message, 'Failed to verify authenticity: signature mismatch'); + assert.propertyVal(e, 'message', 'Failed to verify authenticity: signature mismatch'); } }); it('should parse a ssl_check request body without signature verification', async () => { @@ -207,7 +182,7 @@ describe('HTTPModuleFunctions', async () => { try { await func.parseAndVerifyHTTPRequest({ signingSecret }, req, res); } catch (e) { - assert.equal((e as any).message, 'Failed to verify authenticity: signature mismatch'); + assert.propertyVal(e, 'message', 'Failed to verify authenticity: signature mismatch'); } }); }); @@ -215,32 +190,26 @@ describe('HTTPModuleFunctions', async () => { describe('HTTP response builder methods', async () => { it('should have buildContentResponse', async () => { - const res: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - res.writeHead = writeHead; - func.buildContentResponse(res, 'OK'); - assert.isTrue(writeHead.calledWith(200)); + const res = sinon.createStubInstance(ServerResponse); + func.buildContentResponse(res as unknown as ServerResponse, 'OK'); + assert.isTrue(res.writeHead.calledWith(200)); }); it('should have buildNoBodyResponse', async () => { - const res: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - res.writeHead = writeHead; - func.buildNoBodyResponse(res, 500); - assert.isTrue(writeHead.calledWith(500)); + const res = sinon.createStubInstance(ServerResponse); + func.buildNoBodyResponse(res as unknown as ServerResponse, 500); + assert.isTrue(res.writeHead.calledWith(500)); }); it('should have buildSSLCheckResponse', async () => { - const res: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - res.writeHead = writeHead; - func.buildSSLCheckResponse(res); - assert.isTrue(writeHead.calledWith(200)); + const res = sinon.createStubInstance(ServerResponse); + func.buildSSLCheckResponse(res as unknown as ServerResponse); + assert.isTrue(res.writeHead.calledWith(200)); }); it('should have buildUrlVerificationResponse', async () => { - const res: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - res.writeHead = writeHead; - func.buildUrlVerificationResponse(res, { challenge: '3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P' }); - assert.isTrue(writeHead.calledWith(200)); + const res = sinon.createStubInstance(ServerResponse); + func.buildUrlVerificationResponse(res as unknown as ServerResponse, { + challenge: '3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P', + }); + assert.isTrue(res.writeHead.calledWith(200)); }); }); @@ -250,75 +219,65 @@ describe('HTTPModuleFunctions', async () => { describe('defaultDispatchErrorHandler', async () => { it('should properly handle ReceiverMultipleAckError', async () => { const request = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const response: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - response.writeHead = writeHead; + const response = sinon.createStubInstance(ServerResponse); func.defaultDispatchErrorHandler({ error: new ReceiverMultipleAckError(), logger, request, - response, + response: response as unknown as ServerResponse, }); - assert.isTrue(writeHead.calledWith(500)); + assert.isTrue(response.writeHead.calledWith(500)); }); it('should properly handle HTTPReceiverDeferredRequestError', async () => { const request = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const response: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - response.writeHead = writeHead; + const response = sinon.createStubInstance(ServerResponse); func.defaultDispatchErrorHandler({ - error: new HTTPReceiverDeferredRequestError('msg', request, response), + error: new HTTPReceiverDeferredRequestError('msg', request, response as unknown as ServerResponse), logger, request, - response, + response: response as unknown as ServerResponse, }); - assert.isTrue(writeHead.calledWith(404)); + assert.isTrue(response.writeHead.calledWith(404)); }); }); describe('defaultProcessEventErrorHandler', async () => { it('should properly handle ReceiverMultipleAckError', async () => { const request = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const response: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - response.writeHead = writeHead; + const response = sinon.createStubInstance(ServerResponse); func.defaultProcessEventErrorHandler({ error: new ReceiverMultipleAckError(), storedResponse: undefined, logger, request, - response, + response: response as unknown as ServerResponse, }); - assert.isTrue(writeHead.calledWith(500)); + assert.isTrue(response.writeHead.calledWith(500)); }); it('should properly handle AuthorizationError', async () => { const request = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const response: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - response.writeHead = writeHead; + const response = sinon.createStubInstance(ServerResponse); func.defaultProcessEventErrorHandler({ error: new AuthorizationError('msg', new Error()), storedResponse: undefined, logger, request, - response, + response: response as unknown as ServerResponse, }); - assert.isTrue(writeHead.calledWith(401)); + assert.isTrue(response.writeHead.calledWith(401)); }); }); describe('defaultUnhandledRequestHandler', async () => { it('should properly execute', async () => { const request = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const response: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - response.writeHead = writeHead; + const response = sinon.createStubInstance(ServerResponse); func.defaultUnhandledRequestHandler({ logger, request, - response, + response: response as unknown as ServerResponse, }); - assert.isTrue(writeHead.calledWith(404)); + assert.isTrue(response.writeHead.calledWith(404)); }); }); }); diff --git a/src/receivers/HTTPReceiver.spec.ts b/test/unit/receivers/HTTPReceiver.spec.ts similarity index 60% rename from src/receivers/HTTPReceiver.spec.ts rename to test/unit/receivers/HTTPReceiver.spec.ts index 6fc766435..dad1a3b18 100644 --- a/src/receivers/HTTPReceiver.spec.ts +++ b/test/unit/receivers/HTTPReceiver.spec.ts @@ -1,113 +1,56 @@ -import 'mocha'; -import { EventEmitter } from 'events'; -import { IncomingMessage, ServerResponse } from 'http'; -import sinon, { SinonSpy } from 'sinon'; -import { assert } from 'chai'; -import rewiremock from 'rewiremock'; -import { Logger, LogLevel } from '@slack/logger'; +import { IncomingMessage, ServerResponse } from 'node:http'; import { InstallProvider } from '@slack/oauth'; +import { assert } from 'chai'; +import type { ParamsDictionary } from 'express-serve-static-core'; import { match } from 'path-to-regexp'; -import { ParamsDictionary } from 'express-serve-static-core'; -import { Override, mergeOverrides } from '../test-helpers'; +import rewiremock from 'rewiremock'; +import sinon from 'sinon'; import { AppInitializationError, CustomRouteInitializationError, HTTPReceiverDeferredRequestError, -} from '../errors'; - -/* Testing Harness */ +} from '../../../src/errors'; +import type { CustomRoute } from '../../../src/receivers/custom-routes'; +import { + FakeServer, + type Override, + createFakeLogger, + mergeOverrides, + type noopVoid, + withHttpCreateServer, + withHttpsCreateServer, +} from '../helpers'; // Loading the system under test using overrides -async function importHTTPReceiver(overrides: Override = {}): Promise { - return (await rewiremock.module(() => import('./HTTPReceiver'), overrides)).default; -} - -// Composable overrides -function withHttpCreateServer(spy: SinonSpy): Override { - return { - http: { - createServer: spy, - }, - }; -} - -function withHttpsCreateServer(spy: SinonSpy): Override { - return { - https: { - createServer: spy, - }, - }; -} - -// Fakes -class FakeServer extends EventEmitter { - public on = sinon.fake(); - - public listen = sinon.fake((_listenOptions: any, cb: any) => { - if (this.listeningFailure !== undefined) { - this.emit('error', this.listeningFailure); - } - cb(); - }); - - public close = sinon.fake((...args: any[]) => { - setImmediate(() => { - this.emit('close'); - setImmediate(() => { - args[0](); - }); - }); - }); - - public constructor(private listeningFailure?: Error) { - super(); - } +async function importHTTPReceiver( + overrides: Override = {}, +): Promise { + return (await rewiremock.module(() => import('../../../src/receivers/HTTPReceiver'), overrides)).default; } -describe('HTTPReceiver', function () { - beforeEach(function () { - this.listener = (_req: any, _res: any) => {}; - this.fakeServer = new FakeServer(); - // eslint-disable-next-line @typescript-eslint/no-this-alias - const that = this; - this.fakeCreateServer = sinon.fake(function (_: any, handler: (req: any, res: any) => void) { - that.listener = handler; // pick up the socket listener method so we can assert on its behaviour - return that.fakeServer as FakeServer; +describe('HTTPReceiver', () => { + // TODO: we pick up the socket listener method so we can assert on its behaviour; probably should add tests for it then + // let httpRequestListener: typeof noopVoid; + let fakeServer: FakeServer; + let fakeCreateServer: sinon.SinonSpy; + const noopLogger = createFakeLogger(); + let overrides: Override; + beforeEach(() => { + fakeServer = new FakeServer(); + fakeCreateServer = sinon.fake((_options: Record, _handler: typeof noopVoid) => { + // TODO: we pick up the socket listener method so we can assert on its behaviour; probably should add tests for it then + // httpRequestListener = _handler; + return fakeServer; }); + overrides = mergeOverrides( + withHttpCreateServer(fakeCreateServer), + withHttpsCreateServer(sinon.fake.throws('Should not be used.')), + ); }); - const noopLogger: Logger = { - 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 { - /* noop */ - }, - }; - - describe('constructor', function () { + describe('constructor', () => { // NOTE: it would be more informative to test known valid combinations of options, as well as invalid combinations - it('should accept supported arguments and use default arguments when not provided', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + it('should accept supported arguments and use default arguments when not provided', async () => { const HTTPReceiver = await importHTTPReceiver(overrides); const receiver = new HTTPReceiver({ @@ -146,26 +89,21 @@ describe('HTTPReceiver', function () { assert.isNotNull(receiver); }); - it('should accept a custom port', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + it('should accept a custom port', async () => { const HTTPReceiver = await importHTTPReceiver(overrides); const defaultPort = new HTTPReceiver({ signingSecret: 'secret', }); assert.isNotNull(defaultPort); - assert.equal((defaultPort as any).port, 3000); + assert.propertyVal(defaultPort, 'port', 3000); const customPort = new HTTPReceiver({ port: 9999, signingSecret: 'secret', }); assert.isNotNull(customPort); - assert.equal((customPort as any).port, 9999); + assert.propertyVal(customPort, 'port', 9999); const customPort2 = new HTTPReceiver({ port: 7777, @@ -175,10 +113,10 @@ describe('HTTPReceiver', function () { }, }); assert.isNotNull(customPort2); - assert.equal((customPort2 as any).port, 9999); + assert.propertyVal(customPort2, 'port', 9999); }); - it('should throw an error if redirect uri options supplied invalid or incomplete', async function () { + it('should throw an error if redirect uri options supplied invalid or incomplete', async () => { const HTTPReceiver = await importHTTPReceiver(); const clientId = 'my-clientId'; const clientSecret = 'my-clientSecret'; @@ -201,69 +139,72 @@ describe('HTTPReceiver', function () { }); assert.isNotNull(receiver); // redirectUri supplied, but missing redirectUriPath - assert.throws(() => new HTTPReceiver({ - clientId, - clientSecret, - signingSecret, - stateSecret, - scopes, - redirectUri, - }), AppInitializationError); + assert.throws( + () => + new HTTPReceiver({ + clientId, + clientSecret, + signingSecret, + stateSecret, + scopes, + redirectUri, + }), + AppInitializationError, + ); // inconsistent redirectUriPath - assert.throws(() => new HTTPReceiver({ - clientId: 'my-clientId', - clientSecret, - signingSecret, - stateSecret, - scopes, - redirectUri, - installerOptions: { - redirectUriPath: '/hiya', - }, - }), AppInitializationError); + assert.throws( + () => + new HTTPReceiver({ + clientId: 'my-clientId', + clientSecret, + signingSecret, + stateSecret, + scopes, + redirectUri, + installerOptions: { + redirectUriPath: '/hiya', + }, + }), + AppInitializationError, + ); // inconsistent redirectUri - assert.throws(() => new HTTPReceiver({ - clientId: 'my-clientId', - clientSecret, - signingSecret, - stateSecret, - scopes, - redirectUri: 'http://example.com/hiya', - installerOptions, - }), AppInitializationError); + assert.throws( + () => + new HTTPReceiver({ + clientId: 'my-clientId', + clientSecret, + signingSecret, + stateSecret, + scopes, + redirectUri: 'http://example.com/hiya', + installerOptions, + }), + AppInitializationError, + ); }); }); - describe('start() method', function () { - it('should accept both numeric and string port arguments and correctly pass as number into server.listen method', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + describe('start() method', () => { + it('should accept both numeric and string port arguments and correctly pass as number into server.listen method', async () => { const HTTPReceiver = await importHTTPReceiver(overrides); const defaultPort = new HTTPReceiver({ signingSecret: 'secret', }); assert.isNotNull(defaultPort); - assert.equal((defaultPort as any).port, 3000); + assert.propertyVal(defaultPort, 'port', 3000); await defaultPort.start(9001); - assert.isTrue(this.fakeServer.listen.calledWith(9001)); + sinon.assert.calledWith(fakeServer.listen, 9001); await defaultPort.stop(); + fakeServer.listen.resetHistory(); await defaultPort.start('1337'); - assert.isTrue(this.fakeServer.listen.calledWith(1337)); + sinon.assert.calledWith(fakeServer.listen, 1337); await defaultPort.stop(); }); }); - describe('request handling', function () { - describe('handleInstallPathRequest()', function () { - it('should invoke installer handleInstallPath if a request comes into the install path', async function () { - // Arrange + describe('request handling', () => { + describe('handleInstallPathRequest()', () => { + it('should invoke installer handleInstallPath if a request comes into the install path', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const HTTPReceiver = await importHTTPReceiver(overrides); const metadata = 'this is bat country'; @@ -285,27 +226,16 @@ describe('HTTPReceiver', function () { }); assert.isNotNull(receiver); receiver.installer = installProviderStub as unknown as InstallProvider; - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; + const fakeReq = sinon.createStubInstance(IncomingMessage) as IncomingMessage; fakeReq.url = '/hiya'; fakeReq.method = 'GET'; - const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - const end = sinon.fake(); - const setHeader = sinon.fake(); - fakeRes.writeHead = writeHead; - fakeRes.end = end; - fakeRes.setHeader = setHeader; - await receiver.requestListener(fakeReq, fakeRes); - assert(installProviderStub.handleInstallPath.calledWith(fakeReq, fakeRes)); + const fakeRes = sinon.createStubInstance(ServerResponse); + receiver.requestListener(fakeReq, fakeRes as unknown as ServerResponse); + sinon.assert.calledWith(installProviderStub.handleInstallPath, fakeReq, fakeRes); }); - it('should use a custom HTML renderer for the install path webpage', async function () { - // Arrange + it('should use a custom HTML renderer for the install path webpage', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const HTTPReceiver = await importHTTPReceiver(overrides); const metadata = 'this is bat country'; @@ -332,22 +262,12 @@ describe('HTTPReceiver', function () { fakeReq.url = '/hiya'; fakeReq.method = 'GET'; const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - const end = sinon.fake(); - fakeRes.writeHead = writeHead; - fakeRes.end = end; - /* eslint-disable-next-line @typescript-eslint/await-thenable */ - await receiver.requestListener(fakeReq, fakeRes); - assert(installProviderStub.handleInstallPath.calledWith(fakeReq, fakeRes)); + receiver.requestListener(fakeReq, fakeRes); + sinon.assert.calledWith(installProviderStub.handleInstallPath, fakeReq, fakeRes); }); - it('should redirect installers if directInstall is true', async function () { - // Arrange + it('should redirect installers if directInstall is true', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const HTTPReceiver = await importHTTPReceiver(overrides); const metadata = 'this is bat country'; @@ -374,25 +294,16 @@ describe('HTTPReceiver', function () { fakeReq.url = '/hiya'; fakeReq.method = 'GET'; const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - const end = sinon.fake(); - fakeRes.writeHead = writeHead; - fakeRes.end = end; - await receiver.requestListener(fakeReq, fakeRes); - assert(installProviderStub.handleInstallPath.calledWith(fakeReq, fakeRes)); + receiver.requestListener(fakeReq, fakeRes); + sinon.assert.calledWith(installProviderStub.handleInstallPath, fakeReq, fakeRes); }); }); - describe('handleInstallRedirectRequest()', function () { - it('should invoke installer handler if a request comes into the redirect URI path', async function () { - // Arrange + describe('handleInstallRedirectRequest()', () => { + it('should invoke installer handler if a request comes into the redirect URI path', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider, { handleCallback: sinon.stub().resolves() as unknown as Promise, }); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const HTTPReceiver = await importHTTPReceiver(overrides); const metadata = 'this is bat country'; @@ -417,28 +328,18 @@ describe('HTTPReceiver', function () { }); assert.isNotNull(receiver); receiver.installer = installProviderStub as unknown as InstallProvider; - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; + const fakeReq = sinon.createStubInstance(IncomingMessage) as IncomingMessage; fakeReq.url = '/heyo'; fakeReq.method = 'GET'; const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - const end = sinon.fake(); - fakeRes.writeHead = writeHead; - fakeRes.end = end; - /* eslint-disable-next-line @typescript-eslint/await-thenable */ - await receiver.requestListener(fakeReq, fakeRes); - assert(installProviderStub.handleCallback.calledWith(fakeReq, fakeRes, callbackOptions)); + receiver.requestListener(fakeReq, fakeRes); + sinon.assert.calledWith(installProviderStub.handleCallback, fakeReq, fakeRes, callbackOptions); }); - it('should invoke installer handler with installURLoptions supplied if state verification is off', async function () { - // Arrange + it('should invoke installer handler with installURLoptions supplied if state verification is off', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider, { handleCallback: sinon.stub().resolves() as unknown as Promise, }); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const HTTPReceiver = await importHTTPReceiver(overrides); const metadata = 'this is bat country'; @@ -471,20 +372,22 @@ describe('HTTPReceiver', function () { }); assert.isNotNull(receiver); receiver.installer = installProviderStub as unknown as InstallProvider; - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; + const fakeReq = sinon.createStubInstance(IncomingMessage) as IncomingMessage; fakeReq.url = '/heyo'; fakeReq.method = 'GET'; - const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - fakeRes.writeHead = sinon.fake(); - fakeRes.end = sinon.fake(); - await receiver.requestListener(fakeReq, fakeRes); + const fakeRes = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; + receiver.requestListener(fakeReq, fakeRes); sinon.assert.calledWith( - installProviderStub.handleCallback, fakeReq, fakeRes, callbackOptions, installUrlOptions, + installProviderStub.handleCallback, + fakeReq, + fakeRes, + callbackOptions, + installUrlOptions, ); }); }); - describe('custom route handling', async function () { - it('should call custom route handler only if request matches route path and method', async function () { + describe('custom route handling', async () => { + it('should call custom route handler only if request matches route path and method', async () => { const HTTPReceiver = await importHTTPReceiver(); const customRoutes = [{ path: '/test', method: ['get', 'POST'], handler: sinon.fake() }]; const matchRegex = match(customRoutes[0].path, { decode: decodeURIComponent }); @@ -500,23 +403,23 @@ describe('HTTPReceiver', function () { fakeReq.url = '/test'; const tempMatch = matchRegex(fakeReq.url); if (!tempMatch) throw new Error('match failed'); - const params : ParamsDictionary = tempMatch.params as ParamsDictionary; + const params: ParamsDictionary = tempMatch.params as ParamsDictionary; fakeReq.method = 'GET'; receiver.requestListener(fakeReq, fakeRes); let expectedMessage = Object.assign(fakeReq, { params }); - assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); + sinon.assert.calledWith(customRoutes[0].handler, expectedMessage, fakeRes); fakeReq.method = 'POST'; receiver.requestListener(fakeReq, fakeRes); expectedMessage = Object.assign(fakeReq, { params }); - assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); + sinon.assert.calledWith(customRoutes[0].handler, expectedMessage, fakeRes); fakeReq.method = 'UNHANDLED_METHOD'; assert.throws(() => receiver.requestListener(fakeReq, fakeRes), HTTPReceiverDeferredRequestError); }); - it('should call custom route handler only if request matches route path and method, ignoring query params', async function () { + it('should call custom route handler only if request matches route path and method, ignoring query params', async () => { const HTTPReceiver = await importHTTPReceiver(); const customRoutes = [{ path: '/test', method: ['get', 'POST'], handler: sinon.fake() }]; const matchRegex = match(customRoutes[0].path, { decode: decodeURIComponent }); @@ -526,29 +429,29 @@ describe('HTTPReceiver', function () { customRoutes, }); - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; + const fakeReq = sinon.createStubInstance(IncomingMessage) as IncomingMessage; + const fakeRes = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; fakeReq.url = '/test?hello=world'; const tempMatch = matchRegex('/test'); if (!tempMatch) throw new Error('match failed'); - const params : ParamsDictionary = tempMatch.params as ParamsDictionary; + const params: ParamsDictionary = tempMatch.params as ParamsDictionary; fakeReq.method = 'GET'; receiver.requestListener(fakeReq, fakeRes); let expectedMessage = Object.assign(fakeReq, { params }); - assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); + sinon.assert.calledWith(customRoutes[0].handler, expectedMessage, fakeRes); fakeReq.method = 'POST'; receiver.requestListener(fakeReq, fakeRes); expectedMessage = Object.assign(fakeReq, { params }); - assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); + sinon.assert.calledWith(customRoutes[0].handler, expectedMessage, fakeRes); fakeReq.method = 'UNHANDLED_METHOD'; assert.throws(() => receiver.requestListener(fakeReq, fakeRes), HTTPReceiverDeferredRequestError); }); - it('should call custom route handler only if request matches route path and method including params', async function () { + it('should call custom route handler only if request matches route path and method including params', async () => { const HTTPReceiver = await importHTTPReceiver(); const customRoutes = [{ path: '/test/:id', method: ['get', 'POST'], handler: sinon.fake() }]; const matchRegex = match(customRoutes[0].path, { decode: decodeURIComponent }); @@ -558,29 +461,29 @@ describe('HTTPReceiver', function () { customRoutes, }); - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; + const fakeReq = sinon.createStubInstance(IncomingMessage) as IncomingMessage; + const fakeRes = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; fakeReq.url = '/test/123'; const tempMatch = matchRegex(fakeReq.url); if (!tempMatch) throw new Error('match failed'); - const params : ParamsDictionary = tempMatch.params as ParamsDictionary; + const params: ParamsDictionary = tempMatch.params as ParamsDictionary; fakeReq.method = 'GET'; receiver.requestListener(fakeReq, fakeRes); let expectedMessage = Object.assign(fakeReq, { params }); - assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); + sinon.assert.calledWith(customRoutes[0].handler, expectedMessage, fakeRes); fakeReq.method = 'POST'; receiver.requestListener(fakeReq, fakeRes); expectedMessage = Object.assign(fakeReq, { params }); - assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); + sinon.assert.calledWith(customRoutes[0].handler, expectedMessage, fakeRes); fakeReq.method = 'UNHANDLED_METHOD'; assert.throws(() => receiver.requestListener(fakeReq, fakeRes), HTTPReceiverDeferredRequestError); }); - it('should call custom route handler only if request matches multiple route paths and method including params', async function () { + it('should call custom route handler only if request matches multiple route paths and method including params', async () => { const HTTPReceiver = await importHTTPReceiver(); const customRoutes = [ { path: '/test/123', method: ['get', 'POST'], handler: sinon.fake() }, @@ -593,30 +496,30 @@ describe('HTTPReceiver', function () { customRoutes, }); - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; + const fakeReq = sinon.createStubInstance(IncomingMessage) as IncomingMessage; + const fakeRes = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; fakeReq.url = '/test/123'; const tempMatch = matchRegex(fakeReq.url); if (!tempMatch) throw new Error('match failed'); - const params : ParamsDictionary = tempMatch.params as ParamsDictionary; + const params: ParamsDictionary = tempMatch.params as ParamsDictionary; fakeReq.method = 'GET'; receiver.requestListener(fakeReq, fakeRes); let expectedMessage = Object.assign(fakeReq, { params }); - assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); - assert(customRoutes[1].handler.notCalled); + sinon.assert.calledWith(customRoutes[0].handler, expectedMessage, fakeRes); + sinon.assert.notCalled(customRoutes[1].handler); fakeReq.method = 'POST'; receiver.requestListener(fakeReq, fakeRes); expectedMessage = Object.assign(fakeReq, { params }); - assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); + sinon.assert.calledWith(customRoutes[0].handler, expectedMessage, fakeRes); fakeReq.method = 'UNHANDLED_METHOD'; assert.throws(() => receiver.requestListener(fakeReq, fakeRes), HTTPReceiverDeferredRequestError); }); - it('should call custom route handler only if request matches multiple route paths and method including params reverse order', async function () { + it('should call custom route handler only if request matches multiple route paths and method including params reverse order', async () => { const HTTPReceiver = await importHTTPReceiver(); const customRoutes = [ { path: '/test/:id', method: ['get', 'POST'], handler: sinon.fake() }, @@ -629,48 +532,47 @@ describe('HTTPReceiver', function () { customRoutes, }); - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; + const fakeReq = sinon.createStubInstance(IncomingMessage) as IncomingMessage; + const fakeRes = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; fakeReq.url = '/test/123'; const tempMatch = matchRegex(fakeReq.url); if (!tempMatch) throw new Error('match failed'); - const params : ParamsDictionary = tempMatch.params as ParamsDictionary; + const params: ParamsDictionary = tempMatch.params as ParamsDictionary; fakeReq.method = 'GET'; receiver.requestListener(fakeReq, fakeRes); let expectedMessage = Object.assign(fakeReq, { params }); - assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); - assert(customRoutes[1].handler.notCalled); + sinon.assert.calledWith(customRoutes[0].handler, expectedMessage, fakeRes); + sinon.assert.notCalled(customRoutes[1].handler); fakeReq.method = 'POST'; receiver.requestListener(fakeReq, fakeRes); expectedMessage = Object.assign(fakeReq, { params }); - assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); + sinon.assert.calledWith(customRoutes[0].handler, expectedMessage, fakeRes); fakeReq.method = 'UNHANDLED_METHOD'; assert.throws(() => receiver.requestListener(fakeReq, fakeRes), HTTPReceiverDeferredRequestError); }); - it("should throw an error if customRoutes don't have the required keys", async function () { + it("should throw an error if customRoutes don't have the required keys", async () => { const HTTPReceiver = await importHTTPReceiver(); - const customRoutes = [{ path: '/test' }] as any; - - assert.throws(() => new HTTPReceiver({ - clientSecret: 'my-client-secret', - signingSecret: 'secret', - customRoutes, - }), CustomRouteInitializationError); + const customRoutes = [{ path: '/test' }] as CustomRoute[]; + + assert.throws( + () => + new HTTPReceiver({ + clientSecret: 'my-client-secret', + signingSecret: 'secret', + customRoutes, + }), + CustomRouteInitializationError, + ); }); }); - it("should throw if request doesn't match install path, redirect URI path, or custom routes", async function () { - // Arrange + it("should throw if request doesn't match install path, redirect URI path, or custom routes", async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const HTTPReceiver = await importHTTPReceiver(overrides); const metadata = 'this is bat country'; @@ -699,13 +601,11 @@ describe('HTTPReceiver', function () { assert.isNotNull(receiver); receiver.installer = installProviderStub as unknown as InstallProvider; - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; + const fakeReq = sinon.createStubInstance(IncomingMessage) as IncomingMessage; fakeReq.url = '/nope'; fakeReq.method = 'GET'; - const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - fakeRes.writeHead = sinon.fake(); - fakeRes.end = sinon.fake(); + const fakeRes = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; assert.throws(() => receiver.requestListener(fakeReq, fakeRes), HTTPReceiverDeferredRequestError); }); diff --git a/src/receivers/HTTPResponseAck.spec.ts b/test/unit/receivers/HTTPResponseAck.spec.ts similarity index 87% rename from src/receivers/HTTPResponseAck.spec.ts rename to test/unit/receivers/HTTPResponseAck.spec.ts index b3e7e06b1..3c40616ac 100644 --- a/src/receivers/HTTPResponseAck.spec.ts +++ b/test/unit/receivers/HTTPResponseAck.spec.ts @@ -1,11 +1,10 @@ -import 'mocha'; -import { IncomingMessage, ServerResponse } from 'http'; -import sinon from 'sinon'; +import { IncomingMessage, ServerResponse } from 'node:http'; import { assert } from 'chai'; -import { HTTPResponseAck } from './HTTPResponseAck'; -import { HTTPModuleFunctions } from './HTTPModuleFunctions'; -import { ReceiverMultipleAckError } from '../errors'; -import { createFakeLogger } from '../test-helpers'; +import sinon from 'sinon'; +import { ReceiverMultipleAckError } from '../../../src/errors'; +import * as HTTPModuleFunctions from '../../../src/receivers/HTTPModuleFunctions'; +import { HTTPResponseAck } from '../../../src/receivers/HTTPResponseAck'; +import { createFakeLogger } from '../helpers'; describe('HTTPResponseAck', async () => { it('should work', async () => { @@ -25,7 +24,6 @@ describe('HTTPResponseAck', async () => { const httpRequest = sinon.createStubInstance(IncomingMessage) as IncomingMessage; const httpResponse: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; const spy = sinon.spy(); - // eslint-disable-next-line no-new new HTTPResponseAck({ logger: createFakeLogger(), processBeforeResponse: false, @@ -43,7 +41,6 @@ describe('HTTPResponseAck', async () => { const httpRequest = sinon.createStubInstance(IncomingMessage) as IncomingMessage; const httpResponse: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; const spy = sinon.spy(); - // eslint-disable-next-line no-new const responseAck = new HTTPResponseAck({ logger: createFakeLogger(), processBeforeResponse: false, @@ -102,7 +99,11 @@ describe('HTTPResponseAck', async () => { const bound = ack.bind(); const body = false; await bound(body); - assert.equal(ack.storedResponse, '', 'Falsy body passed to bound handler not stored as empty string in Ack instance.'); + assert.equal( + ack.storedResponse, + '', + 'Falsy body passed to bound handler not stored as empty string in Ack instance.', + ); }); it('should call buildContentResponse with response body if processBeforeResponse=false', async () => { const stub = sinon.stub(HTTPModuleFunctions, 'buildContentResponse'); @@ -117,6 +118,9 @@ describe('HTTPResponseAck', async () => { const bound = ack.bind(); const body = { some: 'thing' }; await bound(body); - assert(stub.calledWith(httpResponse, body), 'buildContentResponse called with HTTP Response object and response body.'); + assert( + stub.calledWith(httpResponse, body), + 'buildContentResponse called with HTTP Response object and response body.', + ); }); }); diff --git a/src/receivers/SocketModeFunctions.spec.ts b/test/unit/receivers/SocketModeFunctions.spec.ts similarity index 68% rename from src/receivers/SocketModeFunctions.spec.ts rename to test/unit/receivers/SocketModeFunctions.spec.ts index e0516a3d8..fe68da142 100644 --- a/src/receivers/SocketModeFunctions.spec.ts +++ b/test/unit/receivers/SocketModeFunctions.spec.ts @@ -1,12 +1,8 @@ -import 'mocha'; import { assert } from 'chai'; -import { SocketModeFunctions as func } from './SocketModeFunctions'; -import { - ReceiverMultipleAckError, - AuthorizationError, -} from '../errors'; -import { createFakeLogger } from '../test-helpers'; -import { ReceiverEvent } from '../types'; +import { AuthorizationError, ReceiverMultipleAckError } from '../../../src/errors'; +import { defaultProcessEventErrorHandler } from '../../../src/receivers/SocketModeFunctions'; +import type { ReceiverEvent } from '../../../src/types'; +import { createFakeLogger } from '../helpers'; describe('SocketModeFunctions', async () => { describe('Error handlers for event processing', async () => { @@ -18,7 +14,7 @@ describe('SocketModeFunctions', async () => { ack: async () => {}, body: {}, }; - const shouldBeAcked = await func.defaultProcessEventErrorHandler({ + const shouldBeAcked = await defaultProcessEventErrorHandler({ error: new ReceiverMultipleAckError(), logger, event, @@ -30,7 +26,7 @@ describe('SocketModeFunctions', async () => { ack: async () => {}, body: {}, }; - const shouldBeAcked = await func.defaultProcessEventErrorHandler({ + const shouldBeAcked = await defaultProcessEventErrorHandler({ error: new AuthorizationError('msg', new Error()), logger, event, diff --git a/src/receivers/SocketModeReceiver.spec.ts b/test/unit/receivers/SocketModeReceiver.spec.ts similarity index 64% rename from src/receivers/SocketModeReceiver.spec.ts rename to test/unit/receivers/SocketModeReceiver.spec.ts index 8b02d9a72..ffdca6997 100644 --- a/src/receivers/SocketModeReceiver.spec.ts +++ b/test/unit/receivers/SocketModeReceiver.spec.ts @@ -1,85 +1,50 @@ -import 'mocha'; -import { EventEmitter } from 'events'; -import { IncomingMessage, ServerResponse } from 'http'; -import sinon, { SinonSpy } from 'sinon'; -import { assert } from 'chai'; -import rewiremock from 'rewiremock'; -import { Logger, LogLevel } from '@slack/logger'; -import { match } from 'path-to-regexp'; -import { ParamsDictionary } from 'express-serve-static-core'; +import { IncomingMessage, ServerResponse } from 'node:http'; import { InstallProvider } from '@slack/oauth'; import { SocketModeClient } from '@slack/socket-mode'; -import { Override, mergeOverrides } from '../test-helpers'; -import { CustomRouteInitializationError, AppInitializationError } from '../errors'; - -// Fakes -class FakeServer extends EventEmitter { - public on = sinon.fake(); - - public listen = sinon.fake(() => { - if (this.listeningFailure !== undefined) { - this.emit('error', this.listeningFailure); - } - }); - - public close = sinon.fake((...args: any[]) => { - setImmediate(() => { - this.emit('close'); - setImmediate(() => { - args[0](); - }); - }); - }); +import { assert } from 'chai'; +import type { ParamsDictionary } from 'express-serve-static-core'; +import { match } from 'path-to-regexp'; +import rewiremock from 'rewiremock'; +import sinon from 'sinon'; +import { AppInitializationError, CustomRouteInitializationError } from '../../../src/errors'; +import { + FakeServer, + type Override, + createFakeLogger, + mergeOverrides, + type noopVoid, + withHttpCreateServer, + withHttpsCreateServer, +} from '../helpers'; - public constructor(private listeningFailure?: Error) { - super(); - } +// Loading the system under test using overrides +async function importSocketModeReceiver( + overrides: Override = {}, +): Promise { + return (await rewiremock.module(() => import('../../../src/receivers/SocketModeReceiver'), overrides)).default; } -describe('SocketModeReceiver', function () { - beforeEach(function () { - this.listener = (_req: any, _res: any) => {}; - this.fakeServer = new FakeServer(); - // eslint-disable-next-line @typescript-eslint/no-this-alias - const that = this; - this.fakeCreateServer = sinon.fake(function (handler: (req: any, res: any) => void) { - that.listener = handler; // pick up the socket listener method so we can assert on its behaviour - return that.fakeServer as FakeServer; +describe('SocketModeReceiver', () => { + let socketModeHttpServerHandler: typeof noopVoid; + let fakeServer: FakeServer; + let fakeCreateServer: sinon.SinonSpy; + const noopLogger = createFakeLogger(); + let overrides: Override; + beforeEach(() => { + fakeServer = new FakeServer(); + fakeCreateServer = sinon.fake((handler: typeof noopVoid) => { + socketModeHttpServerHandler = handler; // pick up the socket-mode receiver's HTTP request handler so we can assert on its behaviour + return fakeServer; }); + overrides = mergeOverrides( + withHttpCreateServer(fakeCreateServer), + withHttpsCreateServer(sinon.fake.throws('Should not be used.')), + ); }); - const noopLogger: Logger = { - 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 { - /* noop */ - }, - }; - - describe('constructor', function () { + describe('constructor', () => { // NOTE: it would be more informative to test known valid combinations of options, as well as invalid combinations - it('should accept supported arguments and use default arguments when not provided', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + it('should accept supported arguments and use default arguments when not provided', async () => { const SocketModeReceiver = await importSocketModeReceiver(overrides); const receiver = new SocketModeReceiver({ @@ -95,15 +60,8 @@ describe('SocketModeReceiver', function () { }, }); assert.isNotNull(receiver); - // since v3.8, the constructor does not start the server - // assert.isNotOk(this.fakeServer.listen.calledWith(3000)); }); - it('should allow for customizing port the socket listens on', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + it('should allow for customizing port the socket listens on', async () => { const SocketModeReceiver = await importSocketModeReceiver(overrides); const customPort = 1337; @@ -121,15 +79,8 @@ describe('SocketModeReceiver', function () { }, }); assert.isNotNull(receiver); - // since v3.8, the constructor does not start the server - // assert.isOk(this.fakeServer.listen.calledWith(customPort)); }); - it('should allow for extracting additional values from Socket Mode messages', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + it('should allow for extracting additional values from Socket Mode messages', async () => { const SocketModeReceiver = await importSocketModeReceiver(overrides); const receiver = new SocketModeReceiver({ @@ -139,11 +90,7 @@ describe('SocketModeReceiver', function () { }); assert.isNotNull(receiver); }); - it('should throw an error if redirect uri options supplied invalid or incomplete', async function () { - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + it('should throw an error if redirect uri options supplied invalid or incomplete', async () => { const SocketModeReceiver = await importSocketModeReceiver(overrides); const clientId = 'my-clientId'; const clientSecret = 'my-clientSecret'; @@ -166,47 +113,89 @@ describe('SocketModeReceiver', function () { }); assert.isNotNull(receiver); // redirectUri supplied, but no redirectUriPath - assert.throws(() => new SocketModeReceiver({ - appToken, - clientId, - clientSecret, - stateSecret, - scopes, - redirectUri, - }), AppInitializationError); + assert.throws( + () => + new SocketModeReceiver({ + appToken, + clientId, + clientSecret, + stateSecret, + scopes, + redirectUri, + }), + AppInitializationError, + ); // inconsistent redirectUriPath - assert.throws(() => new SocketModeReceiver({ - appToken, + assert.throws( + () => + new SocketModeReceiver({ + appToken, + clientId: 'my-clientId', + clientSecret, + stateSecret, + scopes, + redirectUri, + installerOptions: { + redirectUriPath: '/hiya', + }, + }), + AppInitializationError, + ); + // inconsistent redirectUri + assert.throws( + () => + new SocketModeReceiver({ + appToken, + clientId: 'my-clientId', + clientSecret, + stateSecret, + scopes, + redirectUri: 'http://example.com/hiya', + installerOptions, + }), + AppInitializationError, + ); + }); + }); + describe('request handling', () => { + it('should return a 404 if a request flows through the install path, redirect URI path and custom routes without being handled', async () => { + const installProviderStub = sinon.createStubInstance(InstallProvider); + const SocketModeReceiver = await importSocketModeReceiver(overrides); + + const metadata = 'this is bat country'; + const scopes = ['channels:read']; + const userScopes = ['chat:write']; + const customRoutes = [{ path: '/test', method: ['get', 'POST'], handler: sinon.fake() }]; + const receiver = new SocketModeReceiver({ + appToken: 'my-secret', + logger: noopLogger, clientId: 'my-clientId', - clientSecret, - stateSecret, + clientSecret: 'my-client-secret', + stateSecret: 'state-secret', scopes, - redirectUri, + customRoutes, + redirectUri: 'http://example.com/heyo', installerOptions: { - redirectUriPath: '/hiya', + authVersion: 'v2', + installPath: '/hiya', + redirectUriPath: '/heyo', + metadata, + userScopes, }, - }), AppInitializationError); - // inconsistent redirectUri - assert.throws(() => new SocketModeReceiver({ - appToken, - clientId: 'my-clientId', - clientSecret, - stateSecret, - scopes, - redirectUri: 'http://example.com/hiya', - installerOptions, - }), AppInitializationError); + }); + assert.isNotNull(receiver); + receiver.installer = installProviderStub as unknown as InstallProvider; + const fakeReq = sinon.createStubInstance(IncomingMessage); + fakeReq.url = '/nope'; + fakeReq.method = 'GET'; + const fakeRes = sinon.createStubInstance(ServerResponse); + await socketModeHttpServerHandler(fakeReq, fakeRes); + sinon.assert.calledWith(fakeRes.writeHead, 404, sinon.match.object); + assert(fakeRes.end.calledOnce); }); - }); - describe('request handling', function () { - describe('handleInstallPathRequest()', function () { - it('should invoke installer handleInstallPath if a request comes into the install path', async function () { - // Arrange + describe('handleInstallPathRequest()', () => { + it('should invoke installer handleInstallPath if a request comes into the install path', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const metadata = 'this is bat country'; @@ -236,16 +225,11 @@ describe('SocketModeReceiver', function () { writeHead: sinon.fake(), end: sinon.fake(), } as unknown as ServerResponse; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); assert(installProviderStub.handleInstallPath.calledWith(fakeReq, fakeRes)); }); - it('should use a custom HTML renderer for the install path webpage', async function () { - // Arrange + it('should use a custom HTML renderer for the install path webpage', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const metadata = 'this is bat country'; @@ -276,16 +260,11 @@ describe('SocketModeReceiver', function () { writeHead: sinon.fake(), end: sinon.fake(), } as unknown as ServerResponse; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); assert(installProviderStub.handleInstallPath.calledWith(fakeReq, fakeRes)); }); - it('should redirect installers if directInstall is true', async function () { - // Arrange + it('should redirect installers if directInstall is true', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const metadata = 'this is bat country'; @@ -316,18 +295,13 @@ describe('SocketModeReceiver', function () { writeHead: sinon.fake(), end: sinon.fake(), } as unknown as ServerResponse; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); assert(installProviderStub.handleInstallPath.calledWith(fakeReq, fakeRes)); }); }); - describe('handleInstallRedirectRequest()', function () { - it('should invoke installer handleCallback if a request comes into the redirect URI path', async function () { - // Arrange + describe('handleInstallRedirectRequest()', () => { + it('should invoke installer handleCallback if a request comes into the redirect URI path', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const callbackOptions = { @@ -357,7 +331,7 @@ describe('SocketModeReceiver', function () { method: 'GET', }; const fakeRes = null; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); assert( installProviderStub.handleCallback.calledWith( fakeReq as IncomingMessage, @@ -366,13 +340,8 @@ describe('SocketModeReceiver', function () { ), ); }); - it('should invoke handleCallback with installURLoptions as params if state verification is off', async function () { - // Arrange + it('should invoke handleCallback with installURLoptions as params if state verification is off', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const metadata = 'this is bat country'; const scopes = ['channels:read']; @@ -407,14 +376,12 @@ describe('SocketModeReceiver', function () { }); assert.isNotNull(receiver); receiver.installer = installProviderStub as unknown as InstallProvider; - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; + const fakeReq = sinon.createStubInstance(IncomingMessage); fakeReq.url = '/heyo'; fakeReq.headers = { host: 'localhost' }; fakeReq.method = 'GET'; - const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - fakeRes.writeHead = sinon.fake(); - fakeRes.end = sinon.fake(); - await this.listener(fakeReq, fakeRes); + const fakeRes = sinon.createStubInstance(ServerResponse); + await socketModeHttpServerHandler(fakeReq, fakeRes); sinon.assert.calledWith( installProviderStub.handleCallback, fakeReq as IncomingMessage, @@ -424,14 +391,9 @@ describe('SocketModeReceiver', function () { ); }); }); - describe('custom route handling', function () { - it('should call custom route handler only if request matches route path and method', async function () { - // Arrange + describe('custom route handling', () => { + it('should call custom route handler only if request matches route path and method', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const customRoutes = [{ path: '/test', method: ['get', 'POST'], handler: sinon.fake() }]; const matchRegex = match(customRoutes[0].path, { decode: decodeURIComponent }); @@ -443,38 +405,33 @@ describe('SocketModeReceiver', function () { assert.isNotNull(receiver); receiver.installer = installProviderStub as unknown as InstallProvider; - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const fakeRes = { writeHead: sinon.fake(), end: sinon.fake() }; + const fakeReq = sinon.createStubInstance(IncomingMessage); + const fakeRes = sinon.createStubInstance(ServerResponse); fakeReq.url = '/test'; const tempMatch = matchRegex(fakeReq.url); if (!tempMatch) throw new Error('match failed'); - const params : ParamsDictionary = tempMatch.params as ParamsDictionary; + const params = tempMatch.params as ParamsDictionary; fakeReq.headers = { host: 'localhost' }; fakeReq.method = 'GET'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); let expectedMessage = Object.assign(fakeReq, { params }); assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); fakeReq.method = 'POST'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); expectedMessage = Object.assign(fakeReq, { params }); assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); fakeReq.method = 'UNHANDLED_METHOD'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); assert(fakeRes.writeHead.calledWith(404, sinon.match.object)); assert(fakeRes.end.called); }); - it('should call custom route handler when request matches path, ignoring query params', async function () { - // Arrange + it('should call custom route handler when request matches path, ignoring query params', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const customRoutes = [{ path: '/test', method: ['get', 'POST'], handler: sinon.fake() }]; const matchRegex = match(customRoutes[0].path, { decode: decodeURIComponent }); @@ -486,38 +443,33 @@ describe('SocketModeReceiver', function () { assert.isNotNull(receiver); receiver.installer = installProviderStub as unknown as InstallProvider; - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const fakeRes = { writeHead: sinon.fake(), end: sinon.fake() }; + const fakeReq = sinon.createStubInstance(IncomingMessage); + const fakeRes = sinon.createStubInstance(ServerResponse); fakeReq.url = '/test?hello=world'; const tempMatch = matchRegex('/test'); if (!tempMatch) throw new Error('match failed'); - const params : ParamsDictionary = tempMatch.params as ParamsDictionary; + const params: ParamsDictionary = tempMatch.params as ParamsDictionary; fakeReq.headers = { host: 'localhost' }; fakeReq.method = 'GET'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); let expectedMessage = Object.assign(fakeReq, { params }); assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); fakeReq.method = 'POST'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); expectedMessage = Object.assign(fakeReq, { params }); assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); fakeReq.method = 'UNHANDLED_METHOD'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); assert(fakeRes.writeHead.calledWith(404, sinon.match.object)); assert(fakeRes.end.called); }); - it('should call custom route handler only if request matches route path and method including params', async function () { - // Arrange + it('should call custom route handler only if request matches route path and method including params', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const customRoutes = [{ path: '/test/:id', method: ['get', 'POST'], handler: sinon.fake() }]; const matchRegex = match(customRoutes[0].path, { decode: decodeURIComponent }); @@ -529,38 +481,33 @@ describe('SocketModeReceiver', function () { assert.isNotNull(receiver); receiver.installer = installProviderStub as unknown as InstallProvider; - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const fakeRes = { writeHead: sinon.fake(), end: sinon.fake() }; + const fakeReq = sinon.createStubInstance(IncomingMessage); + const fakeRes = sinon.createStubInstance(ServerResponse); fakeReq.url = '/test/123'; const tempMatch = matchRegex(fakeReq.url); if (!tempMatch) throw new Error('match failed'); - const params : ParamsDictionary = tempMatch.params as ParamsDictionary; + const params: ParamsDictionary = tempMatch.params as ParamsDictionary; fakeReq.headers = { host: 'localhost' }; fakeReq.method = 'GET'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); let expectedMessage = Object.assign(fakeReq, { params }); assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); fakeReq.method = 'POST'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); expectedMessage = Object.assign(fakeReq, { params }); assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); fakeReq.method = 'UNHANDLED_METHOD'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); assert(fakeRes.writeHead.calledWith(404, sinon.match.object)); assert(fakeRes.end.called); }); - it('should call custom route handler only if request matches multiple route paths and method including params', async function () { - // Arrange + it('should call custom route handler only if request matches multiple route paths and method including params', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const customRoutes = [ { path: '/test/123', method: ['get', 'POST'], handler: sinon.fake() }, @@ -575,40 +522,35 @@ describe('SocketModeReceiver', function () { assert.isNotNull(receiver); receiver.installer = installProviderStub as unknown as InstallProvider; - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const fakeRes = { writeHead: sinon.fake(), end: sinon.fake() }; + const fakeReq = sinon.createStubInstance(IncomingMessage); + const fakeRes = sinon.createStubInstance(ServerResponse); fakeReq.url = '/test/123'; const tempMatch = matchRegex(fakeReq.url); if (!tempMatch) throw new Error('match failed'); - const params : ParamsDictionary = tempMatch.params as ParamsDictionary; + const params: ParamsDictionary = tempMatch.params as ParamsDictionary; fakeReq.headers = { host: 'localhost' }; fakeReq.method = 'GET'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); let expectedMessage = Object.assign(fakeReq, params); assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); assert(customRoutes[1].handler.notCalled); fakeReq.method = 'POST'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); expectedMessage = Object.assign(fakeReq, params); assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); assert(customRoutes[1].handler.notCalled); fakeReq.method = 'UNHANDLED_METHOD'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); assert(fakeRes.writeHead.calledWith(404, sinon.match.object)); assert(fakeRes.end.called); }); - it('should call custom route handler only if request matches multiple route paths and method including params reverse order', async function () { - // Arrange + it('should call custom route handler only if request matches multiple route paths and method including params reverse order', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const customRoutes = [ { path: '/test/:id', method: ['get', 'POST'], handler: sinon.fake() }, @@ -623,99 +565,48 @@ describe('SocketModeReceiver', function () { assert.isNotNull(receiver); receiver.installer = installProviderStub as unknown as InstallProvider; - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const fakeRes = { writeHead: sinon.fake(), end: sinon.fake() }; + const fakeReq = sinon.createStubInstance(IncomingMessage); + const fakeRes = sinon.createStubInstance(ServerResponse); fakeReq.url = '/test/123'; const tempMatch = matchRegex(fakeReq.url); if (!tempMatch) throw new Error('match failed'); - const params : ParamsDictionary = tempMatch.params as ParamsDictionary; + const params: ParamsDictionary = tempMatch.params as ParamsDictionary; fakeReq.headers = { host: 'localhost' }; fakeReq.method = 'GET'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); let expectedMessage = Object.assign(fakeReq, params); assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); assert(customRoutes[1].handler.notCalled); fakeReq.method = 'POST'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); expectedMessage = Object.assign(fakeReq, params); assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); fakeReq.method = 'UNHANDLED_METHOD'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); assert(fakeRes.writeHead.calledWith(404, sinon.match.object)); assert(fakeRes.end.called); }); - it("should throw an error if customRoutes don't have the required keys", async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + it("should throw an error if customRoutes don't have the required keys", async () => { const SocketModeReceiver = await importSocketModeReceiver(overrides); + // biome-ignore lint/suspicious/noExplicitAny: typing as any to intentionally have missing required keys const customRoutes = [{ handler: sinon.fake() }] as any; - assert.throws(() => new SocketModeReceiver({ appToken: 'my-secret', customRoutes }), CustomRouteInitializationError); - }); - }); - - it('should return a 404 if a request passes the install path, redirect URI path and custom routes', async function () { - // Arrange - const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); - const SocketModeReceiver = await importSocketModeReceiver(overrides); - - const metadata = 'this is bat country'; - const scopes = ['channels:read']; - const userScopes = ['chat:write']; - const customRoutes = [{ path: '/test', method: ['get', 'POST'], handler: sinon.fake() }]; - const receiver = new SocketModeReceiver({ - appToken: 'my-secret', - logger: noopLogger, - clientId: 'my-clientId', - clientSecret: 'my-client-secret', - stateSecret: 'state-secret', - scopes, - customRoutes, - redirectUri: 'http://example.com/heyo', - installerOptions: { - authVersion: 'v2', - installPath: '/hiya', - redirectUriPath: '/heyo', - metadata, - userScopes, - }, + assert.throws( + () => new SocketModeReceiver({ appToken: 'my-secret', customRoutes }), + CustomRouteInitializationError, + ); }); - assert.isNotNull(receiver); - receiver.installer = installProviderStub as unknown as InstallProvider; - const fakeReq = { - url: '/nope', - method: 'GET', - }; - const fakeRes = { - writeHead: sinon.fake(), - end: sinon.fake(), - }; - await this.listener(fakeReq, fakeRes); - assert(fakeRes.writeHead.calledWith(404, sinon.match.object)); - assert(fakeRes.end.calledOnce); }); }); - describe('#start()', function () { - it('should invoke the SocketModeClient start method', async function () { - // Arrange + describe('#start()', () => { + it('should invoke the SocketModeClient start method', async () => { const clientStub = sinon.createStubInstance(SocketModeClient); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const receiver = new SocketModeReceiver({ @@ -736,14 +627,9 @@ describe('SocketModeReceiver', function () { assert(clientStub.start.called); }); }); - describe('#stop()', function () { - it('should invoke the SocketModeClient disconnect method', async function () { - // Arrange + describe('#stop()', () => { + it('should invoke the SocketModeClient disconnect method', async () => { const clientStub = sinon.createStubInstance(SocketModeClient); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const receiver = new SocketModeReceiver({ @@ -765,29 +651,3 @@ describe('SocketModeReceiver', function () { }); }); }); - -/* Testing Harness */ - -// Loading the system under test using overrides -async function importSocketModeReceiver( - overrides: Override = {}, -): Promise { - return (await rewiremock.module(() => import('./SocketModeReceiver'), overrides)).default; -} - -// Composable overrides -function withHttpCreateServer(spy: SinonSpy): Override { - return { - http: { - createServer: spy, - }, - }; -} - -function withHttpsCreateServer(spy: SinonSpy): Override { - return { - https: { - createServer: spy, - }, - }; -} diff --git a/src/receivers/verify-request.spec.ts b/test/unit/receivers/verify-request.spec.ts similarity index 68% rename from src/receivers/verify-request.spec.ts rename to test/unit/receivers/verify-request.spec.ts index 783359e8e..7554abb9c 100644 --- a/src/receivers/verify-request.spec.ts +++ b/test/unit/receivers/verify-request.spec.ts @@ -1,7 +1,6 @@ -import 'mocha'; -import { createHmac } from 'crypto'; +import { createHmac } from 'node:crypto'; import { assert } from 'chai'; -import { isValidSlackRequest, verifySlackRequest } from './verify-request'; +import { isValidSlackRequest, verifySlackRequest } from '../../../src/receivers/verify-request'; describe('Request verification', async () => { const signingSecret = 'secret'; @@ -38,7 +37,11 @@ describe('Request verification', async () => { body: rawBody, }); } catch (e) { - assert.equal((e as any).message, 'Failed to verify authenticity: x-slack-request-timestamp must differ from system time by no more than 5 minutes or request is stale'); + assert.propertyVal( + e, + 'message', + 'Failed to verify authenticity: x-slack-request-timestamp must differ from system time by no more than 5 minutes or request is stale', + ); } }); it('should detect an invalid signature', async () => { @@ -54,7 +57,7 @@ describe('Request verification', async () => { body: rawBody, }); } catch (e) { - assert.equal((e as any).message, 'Failed to verify authenticity: signature mismatch'); + assert.propertyVal(e, 'message', 'Failed to verify authenticity: signature mismatch'); } }); }); @@ -66,14 +69,16 @@ describe('Request verification', async () => { const hmac = createHmac('sha256', signingSecret); hmac.update(`v0:${timestamp}:${rawBody}`); const signature = hmac.digest('hex'); - assert.isTrue(isValidSlackRequest({ - signingSecret, - headers: { - 'x-slack-signature': `v0=${signature}`, - 'x-slack-request-timestamp': timestamp, - }, - body: rawBody, - })); + assert.isTrue( + isValidSlackRequest({ + signingSecret, + headers: { + 'x-slack-signature': `v0=${signature}`, + 'x-slack-request-timestamp': timestamp, + }, + body: rawBody, + }), + ); }); it('should detect an invalid timestamp', async () => { const timestamp = Math.floor(Date.now() / 1000) - 600; // 10 minutes @@ -81,26 +86,30 @@ describe('Request verification', async () => { const hmac = createHmac('sha256', signingSecret); hmac.update(`v0:${timestamp}:${rawBody}`); const signature = hmac.digest('hex'); - assert.isFalse(isValidSlackRequest({ - signingSecret, - headers: { - 'x-slack-signature': `v0=${signature}`, - 'x-slack-request-timestamp': timestamp, - }, - body: rawBody, - })); + assert.isFalse( + isValidSlackRequest({ + signingSecret, + headers: { + 'x-slack-signature': `v0=${signature}`, + 'x-slack-request-timestamp': timestamp, + }, + body: rawBody, + }), + ); }); it('should detect an invalid signature', async () => { const timestamp = Math.floor(Date.now() / 1000); const rawBody = '{"foo":"bar"}'; - assert.isFalse(isValidSlackRequest({ - signingSecret, - headers: { - 'x-slack-signature': 'v0=invalid-signature', - 'x-slack-request-timestamp': timestamp, - }, - body: rawBody, - })); + assert.isFalse( + isValidSlackRequest({ + signingSecret, + headers: { + 'x-slack-signature': 'v0=invalid-signature', + 'x-slack-request-timestamp': timestamp, + }, + body: rawBody, + }), + ); }); }); }); diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json deleted file mode 100644 index d554c4b36..000000000 --- a/tsconfig.eslint.json +++ /dev/null @@ -1,26 +0,0 @@ -// This config is only used to allow ESLint to use a different include / exclude setting than the actual build -{ - // extend the build config to share compilerOptions - "extends": "./tsconfig.json", - "compilerOptions": { - // Setting "noEmit" prevents misuses of this config such as using it to produce a build - "noEmit": true - }, - "include": [ - // Since extending a config overwrites the entire value for "include", those value are copied here - "src/**/*", - - // List files that should be linted by ESLint, but are not part of the tsconfig used for the actual build - ".eslintrc.js", - "docs/**/*", - "examples/**/*", - "types-tests/**/*" - ], - "exclude": [ - // Overwrite exclude from the base config to clear the value - - // Contains external module type definitions, which are not subject to this project's style rules - "types/**/*", - // Contain intentional type checking issues for the purpose of testing the typechecker's output - ] -} diff --git a/tsconfig.json b/tsconfig.json index db3790155..d4105a9ce 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,6 @@ "src/**/*" ], "exclude": [ - "**/*.spec.ts", - "src/test-helpers.ts" + "test/**/*" ] } diff --git a/types-tests/action.test-d.ts b/types-tests/action.test-d.ts deleted file mode 100644 index 49e5a37c0..000000000 --- a/types-tests/action.test-d.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { expectError, expectType } from 'tsd'; -import { App, BlockElementAction, InteractiveAction, DialogSubmitAction } from '..'; - -const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); - -// calling action method with incorrect an type constraint value should not work -expectError(app.action({ type: 'Something wrong' }, async ({ action }) => { - await Promise.resolve(action); -})); - -expectType(app.action({ type: 'block_actions' }, async ({ action }) => { - expectType(action); - await Promise.resolve(action); -})); - -expectType(app.action({ type: 'interactive_message' }, async ({ action }) => { - expectType(action); - await Promise.resolve(action); -})); - -expectType(app.action({ type: 'dialog_submission' }, async ({ action }) => { - expectType(action); - await Promise.resolve(action); -})); diff --git a/types-tests/command.test-d.ts b/types-tests/command.test-d.ts deleted file mode 100644 index cfe8afa44..000000000 --- a/types-tests/command.test-d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { expectType } from 'tsd'; -import { App, SlashCommand } from '../dist'; - -const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); - -expectType(app.command('/hello', async ({ command }) => { - expectType(command); - await Promise.resolve(command); -})); diff --git a/types-tests/event.test-d.ts b/types-tests/event.test-d.ts deleted file mode 100644 index 06a27ab8f..000000000 --- a/types-tests/event.test-d.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { expectNotType, expectType } from 'tsd'; -import { App, SlackEvent, AppMentionEvent, ReactionAddedEvent, ReactionRemovedEvent, UserHuddleChangedEvent, UserProfileChangedEvent, UserStatusChangedEvent, PinAddedEvent, SayFn, PinRemovedEvent } from '..'; - -const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); - -expectType( - app.event('app_mention', async ({ event }) => { - expectType(event); - expectNotType(event); - await Promise.resolve(event); - }), -); - -expectType( - app.event('reaction_added', async ({ event }) => { - expectType(event); - expectNotType(event); - await Promise.resolve(event); - }), -); - -expectType( - app.event('reaction_removed', async ({ event }) => { - expectType(event); - expectNotType(event); - await Promise.resolve(event); - }), -); - -expectType( - app.event('user_huddle_changed', async ({ event }) => { - expectType(event); - expectNotType(event); - await Promise.resolve(event); - }), -); - -expectType( - app.event('user_profile_changed', async ({ event }) => { - expectType(event); - expectNotType(event); - await Promise.resolve(event); - }), -); - -expectType( - app.event('user_status_changed', async ({ event }) => { - expectType(event); - expectNotType(event); - await Promise.resolve(event); - }), -); - -expectType( - app.event('pin_added', async ({ say, event }) => { - expectType(say); - expectType(event); - await Promise.resolve(event); - }), -); - -expectType( - app.event('pin_removed', async ({ say, event }) => { - expectType(say); - expectType(event); - await Promise.resolve(event); - }), -); - -expectType( - app.event('reaction_added', async ({ say, event }) => { - expectType(say); - await Promise.resolve(event); - }), -); - -expectType( - app.event('reaction_removed', async ({ say, event }) => { - expectType(say); - await Promise.resolve(event); - }), -); diff --git a/types-tests/middleware.test-d.ts b/types-tests/middleware.test-d.ts deleted file mode 100644 index 214ebf260..000000000 --- a/types-tests/middleware.test-d.ts +++ /dev/null @@ -1,19 +0,0 @@ -import App from '../src/App'; -import { onlyViewActions, onlyCommands } from '../src/middleware/builtin'; - -const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); - -// https://github.com/slackapi/bolt-js/issues/911 -app.use(async (args) => { - onlyViewActions(args); -}); -app.use(async (args) => { - onlyCommands(args); -}); -app.use(async ({ ack, next }) => { - if (ack) { - await ack(); - return; - } - await next(); -}); diff --git a/types-tests/options.test-d.ts b/types-tests/options.test-d.ts deleted file mode 100644 index a9d273f80..000000000 --- a/types-tests/options.test-d.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { expectType, expectError } from 'tsd'; -import { - App, - SlackOptions, - BlockSuggestion, - InteractiveMessageSuggestion, - DialogSuggestion, -} from '../dist'; -import { Option } from '@slack/types'; - -const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); - -const blockSuggestionOptions: Option[] = [ - { - text: { - type: 'plain_text', - text: 'foo', - }, - value: 'bar', - }, -]; - -// set the default to block_suggestion -expectType( - app.options('action-id-or-callback-id', async ({ options, ack }) => { - expectType(options); - // resolved by StringIndexed - expectType(options.callback_id); - options.block_id; - options.action_id; - // https://github.com/slackapi/bolt-js/issues/720 - await ack({ options: blockSuggestionOptions }); - await Promise.resolve(options); - }), -); - -// block_suggestion -expectType( - app.options<'block_suggestion'>({ action_id: 'a' }, async ({ options, ack }) => { - expectType(options); - // https://github.com/slackapi/bolt-js/issues/720 - await ack({ options: blockSuggestionOptions }); - await Promise.resolve(options); - }), -); -// FIXME: app.options({ type: 'block_suggestion', action_id: 'a' } does not work - -// interactive_message (attachments) -expectType( - app.options<'interactive_message'>({ callback_id: 'a' }, async ({ options, ack }) => { - expectType(options); - ack({ options: blockSuggestionOptions }); - await Promise.resolve(options); - }), -); - -expectType( - app.options({ type: 'interactive_message', callback_id: 'a' }, async ({ options, ack }) => { - // FIXME: the type should be OptionsRequest<'interactive_message'> - expectType(options); - // https://github.com/slackapi/bolt-js/issues/720 - expectError(ack({ options: blockSuggestionOptions })); - await Promise.resolve(options); - }), -); - -// dialog_suggestion (dialog) -expectType( - app.options<'dialog_suggestion'>({ callback_id: 'a' }, async ({ options, ack }) => { - expectType(options); - // https://github.com/slackapi/bolt-js/issues/720 - expectError(ack({ options: blockSuggestionOptions })); - await Promise.resolve(options); - }), -); -// FIXME: app.options({ type: 'dialog_suggestion', callback_id: 'a' } does not work - -const db = { - get: (_teamId: String) => { return [{ label: 'l', value: 'v' }]; }, -}; - -expectType( - // Taken from https://slack.dev/bolt-js/concepts#options - // Example of responding to an external_select options request - app.options('external_action', async ({ options, ack }) => { - // Get information specific to a team or channel - // (modified to satisfy TS compiler) - const results = options.team != null ? await db.get(options.team.id) : []; - - if (results) { - // (modified to satisfy TS compiler) - let options: Option[] = []; - // Collect information in options array to send in Slack ack response - for (const result of results) { - options.push({ - "text": { - "type": "plain_text", - "text": result.label - }, - "value": result.value - }); - } - - await ack({ - "options": options - }); - } else { - await ack(); - } - }) -); diff --git a/types-tests/shortcut.test-d.ts b/types-tests/shortcut.test-d.ts deleted file mode 100644 index b56aa19f3..000000000 --- a/types-tests/shortcut.test-d.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { expectError, expectType } from 'tsd'; -import { App, GlobalShortcut, MessageShortcut, SayFn } from '../'; - -const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); - -// calling shortcut method with incorrect an type constraint value should not work -expectError(app.shortcut({ type: 'Something wrong' }, async ({ shortcut }) => { - await Promise.resolve(shortcut); -})); - -// Shortcut in listener should be - MessageShortcut -expectType(app.shortcut({ type: 'message_action' }, async ({ shortcut }) => { - expectType(shortcut); - await Promise.resolve(shortcut); -})); - -// If shortcut is parameterized with MessageShortcut, shortcut argument in callback should be type MessageShortcut -expectType(app.shortcut({}, async ({ shortcut }) => { - expectType(shortcut); - await Promise.resolve(shortcut); -})); - -expectType(app.shortcut({}, async ({ shortcut, say }) => { - expectType(say); - await Promise.resolve(shortcut); -})); - -// If shortcut is parameterized with MessageShortcut, say argument in callback should be type SayFn -expectType(app.shortcut({}, async ({ shortcut, say }) => { - expectType(say); - await Promise.resolve(shortcut); -})); - -// If shortcut is parameterized with GlobalShortcut, say argument in callback should be type undefined -expectType(app.shortcut({}, async ({ shortcut, say }) => { - expectType(say); - await Promise.resolve(shortcut); -})); diff --git a/types-tests/utilities.test-d.ts b/types-tests/utilities.test-d.ts deleted file mode 100644 index 7fb81900e..000000000 --- a/types-tests/utilities.test-d.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { expectType } from 'tsd'; -import { ChatPostMessageResponse } from '@slack/web-api'; -// eslint-disable-next-line -import App from '../src/App'; -import { InteractiveButtonClick } from '../src/types'; - -const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); - -app.action('my_callback_id', async ({ respond, say }) => { - // Expect respond to work with text - await respond({ text: 'Some text' }); - - // Expect respond to work without text - await respond({ delete_original: true }); - - // Expect say to work with text - const response = await say({ text: 'Some more text' }); - expectType(response); - say({ blocks: [] }); -}); diff --git a/types-tests/view.test-d.ts b/types-tests/view.test-d.ts deleted file mode 100644 index 721e6331f..000000000 --- a/types-tests/view.test-d.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { expectType } from 'tsd'; -import { App, SlackViewAction, ViewOutput } from '..'; - -const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); - -// view_submission -expectType( - app.view('modal-id', async ({ body, view }) => { - // TODO: the body can be more specific (ViewSubmitAction) here - expectType(body); - expectType(view); - await Promise.resolve(view); - }) -); - -expectType( - app.view({ type: 'view_submission', callback_id: 'modal-id' }, async ({ body, view }) => { - // TODO: the body can be more specific (ViewSubmitAction) here - expectType(body); - expectType(view); - await Promise.resolve(view); - }) -); - -// view_closed -expectType( - app.view({ type: 'view_closed', callback_id: 'modal-id' }, async ({ body, view }) => { - // TODO: the body can be more specific (ViewClosedAction) here - expectType(body); - expectType(view); - await Promise.resolve(view); - }) -); - -const viewSubmissionPayload: ViewOutput = { - "id": "V111", - "team_id": "T111", - "type": "modal", - "blocks": [ - { - "type": "divider", - "block_id": "+3ht" - } - ], - "private_metadata": "", - "callback_id": "", - "state": { - "values": { - "aPVYH": { - "g/t5": { - "type": "radio_buttons", - "selected_option": null - } - }, - "1pSa": { - "h3R": { - "type": "multi_static_select", - "selected_options": [] - } - }, - "a/Rt": { - "zmPQ": { - "type": "plain_text_input", - "value": null - } - }, - "7/wWO": { - "HdJj": { - "type": "plain_text_input", - "value": "test" - } - } - } - }, - "hash": "1618378109.3ndA0Spf", - "title": { - "type": "plain_text", - "text": "Workplace check-in", - "emoji": true - }, - "clear_on_close": false, - "notify_on_close": false, - "close": { - "type": "plain_text", - "text": "Cancel", - "emoji": true - }, - "submit": { - "type": "plain_text", - "text": "Submit", - "emoji": true - }, - "previous_view_id": null, - "root_view_id": "V1234567890", - "app_id": "A02", - "external_id": "", - "app_installed_team_id": "T5J4Q04QG", - "bot_id": "B00" -}; -expectType(viewSubmissionPayload); \ No newline at end of file