Skip to content

Commit adf885b

Browse files
Merge pull request #1479 from inteee/inteee/webhooksapi-1441-1444
test(webhooks): add controller integration tests (#1444)
2 parents 8f6bc9f + 3289ba2 commit adf885b

2 files changed

Lines changed: 316 additions & 0 deletions

File tree

‎docs/api/webhooks-tests.md‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Webhooks API tests
2+
3+
## Integration tests
4+
5+
`test/e2e/webhooks-controller.integration.spec.ts` boots the real
6+
`WebhooksController` on Nest's HTTP stack and drives it with `supertest`, so the
7+
full request path is covered:
8+
9+
- HTTP routing and status codes for every endpoint,
10+
- the global `ValidationPipe` settings from `main.ts`
11+
(`whitelist`, `transform`, `forbidNonWhitelisted`),
12+
- `JwtAuthGuard` wiring, and
13+
- the `@CurrentUser()` decorator resolving to the authenticated user.
14+
15+
`WebhooksService` is replaced with a mock, so the tests never touch a database
16+
and can assert exactly which arguments the controller forwards (for example
17+
that the `id` path param and the current user id both reach the service).
18+
19+
### What is covered
20+
21+
| Area | Cases |
22+
| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
23+
| Auth | request with no valid bearer token is rejected (`403`), service untouched |
24+
| `POST /webhooks` | creates for the current user; rejects a bad URL, an unknown event type and non-whitelisted fields (`400`) |
25+
| `GET /webhooks` | lists only the current user's webhooks |
26+
| `GET /webhooks/:id` | forwards `id` + user |
27+
| `PATCH /webhooks/:id` | applies the validated body; rejects a non-boolean `isActive` |
28+
| `DELETE /webhooks/:id` | forwards `id` + user |
29+
| `GET /webhooks/:id/deliveries` | returns the delivery log |
30+
| `POST /webhooks/:id/deliveries/:deliveryId/replay` | replays a stored delivery |
31+
| `POST /webhooks/:id/rotate-secret` | rotates and returns the new secret |
32+
| `POST /webhooks/:id/verify` | answers the challenge; rejects a non-string or missing challenge |
33+
34+
### Running them
35+
36+
```bash
37+
# just the webhooks integration suite
38+
npx jest test/e2e/webhooks-controller.integration.spec.ts
39+
40+
# whole webhooks module (unit + integration)
41+
npx jest webhooks
42+
```
43+
44+
## Unit tests
45+
46+
`src/webhooks/webhooks.service.spec.ts` covers the service in isolation:
47+
signature generation and verification, delivery retries, replay idempotency, the
48+
URL validator and delivery-log pruning.
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
/**
2+
* Integration tests: WebhooksController over HTTP.
3+
*
4+
* Boots the real controller with Nest's HTTP stack, the same global
5+
* ValidationPipe settings as main.ts, and a mocked WebhooksService, so request
6+
* parsing, DTO validation, guard wiring and argument passing (including the
7+
* @CurrentUser decorator) are exercised in full without a database.
8+
*/
9+
10+
import {
11+
CanActivate,
12+
ExecutionContext,
13+
INestApplication,
14+
Injectable,
15+
ValidationPipe,
16+
} from '@nestjs/common';
17+
import { Test } from '@nestjs/testing';
18+
import * as request from 'supertest';
19+
import { WebhooksController } from '../../src/webhooks/webhooks.controller';
20+
import { WebhooksService } from '../../src/webhooks/webhooks.service';
21+
import { JwtAuthGuard } from '../../src/auth/guards/jwt-auth.guard';
22+
import { WebhookEventType } from '../../src/webhooks/webhook.dto';
23+
24+
/** Accepts "Authorization: Bearer test" and attaches a fixed user; rejects otherwise. */
25+
@Injectable()
26+
class BearerTestGuard implements CanActivate {
27+
canActivate(ctx: ExecutionContext): boolean {
28+
const req = ctx.switchToHttp().getRequest();
29+
if (req.headers['authorization'] !== 'Bearer test') return false;
30+
req.authUser = { id: 'user-1', sub: 'user-1', email: 'user@example.com', role: 'USER' };
31+
return true;
32+
}
33+
}
34+
35+
describe('WebhooksController (integration)', () => {
36+
let app: INestApplication;
37+
38+
const webhooksService = {
39+
create: jest.fn(),
40+
findAll: jest.fn(),
41+
findOne: jest.fn(),
42+
update: jest.fn(),
43+
remove: jest.fn(),
44+
getDeliveries: jest.fn(),
45+
replay: jest.fn(),
46+
rotateSecret: jest.fn(),
47+
verifyChallenge: jest.fn(),
48+
};
49+
50+
const authHeader = { Authorization: 'Bearer test' };
51+
52+
beforeAll(async () => {
53+
const moduleRef = await Test.createTestingModule({
54+
controllers: [WebhooksController],
55+
providers: [{ provide: WebhooksService, useValue: webhooksService }],
56+
})
57+
.overrideGuard(JwtAuthGuard)
58+
.useClass(BearerTestGuard)
59+
.compile();
60+
61+
app = moduleRef.createNestApplication();
62+
app.useGlobalPipes(
63+
new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true }),
64+
);
65+
await app.init();
66+
});
67+
68+
afterAll(async () => {
69+
await app.close();
70+
});
71+
72+
afterEach(() => {
73+
jest.clearAllMocks();
74+
});
75+
76+
describe('authentication', () => {
77+
it('rejects requests without a valid bearer token', async () => {
78+
await request(app.getHttpServer()).get('/webhooks').expect(403);
79+
expect(webhooksService.findAll).not.toHaveBeenCalled();
80+
});
81+
});
82+
83+
describe('POST /webhooks', () => {
84+
it('creates a webhook for the current user and returns the result', async () => {
85+
const created = { id: 'wh-1', url: 'https://example.com/hook', eventTypes: [] };
86+
webhooksService.create.mockResolvedValue(created);
87+
88+
const response = await request(app.getHttpServer())
89+
.post('/webhooks')
90+
.set(authHeader)
91+
.send({ url: 'https://example.com/hook', eventTypes: [WebhookEventType.PROPERTY_CREATED] })
92+
.expect(201);
93+
94+
expect(response.body).toEqual(created);
95+
expect(webhooksService.create).toHaveBeenCalledWith('user-1', {
96+
url: 'https://example.com/hook',
97+
eventTypes: [WebhookEventType.PROPERTY_CREATED],
98+
});
99+
});
100+
101+
it('rejects an invalid url before the service is reached', async () => {
102+
await request(app.getHttpServer())
103+
.post('/webhooks')
104+
.set(authHeader)
105+
.send({ url: 'not-a-url', eventTypes: [WebhookEventType.PROPERTY_CREATED] })
106+
.expect(400);
107+
108+
expect(webhooksService.create).not.toHaveBeenCalled();
109+
});
110+
111+
it('rejects an unknown event type', async () => {
112+
await request(app.getHttpServer())
113+
.post('/webhooks')
114+
.set(authHeader)
115+
.send({ url: 'https://example.com/hook', eventTypes: ['NOT_AN_EVENT'] })
116+
.expect(400);
117+
118+
expect(webhooksService.create).not.toHaveBeenCalled();
119+
});
120+
121+
it('strips non-whitelisted properties', async () => {
122+
webhooksService.create.mockResolvedValue({ id: 'wh-1' });
123+
124+
await request(app.getHttpServer())
125+
.post('/webhooks')
126+
.set(authHeader)
127+
.send({ url: 'https://example.com/hook', eventTypes: [], isActive: true })
128+
.expect(400);
129+
130+
expect(webhooksService.create).not.toHaveBeenCalled();
131+
});
132+
});
133+
134+
describe('GET /webhooks', () => {
135+
it('lists only the current user webhooks', async () => {
136+
const list = [{ id: 'wh-1' }];
137+
webhooksService.findAll.mockResolvedValue(list);
138+
139+
const response = await request(app.getHttpServer())
140+
.get('/webhooks')
141+
.set(authHeader)
142+
.expect(200);
143+
144+
expect(response.body).toEqual(list);
145+
expect(webhooksService.findAll).toHaveBeenCalledWith('user-1');
146+
});
147+
});
148+
149+
describe('GET /webhooks/:id', () => {
150+
it('passes the id and user through to the service', async () => {
151+
webhooksService.findOne.mockResolvedValue({ id: 'wh-1' });
152+
153+
await request(app.getHttpServer()).get('/webhooks/wh-1').set(authHeader).expect(200);
154+
155+
expect(webhooksService.findOne).toHaveBeenCalledWith('wh-1', 'user-1');
156+
});
157+
});
158+
159+
describe('PATCH /webhooks/:id', () => {
160+
it('updates a webhook with the validated body', async () => {
161+
webhooksService.update.mockResolvedValue({ id: 'wh-1', isActive: false });
162+
163+
await request(app.getHttpServer())
164+
.patch('/webhooks/wh-1')
165+
.set(authHeader)
166+
.send({ isActive: false })
167+
.expect(200);
168+
169+
expect(webhooksService.update).toHaveBeenCalledWith('wh-1', 'user-1', { isActive: false });
170+
});
171+
172+
it('rejects a non-boolean isActive', async () => {
173+
await request(app.getHttpServer())
174+
.patch('/webhooks/wh-1')
175+
.set(authHeader)
176+
.send({ isActive: 'yes' })
177+
.expect(400);
178+
179+
expect(webhooksService.update).not.toHaveBeenCalled();
180+
});
181+
});
182+
183+
describe('DELETE /webhooks/:id', () => {
184+
it('removes a webhook for the current user', async () => {
185+
webhooksService.remove.mockResolvedValue({ deleted: true });
186+
187+
await request(app.getHttpServer()).delete('/webhooks/wh-1').set(authHeader).expect(200);
188+
189+
expect(webhooksService.remove).toHaveBeenCalledWith('wh-1', 'user-1');
190+
});
191+
});
192+
193+
describe('GET /webhooks/:id/deliveries', () => {
194+
it('returns the delivery log for the webhook', async () => {
195+
const deliveries = [{ id: 'd-1', status: 'DELIVERED' }];
196+
webhooksService.getDeliveries.mockResolvedValue(deliveries);
197+
198+
const response = await request(app.getHttpServer())
199+
.get('/webhooks/wh-1/deliveries')
200+
.set(authHeader)
201+
.expect(200);
202+
203+
expect(response.body).toEqual(deliveries);
204+
expect(webhooksService.getDeliveries).toHaveBeenCalledWith('wh-1', 'user-1');
205+
});
206+
});
207+
208+
describe('POST /webhooks/:id/deliveries/:deliveryId/replay', () => {
209+
it('replays a stored delivery through the service', async () => {
210+
webhooksService.replay.mockResolvedValue({ id: 'd-1', status: 'RETRYING' });
211+
212+
await request(app.getHttpServer())
213+
.post('/webhooks/wh-1/deliveries/d-1/replay')
214+
.set(authHeader)
215+
.expect(201);
216+
217+
expect(webhooksService.replay).toHaveBeenCalledWith('wh-1', 'user-1', 'd-1');
218+
});
219+
});
220+
221+
describe('POST /webhooks/:id/rotate-secret', () => {
222+
it('rotates the secret and returns it', async () => {
223+
webhooksService.rotateSecret.mockResolvedValue({ secret: 'new-secret' });
224+
225+
const response = await request(app.getHttpServer())
226+
.post('/webhooks/wh-1/rotate-secret')
227+
.set(authHeader)
228+
.expect(201);
229+
230+
expect(response.body).toEqual({ secret: 'new-secret' });
231+
expect(webhooksService.rotateSecret).toHaveBeenCalledWith('wh-1', 'user-1');
232+
});
233+
});
234+
235+
describe('POST /webhooks/:id/verify', () => {
236+
it('answers the challenge with the current user', async () => {
237+
webhooksService.verifyChallenge.mockResolvedValue({ challenge: 'abc' });
238+
239+
await request(app.getHttpServer())
240+
.post('/webhooks/wh-1/verify')
241+
.set(authHeader)
242+
.send({ challenge: 'abc' })
243+
.expect(201);
244+
245+
expect(webhooksService.verifyChallenge).toHaveBeenCalledWith('wh-1', 'user-1', 'abc');
246+
});
247+
248+
it('rejects a non-string challenge', async () => {
249+
await request(app.getHttpServer())
250+
.post('/webhooks/wh-1/verify')
251+
.set(authHeader)
252+
.send({ challenge: 123 })
253+
.expect(400);
254+
255+
expect(webhooksService.verifyChallenge).not.toHaveBeenCalled();
256+
});
257+
258+
it('rejects a missing challenge', async () => {
259+
await request(app.getHttpServer())
260+
.post('/webhooks/wh-1/verify')
261+
.set(authHeader)
262+
.send({})
263+
.expect(400);
264+
265+
expect(webhooksService.verifyChallenge).not.toHaveBeenCalled();
266+
});
267+
});
268+
});

0 commit comments

Comments
 (0)