Skip to content

Commit 6a55933

Browse files
committed
Initial tests for middleware funcitonality.
1 parent 61e96b0 commit 6a55933

File tree

4 files changed

+347
-4
lines changed

4 files changed

+347
-4
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"scripts": {
77
"test": "jest --passWithNoTests",
88
"lint": "eslint src test",
9-
"lint:fix": "eslint --fix src test",
9+
"lint:fix": "eslint --fix src test @types",
1010
"prettier": "prettier --write .",
1111
"prepare": "husky install"
1212
},

src/@types/rateLimit.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,6 @@ interface TokenBucketOptions {
4040
}
4141

4242
// TODO: This will be a union type where we can specify Option types for other Rate Limiters
43-
type RateLimiterOptions = TokenBucketOptions;
43+
// Record<string, never> represents the empty object for alogorithms that don't require settings
44+
// and might be able to be removed in the future.
45+
type RateLimiterOptions = TokenBucketOptions | Record<string, never>;

src/middleware/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import { defaultTypeWeightsConfig } from '../analysis/buildTypeWeights';
1717
* Defaults to {mutation: 10, object: 1, field: 0, connection: 2}
1818
* @returns {RequestHandler} express middleware that computes the complexity of req.query and calls the next middleware
1919
* if the query is allowed or sends a 429 status if the request is blocked
20-
* @throws ValidationError if GraphQL Schema is invalid
20+
* FIXME: How about the specific GraphQLError?
21+
* @throws ValidationError if GraphQL Schema is invalid.
2122
*/
2223
export function expressRateLimiter(
2324
rateLimiter: RateLimiterSelection,
@@ -31,7 +32,6 @@ export function expressRateLimiter(
3132
// TODO: Connect to Redis store using provided options. Default to localhost:6379
3233
// TODO: Configure the selected RateLimtier
3334
// TODO: Configure the complexity analysis algorithm to run for incoming requests
34-
3535
const middleware: RequestHandler = (req: Request, res: Response, next: NextFunction) => {
3636
// TODO: Parse query from req.query, compute complexity and pass necessary info to rate limiter
3737
// TODO: Call next if query is successful, send 429 status if query blocked, call next(err) with any thrown errors

test/middleware/express.test.ts

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
import { Request, Response, NextFunction, RequestHandler } from 'express';
2+
import { GraphQLSchema, buildSchema } from 'graphql';
3+
import redis from 'redis-mock';
4+
import { RedisClientType } from 'redis';
5+
import expressRateLimitMiddleware from '../../src/middleware/index';
6+
import { Socket } from 'net';
7+
8+
let middleware: RequestHandler;
9+
let mockRequest: Partial<Request>;
10+
let complexRequest: Partial<Request>;
11+
let mockResponse: Partial<Response>;
12+
const nextFunction: NextFunction = jest.fn();
13+
const schema: GraphQLSchema = buildSchema(`
14+
type Query {
15+
hero(episode: Episode): Character
16+
reviews(episode: Episode!, first: Int): [Review]
17+
search(text: String): [SearchResult]
18+
character(id: ID!): Character
19+
droid(id: ID!): Droid
20+
human(id: ID!): Human
21+
scalars: Scalars
22+
}
23+
enum Episode {
24+
NEWHOPE
25+
EMPIRE
26+
JEDI
27+
}
28+
interface Character {
29+
id: ID!
30+
name: String!
31+
friends: [Character]
32+
appearsIn: [Episode]!
33+
}
34+
type Human implements Character {
35+
id: ID!
36+
name: String!
37+
homePlanet: String
38+
friends: [Character]
39+
appearsIn: [Episode]!
40+
}
41+
type Droid implements Character {
42+
id: ID!
43+
name: String!
44+
friends: [Character]
45+
primaryFunction: String
46+
appearsIn: [Episode]!
47+
}
48+
type Review {
49+
episode: Episode
50+
stars: Int!
51+
commentary: String
52+
}
53+
union SearchResult = Human | Droid
54+
type Scalars {
55+
num: Int,
56+
id: ID,
57+
float: Float,
58+
bool: Boolean,
59+
string: String
60+
test: Test,
61+
}
62+
type Test {
63+
name: String,
64+
variable: Scalars
65+
}
66+
`);
67+
68+
describe('Express Middleware tests', () => {
69+
xdescribe('Middleware is configurable...', () => {
70+
describe('...successfully connects to redis using standard connection options', () => {
71+
beforeEach(() => {
72+
// TODO: Setup mock redis store.
73+
});
74+
75+
test('...via connection string', () => {
76+
// TODO: use event listener to listen for connections to a mock redis store
77+
expect(true).toBeFalsy();
78+
});
79+
80+
test('via indiividual parameters', () => {
81+
// TODO:
82+
expect(true).toBeFalsy();
83+
});
84+
85+
test('defaults to localhost', () => {
86+
// TODO:
87+
expect(true).toBeFalsy();
88+
});
89+
});
90+
91+
describe('...Can be configured to use a valid algorithm', () => {
92+
test('... Token Bucket', () => {
93+
// FIXME: Is it possible to check which algorithm was chosen beyond error checking?
94+
expect(
95+
expressRateLimitMiddleware(
96+
'TOKEN_BUCKET',
97+
{ refillRate: 1, bucketSize: 10 },
98+
schema,
99+
{ url: '' }
100+
)
101+
).not.toThrow();
102+
});
103+
104+
xtest('...Leaky Bucket', () => {
105+
expect(
106+
expressRateLimitMiddleware(
107+
'LEAKY_BUCKET',
108+
{ refillRate: 1, bucketSize: 10 }, // FIXME: Replace with valid params
109+
schema,
110+
{ url: '' }
111+
)
112+
).not.toThrow();
113+
});
114+
115+
xtest('...Fixed Window', () => {
116+
expect(
117+
expressRateLimitMiddleware(
118+
'FIXED_WINDOW',
119+
{ refillRate: 1, bucketSize: 10 }, // FIXME: Replace with valid params
120+
schema,
121+
{ url: '' }
122+
)
123+
).not.toThrow();
124+
});
125+
126+
xtest('...Sliding Window', () => {
127+
expect(
128+
expressRateLimitMiddleware(
129+
'SLIDING_WINDOW_LOG',
130+
{ refillRate: 1, bucketSize: 10 }, // FIXME: Replace with valid params
131+
schema,
132+
{ url: '' }
133+
)
134+
).not.toThrow();
135+
});
136+
137+
xtest('...Sliding Window Counter', () => {
138+
expect(
139+
expressRateLimitMiddleware(
140+
'SLIDING_WINDOW_COUNTER',
141+
{ refillRate: 1, bucketSize: 10 }, // FIXME: Replace with valid params
142+
schema,
143+
{ url: '' }
144+
)
145+
).not.toThrow();
146+
});
147+
});
148+
149+
test('Throw an error for invalid schemas', () => {
150+
const invalidSchema: GraphQLSchema = buildSchema(`{Query {name}`);
151+
152+
expect(
153+
expressRateLimitMiddleware('TOKEN_BUCKET', {}, invalidSchema, { url: '' })
154+
).toThrowError('ValidationError');
155+
});
156+
});
157+
158+
describe('Middleware is Functional', () => {
159+
// Before each test configure a new middleware amd mock req, res objects.
160+
beforeAll(() => {
161+
jest.useFakeTimers('modern');
162+
});
163+
164+
afterAll(() => {
165+
jest.useRealTimers();
166+
});
167+
168+
beforeEach(() => {
169+
middleware = expressRateLimitMiddleware(
170+
'TOKEN_BUCKET',
171+
{ refillRate: 1, bucketSize: 10 },
172+
schema,
173+
{}
174+
);
175+
mockRequest = {
176+
query: {
177+
// complexity should be 2 (1 Query + 1 Scalar)
178+
query: `Query {
179+
scalars: {
180+
num
181+
}
182+
`,
183+
},
184+
ip: '123.456',
185+
};
186+
187+
mockResponse = {
188+
json: jest.fn(),
189+
send: jest.fn(),
190+
sendStatus: jest.fn(),
191+
locals: {},
192+
};
193+
194+
complexRequest = {
195+
// complexity should be 10 if 'first' is accounted for.
196+
// scalars: 1, droid: 1, reviews (4 * (1 Review, 1 episode))
197+
query: {
198+
query: `Query {
199+
scalars: {
200+
num
201+
}
202+
droid(id: 1) {
203+
name
204+
}
205+
reviews(episode: 'NEWHOPE', first: 4) {
206+
episode
207+
stars
208+
commentary
209+
}
210+
`,
211+
},
212+
};
213+
});
214+
215+
xdescribe('Adds expected properties to res.locals', () => {
216+
test('Adds UNIX timestamp and complexity', () => {
217+
const expectedResponse = {
218+
locals: {},
219+
};
220+
221+
middleware(mockRequest as Request, mockResponse as Response, nextFunction);
222+
223+
// We don't actually call json
224+
expect(mockResponse.json).toBeCalledWith(expectedResponse);
225+
expect(mockResponse.locals).toHaveProperty('complexity');
226+
expect(mockResponse.locals?.complexity).toBeInstanceOf('number');
227+
expect(mockResponse.locals?.complexity).toBeGreaterThanOrEqual(0);
228+
229+
expect(mockResponse.locals).toHaveProperty('timestamp');
230+
expect(mockResponse.locals?.timestamp).toBeInstanceOf('number');
231+
// confirm that this is timestamp +/- 5 minutes of now.
232+
const now: number = Date.now().valueOf();
233+
const diff: number = Math.abs(now - (mockResponse.locals?.timestamp || 0));
234+
expect(diff).toBeLessThan(5 * 60);
235+
});
236+
});
237+
238+
describe('Correctly limits requests', () => {
239+
describe('Allows requests', () => {
240+
test('...a single request', () => {
241+
// successful request calls next without any arguments.
242+
middleware(mockRequest as Request, mockResponse as Response, nextFunction);
243+
expect(nextFunction).toBeCalledTimes(1);
244+
expect(nextFunction).toBeCalledWith();
245+
});
246+
247+
test('Multiple valid requests at > 1 second intervals', () => {
248+
for (let i = 0; i < 3; i++) {
249+
const next: NextFunction = jest.fn();
250+
middleware(complexRequest as Request, mockResponse as Response, next);
251+
expect(next).toBeCalledTimes(1);
252+
expect(next).toBeCalledWith();
253+
254+
// advance the timers by 1 second for the next request
255+
jest.advanceTimersByTime(1000);
256+
}
257+
});
258+
259+
test('Multiple valid requests at within one second', () => {
260+
for (let i = 0; i < 3; i++) {
261+
const next: NextFunction = jest.fn();
262+
middleware(complexRequest as Request, mockResponse as Response, next);
263+
expect(next).toBeCalledTimes(1);
264+
expect(next).toBeCalledWith();
265+
266+
// advance the timers by 1 second for the next request
267+
jest.advanceTimersByTime(20);
268+
}
269+
});
270+
});
271+
272+
describe('BLOCKS requests', () => {
273+
test('A single request that exceeds capacity', () => {
274+
complexRequest = {
275+
// complexity should be 12 if 'first' is accounted for.
276+
// scalars: 1, droid: 1, reviews (5 * (1 Review, 1 episode))
277+
query: {
278+
query: `Query {
279+
scalars: {
280+
num
281+
}
282+
droid(id: 1) {
283+
name
284+
}
285+
reviews(episode: 'NEWHOPE', first: 5) {
286+
episode
287+
stars
288+
commentary
289+
}
290+
`,
291+
},
292+
};
293+
const expectedResponse = {
294+
status: 429,
295+
};
296+
297+
middleware(mockRequest as Request, mockResponse as Response, nextFunction);
298+
expect(mockResponse.status).toBe(429);
299+
expect(nextFunction).not.toBeCalled();
300+
301+
// FIXME: There are multiple functions to send a response
302+
// json, send html, sendStatus etc. How do we check at least one was called
303+
expect(mockResponse.send).toBeCalled();
304+
});
305+
306+
test('Multiple queries that exceed token limit', () => {
307+
for (let i = 0; i < 5; i++) {
308+
// Send 5 queries of complexity 2. These should all succeed
309+
middleware(mockRequest as Request, mockResponse as Response, nextFunction);
310+
311+
// advance the timers by 1 second for the next request
312+
jest.advanceTimersByTime(20);
313+
}
314+
315+
// Send a 6th request that should be blocked.
316+
const next: NextFunction = jest.fn();
317+
middleware(mockRequest as Request, mockResponse as Response, next);
318+
expect(mockResponse.status).toBe(429);
319+
expect(next).not.toBeCalled();
320+
321+
// FIXME: See above comment on sending responses
322+
expect(mockResponse.send).toBeCalled();
323+
});
324+
});
325+
});
326+
327+
test('Uses User IP Address in Redis', async () => {
328+
const client: RedisClientType = redis.createClient();
329+
// Check for change in the redis store for the IP key
330+
if (!mockRequest.ip) throw new Error('Expected ip to exist on mockRequest');
331+
const initialValue: string | null = await client.get(mockRequest?.ip);
332+
333+
middleware(mockRequest as Request, mockResponse as Response, nextFunction);
334+
335+
const finalValue: string | null = await client.get(mockRequest?.ip);
336+
337+
expect(finalValue).not.toBeNull();
338+
expect(finalValue).not.toBe(initialValue);
339+
});
340+
});
341+
});

0 commit comments

Comments
 (0)