Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import type { Request, Response } from 'express';
2
3import { createBodySchemaValidator } from './bodySchemaValidation.js';
4import { CoopError } from './errors.js';
5
6const schema: Record<string, unknown> = {
7 $schema: 'http://json-schema.org/draft-04/schema#',
8 type: 'object',
9 properties: {
10 name: { type: 'string' },
11 count: { type: 'integer' },
12 },
13 required: ['name'],
14 additionalProperties: false,
15};
16
17function invoke(
18 middleware: ReturnType<typeof createBodySchemaValidator>,
19 body: unknown,
20) {
21 const req: Partial<Request> = { body };
22 const res: Partial<Response> = {};
23 const next = jest.fn();
24 middleware(req as Request, res as Response, next);
25 return { next };
26}
27
28function firstNextArg(next: ReturnType<typeof jest.fn>): unknown {
29 return next.mock.calls[0]?.[0];
30}
31
32describe('createBodySchemaValidator', () => {
33 test('passes valid bodies through to next()', () => {
34 const middleware = createBodySchemaValidator(schema);
35 const { next } = invoke(middleware, { name: 'ok', count: 3 });
36
37 expect(next).toHaveBeenCalledTimes(1);
38 expect(next).toHaveBeenCalledWith();
39 });
40
41 test('allows optional fields to be omitted', () => {
42 const middleware = createBodySchemaValidator(schema);
43 const { next } = invoke(middleware, { name: 'ok' });
44
45 expect(next).toHaveBeenCalledTimes(1);
46 expect(next).toHaveBeenCalledWith();
47 });
48
49 test('forwards a BadRequestError when a required field is missing', () => {
50 const middleware = createBodySchemaValidator(schema);
51 const { next } = invoke(middleware, { count: 3 });
52
53 expect(next).toHaveBeenCalledTimes(1);
54 const err = firstNextArg(next);
55 expect(err).toBeInstanceOf(CoopError);
56 expect(err).toMatchObject({
57 name: 'BadRequestError',
58 status: 400,
59 title: 'Request body failed schema validation.',
60 });
61 // Error message should reference the missing field, not crash.
62 expect((err as CoopError).detail).toContain('name');
63 });
64
65 test('forwards a BadRequestError when a field has the wrong type', () => {
66 const middleware = createBodySchemaValidator(schema);
67 const { next } = invoke(middleware, { name: 'ok', count: 'not-a-number' });
68
69 expect(next).toHaveBeenCalledTimes(1);
70 const err = firstNextArg(next);
71 expect(err).toBeInstanceOf(CoopError);
72 expect(err).toMatchObject({
73 name: 'BadRequestError',
74 status: 400,
75 pointer: '/count',
76 });
77 });
78
79 test('rejects unknown additional properties when the schema forbids them', () => {
80 const middleware = createBodySchemaValidator(schema);
81 const { next } = invoke(middleware, { name: 'ok', surprise: true });
82
83 expect(next).toHaveBeenCalledTimes(1);
84 const err = firstNextArg(next);
85 expect(err).toBeInstanceOf(CoopError);
86 expect(err).toMatchObject({ name: 'BadRequestError', status: 400 });
87 });
88
89 test('rejects non-object bodies (e.g., undefined from a request with no body)', () => {
90 const middleware = createBodySchemaValidator(schema);
91 const { next } = invoke(middleware, undefined);
92
93 expect(next).toHaveBeenCalledTimes(1);
94 const err = firstNextArg(next);
95 expect(err).toBeInstanceOf(CoopError);
96 expect(err).toMatchObject({ name: 'BadRequestError', status: 400 });
97 });
98
99 test('does not leak Ajv internals (schemaPath / params) in the error detail', () => {
100 const middleware = createBodySchemaValidator(schema);
101 const { next } = invoke(middleware, { name: 42 });
102
103 const err = firstNextArg(next) as CoopError;
104 expect(err.detail ?? '').not.toContain('schemaPath');
105 expect(err.detail ?? '').not.toContain('params');
106 });
107});