diff --git a/.eslintrc.json b/.eslintrc.json index 9acdafc..bb033d1 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -19,10 +19,12 @@ }, "plugins": ["import", "prettier"], "rules": { - + "no-plusplus": [2, { + "allowForLoopAfterthoughts": true + }], "prettier/prettier": [ "error" ] }, - "ignorePatterns": ["jest.config.js"] + "ignorePatterns": ["jest.config.ts"] } diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 81ae109..0000000 --- a/jest.config.js +++ /dev/null @@ -1,7 +0,0 @@ -/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ -module.exports = { - roots: ['./test'], - preset: 'ts-jest', - testEnvironment: 'node', - moduleFileExtensions: ['js', 'ts'], -}; diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..85af1ef --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,11 @@ +import type { Config } from '@jest/types'; + +const config: Config.InitialOptions = { + verbose: true, + roots: ['./test'], + preset: 'ts-jest', + testEnvironment: 'node', + moduleFileExtensions: ['js', 'ts'], +}; + +export default config; diff --git a/package-lock.json b/package-lock.json index 05bd366..140eab2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "prettier": "2.6.2", "redis-mock": "^0.56.3", "ts-jest": "^28.0.2", + "ts-node": "^10.8.0", "typescript": "^4.6.4" } }, @@ -1824,6 +1825,28 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@eslint/eslintrc": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.2.3.tgz", @@ -2427,6 +2450,30 @@ "@sinonjs/commons": "^1.7.0" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.8.tgz", + "integrity": "sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.9.tgz", + "integrity": "sha512-/yBMcem+fbvhSREH+s14YJi18sp7J9jpuhYByADT2rypfajMZZN4WQ6zBGgBKp53NKmqI36wFYDb3yaMPurITw==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.1.tgz", + "integrity": "sha512-509r2+yARFfHHE7T6Puu2jjkoycftovhXRqW328PDXTVGKihlb1P8Z9mMZH04ebyajfRY7dedfGynlrFHJUQCg==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.2.tgz", + "integrity": "sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==", + "dev": true + }, "node_modules/@types/babel__core": { "version": "7.1.19", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.19.tgz", @@ -2951,6 +2998,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -3044,6 +3100,12 @@ "node": ">= 8" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3599,6 +3661,12 @@ "semver": "bin/semver.js" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -3676,6 +3744,15 @@ "node": ">=8" } }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "28.0.2", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-28.0.2.tgz", @@ -7324,6 +7401,49 @@ "node": ">=10" } }, + "node_modules/ts-node": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.8.0.tgz", + "integrity": "sha512-/fNd5Qh+zTt8Vt1KbYZjRHCE9sI5i7nqfD/dzBBRDeVXZXS6kToW6R7tTU6Nd4XavFs0mAVCg29Q//ML7WsZYA==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, "node_modules/tsconfig-paths": { "version": "3.14.1", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", @@ -7473,6 +7593,12 @@ "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", "dev": true }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, "node_modules/v8-to-istanbul": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.0.0.tgz", @@ -7679,6 +7805,15 @@ "engines": { "node": ">=8" } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } } }, "dependencies": { @@ -8944,6 +9079,27 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "dependencies": { + "@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + } + } + }, "@eslint/eslintrc": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.2.3.tgz", @@ -9421,6 +9577,30 @@ "@sinonjs/commons": "^1.7.0" } }, + "@tsconfig/node10": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.8.tgz", + "integrity": "sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg==", + "dev": true + }, + "@tsconfig/node12": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.9.tgz", + "integrity": "sha512-/yBMcem+fbvhSREH+s14YJi18sp7J9jpuhYByADT2rypfajMZZN4WQ6zBGgBKp53NKmqI36wFYDb3yaMPurITw==", + "dev": true + }, + "@tsconfig/node14": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.1.tgz", + "integrity": "sha512-509r2+yARFfHHE7T6Puu2jjkoycftovhXRqW328PDXTVGKihlb1P8Z9mMZH04ebyajfRY7dedfGynlrFHJUQCg==", + "dev": true + }, + "@tsconfig/node16": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.2.tgz", + "integrity": "sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==", + "dev": true + }, "@types/babel__core": { "version": "7.1.19", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.19.tgz", @@ -9825,6 +10005,12 @@ "dev": true, "requires": {} }, + "acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true + }, "aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -9889,6 +10075,12 @@ "picomatch": "^2.0.4" } }, + "arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -10311,6 +10503,12 @@ } } }, + "create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -10365,6 +10563,12 @@ "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", "dev": true }, + "diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true + }, "diff-sequences": { "version": "28.0.2", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-28.0.2.tgz", @@ -13041,6 +13245,27 @@ } } }, + "ts-node": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.8.0.tgz", + "integrity": "sha512-/fNd5Qh+zTt8Vt1KbYZjRHCE9sI5i7nqfD/dzBBRDeVXZXS6kToW6R7tTU6Nd4XavFs0mAVCg29Q//ML7WsZYA==", + "dev": true, + "requires": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + } + }, "tsconfig-paths": { "version": "3.14.1", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", @@ -13150,6 +13375,12 @@ "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", "dev": true }, + "v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, "v8-to-istanbul": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.0.0.tgz", @@ -13312,6 +13543,12 @@ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.1.tgz", "integrity": "sha512-9BK1jFpLzJROCI5TzwZL/TU4gqjK5xiHV/RfWLOahrjAko/e4DJkRDZQXfvqAsiZzzYhgAzbgz6lg48jcm4GLg==", "dev": true + }, + "yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true } } } diff --git a/package.json b/package.json index a154acd..a219b9d 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,11 @@ "version": "1.0.0", "description": "A GraphQL rate limiting library using query complexity analysis.", "main": "index.js", + "type": "module", "scripts": { "test": "jest --passWithNoTests", "lint": "eslint src test", - "lint:fix": "eslint --fix src test", + "lint:fix": "eslint --fix src test @types", "prettier": "prettier --write .", "prepare": "husky install" }, @@ -43,6 +44,7 @@ "prettier": "2.6.2", "redis-mock": "^0.56.3", "ts-jest": "^28.0.2", + "ts-node": "^10.8.0", "typescript": "^4.6.4" }, "lint-staged": { diff --git a/src/@types/rateLimit.d.ts b/src/@types/rateLimit.d.ts index 91ec955..302821c 100644 --- a/src/@types/rateLimit.d.ts +++ b/src/@types/rateLimit.d.ts @@ -40,4 +40,6 @@ interface TokenBucketOptions { } // TODO: This will be a union type where we can specify Option types for other Rate Limiters -type RateLimiterOptions = TokenBucketOptions; +// Record represents the empty object for alogorithms that don't require settings +// and might be able to be removed in the future. +type RateLimiterOptions = TokenBucketOptions | Record; diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 57fc4d6..80ba2c3 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -17,7 +17,8 @@ import { defaultTypeWeightsConfig } from '../analysis/buildTypeWeights'; * Defaults to {mutation: 10, object: 1, field: 0, connection: 2} * @returns {RequestHandler} express middleware that computes the complexity of req.query and calls the next middleware * if the query is allowed or sends a 429 status if the request is blocked - * @throws ValidationError if GraphQL Schema is invalid + * FIXME: How about the specific GraphQLError? + * @throws ValidationError if GraphQL Schema is invalid. */ export function expressRateLimiter( rateLimiter: RateLimiterSelection, @@ -31,7 +32,6 @@ export function expressRateLimiter( // TODO: Connect to Redis store using provided options. Default to localhost:6379 // TODO: Configure the selected RateLimtier // TODO: Configure the complexity analysis algorithm to run for incoming requests - const middleware: RequestHandler = (req: Request, res: Response, next: NextFunction) => { // TODO: Parse query from req.query, compute complexity and pass necessary info to rate limiter // TODO: Call next if query is successful, send 429 status if query blocked, call next(err) with any thrown errors diff --git a/test/middleware/express.test.ts b/test/middleware/express.test.ts new file mode 100644 index 0000000..aa75196 --- /dev/null +++ b/test/middleware/express.test.ts @@ -0,0 +1,358 @@ +import { Request, Response, NextFunction, RequestHandler } from 'express'; +import { GraphQLSchema, buildSchema } from 'graphql'; +import * as redis from 'redis'; +import redisMock from 'redis-mock'; +import { RedisClientType } from 'redis'; +import expressRateLimitMiddleware from '../../src/middleware/index'; + +let middleware: RequestHandler; +let mockRequest: Partial; +let complexRequest: Partial; +let mockResponse: Partial; +let nextFunction: NextFunction = jest.fn(); +const schema: GraphQLSchema = buildSchema(` + type Query { + hero(episode: Episode): Character + reviews(episode: Episode!, first: Int): [Review] + search(text: String): [SearchResult] + character(id: ID!): Character + droid(id: ID!): Droid + human(id: ID!): Human + scalars: Scalars + } + enum Episode { + NEWHOPE + EMPIRE + JEDI + } + interface Character { + id: ID! + name: String! + friends: [Character] + appearsIn: [Episode]! + } + type Human implements Character { + id: ID! + name: String! + homePlanet: String + friends: [Character] + appearsIn: [Episode]! + } + type Droid implements Character { + id: ID! + name: String! + friends: [Character] + primaryFunction: String + appearsIn: [Episode]! + } + type Review { + episode: Episode + stars: Int! + commentary: String + } + union SearchResult = Human | Droid + type Scalars { + num: Int, + id: ID, + float: Float, + bool: Boolean, + string: String + test: Test, + } + type Test { + name: String, + variable: Scalars + } + `); + +xdescribe('Express Middleware tests', () => { + describe('Middleware is configurable...', () => { + describe('...successfully connects to redis using standard connection options', () => { + beforeEach(() => { + // TODO: Setup mock redis store. + }); + + test('...via url', () => { + // TODO: Connect to redis instance and add 'connect' event listener + // assert that event listener is called once + expect(true).toBeFalsy(); + }); + + xtest('via socket', () => { + // TODO: Connect to redis instance and add 'connect' event listener + // assert that event listener is called once + expect(true).toBeFalsy(); + }); + + xtest('defaults to localhost', () => { + // TODO: Connect to redis instance and add 'connect' event listener + // assert that event listener is called once + expect(true).toBeFalsy(); + }); + }); + + describe('...Can be configured to use a valid algorithm', () => { + test('... Token Bucket', () => { + // FIXME: Is it possible to check which algorithm was chosen beyond error checking? + expect( + expressRateLimitMiddleware( + 'TOKEN_BUCKET', + { refillRate: 1, bucketSize: 10 }, + schema, + { url: '' } + ) + ).not.toThrow(); + }); + + xtest('...Leaky Bucket', () => { + expect( + expressRateLimitMiddleware( + 'LEAKY_BUCKET', + { refillRate: 1, bucketSize: 10 }, // FIXME: Replace with valid params + schema, + { url: '' } + ) + ).not.toThrow(); + }); + + xtest('...Fixed Window', () => { + expect( + expressRateLimitMiddleware( + 'FIXED_WINDOW', + { refillRate: 1, bucketSize: 10 }, // FIXME: Replace with valid params + schema, + { url: '' } + ) + ).not.toThrow(); + }); + + xtest('...Sliding Window', () => { + expect( + expressRateLimitMiddleware( + 'SLIDING_WINDOW_LOG', + { refillRate: 1, bucketSize: 10 }, // FIXME: Replace with valid params + schema, + { url: '' } + ) + ).not.toThrow(); + }); + + xtest('...Sliding Window Counter', () => { + expect( + expressRateLimitMiddleware( + 'SLIDING_WINDOW_COUNTER', + { refillRate: 1, bucketSize: 10 }, // FIXME: Replace with valid params + schema, + { url: '' } + ) + ).not.toThrow(); + }); + }); + + test('Throw an error for invalid schemas', () => { + const invalidSchema: GraphQLSchema = buildSchema(`{Query {name}`); + + expect( + expressRateLimitMiddleware('TOKEN_BUCKET', {}, invalidSchema, { url: '' }) + ).toThrowError('ValidationError'); + }); + + test('Throw an error in unable to connect to redis', () => { + expect( + expressRateLimitMiddleware( + 'TOKEN_BUCKET', + { bucketSize: 10, refillRate: 1 }, + schema, + { socket: { host: 'localhost', port: 1 } } + ) + ).toThrow('ECONNREFUSED'); + }); + }); + + describe('Middleware is Functional', () => { + // Before each test configure a new middleware amd mock req, res objects. + beforeAll(() => { + jest.useFakeTimers('modern'); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + middleware = expressRateLimitMiddleware( + 'TOKEN_BUCKET', + { refillRate: 1, bucketSize: 10 }, + schema, + {} + ); + mockRequest = { + query: { + // complexity should be 2 (1 Query + 1 Scalar) + query: `Query { + scalars: { + num + } + `, + }, + ip: '123.456', + }; + + mockResponse = { + json: jest.fn(), + send: jest.fn(), + sendStatus: jest.fn(), + locals: {}, + }; + + complexRequest = { + // complexity should be 10 if 'first' is accounted for. + // Query: 1, droid: 1, reviews 8: 1) + query: { + query: `Query { + droid(id: 1) { + name + } + reviews(episode: 'NEWHOPE', first: 8) { + episode + stars + commentary + } + `, + }, + }; + nextFunction = jest.fn(); + }); + + describe('Adds expected properties to res.locals', () => { + test('Adds UNIX timestamp and complexity', () => { + const expectedResponse = { + locals: {}, + }; + + middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockResponse.locals).toHaveProperty('complexity'); + expect(mockResponse.locals?.complexity).toBeInstanceOf('number'); + expect(mockResponse.locals?.complexity).toBeGreaterThanOrEqual(0); + + expect(mockResponse.locals).toHaveProperty('timestamp'); + expect(mockResponse.locals?.timestamp).toBeInstanceOf('number'); + // confirm that this is timestamp +/- 5 minutes of now. + const now: number = Date.now().valueOf(); + const diff: number = Math.abs(now - (mockResponse.locals?.timestamp || 0)); + expect(diff).toBeLessThan(5 * 60); + }); + }); + + describe('Correctly limits requests', () => { + describe('Allows requests', () => { + test('...a single request', () => { + // successful request calls next without any arguments. + middleware(mockRequest as Request, mockResponse as Response, nextFunction); + expect(nextFunction).toBeCalledTimes(1); + expect(nextFunction).toBeCalledWith(); + }); + + test('Multiple valid requests at > 1 second intervals', () => { + for (let i = 0; i < 3; i++) { + const next: NextFunction = jest.fn(); + middleware(complexRequest as Request, mockResponse as Response, next); + expect(next).toBeCalledTimes(1); + expect(next).toBeCalledWith(); + + // advance the timers by 1 second for the next request + jest.advanceTimersByTime(1000); + } + }); + + test('Multiple valid requests at within one second', () => { + for (let i = 0; i < 3; i++) { + const next: NextFunction = jest.fn(); + middleware(complexRequest as Request, mockResponse as Response, next); + expect(next).toBeCalledTimes(1); + expect(next).toBeCalledWith(); + + // advance the timers by 1 second for the next request + jest.advanceTimersByTime(20); + } + }); + }); + + describe('BLOCKS requests', () => { + test('A single request that exceeds capacity', () => { + const blockedRequest: Partial = { + // complexity should be 12 if 'first' is accounted for. + // scalars: 1, droid: 1, reviews (10 * (1 Review, 0 episode)) + query: { + query: `Query { + scalars: { + num + } + droid(id: 1) { + name + } + reviews(episode: 'NEWHOPE', first: 10) { + episode + stars + commentary + } + `, + }, + }; + + middleware(blockedRequest as Request, mockResponse as Response, nextFunction); + expect(mockResponse.statusCode).toBe(429); + expect(nextFunction).not.toBeCalled(); + + // FIXME: There are multiple functions to send a response + // json, send html, sendStatus etc. How do we check at least one was called + expect(mockResponse.send).toBeCalled(); + }); + + test('Multiple queries that exceed token limit', () => { + for (let i = 0; i < 5; i++) { + // Send 5 queries of complexity 2. These should all succeed + middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + // advance the timers by 20 miliseconds for the next request + jest.advanceTimersByTime(20); + } + + // Send a 6th request that should be blocked. + const next: NextFunction = jest.fn(); + middleware(mockRequest as Request, mockResponse as Response, next); + expect(mockResponse.statusCode).toBe(429); + expect(next).not.toBeCalled(); + + // FIXME: See above comment on sending responses + expect(mockResponse.send).toBeCalled(); + }); + }); + }); + + xtest('Uses User IP Address in Redis', async () => { + // FIXME: In order to test this accurately the middleware would need to connect + // to a mock instance or the tests would need to connect to an actual redis instance + // We could use NODE_ENV varibale in the implementation to determine the connection type. + + // TODO: connect to the actual redis client here. Make sure to disconnect for proper teardown + const client: RedisClientType = redisMock.createClient(); + await client.connect(); + // Check for change in the redis store for the IP key + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore mockRequest will always have an ip address. + const initialValue: string | null = await client.get(mockRequest.ip); + + middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const finalValue: string | null = await client.get(mockRequest.ip); + + expect(finalValue).not.toBeNull(); + expect(finalValue).not.toBe(initialValue); + }); + }); +});