4ce4204b6c
CI / Lint (push) Failing after 2s
CI / Build (push) Has been skipped
SDK CI / PHP SDK (push) Failing after 1s
Split PHP SDK / PHP SDK tests (push) Failing after 1s
CI / Test (push) Failing after 1s
CI / Dashboard (push) Failing after 0s
SDK CI / JavaScript SDK (push) Failing after 0s
SDK CI / Python SDK (push) Failing after 2s
SDK CI / Java SDK (push) Failing after 1s
Split PHP SDK / Mirror sdk/php -> rmyndharis/openwa-php (push) Has been skipped
CI / Test (PostgreSQL migrations) (push) Failing after 7m47s
CI / Docker Build (push) Has been skipped
347 lines
14 KiB
TypeScript
347 lines
14 KiB
TypeScript
// archiver v8 is ESM-only (pulled in transitively via @Global StorageModule); stub for ts-jest CJS.
|
|
jest.mock('archiver', () => ({ TarArchive: jest.fn() }));
|
|
|
|
import http from 'http';
|
|
import crypto from 'crypto';
|
|
import { AddressInfo } from 'net';
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import request from 'supertest';
|
|
import { App } from 'supertest/types';
|
|
import { AppModule } from './../src/app.module';
|
|
import { AuthService } from './../src/modules/auth/auth.service';
|
|
import { ApiKeyRole } from './../src/modules/auth/entities/api-key.entity';
|
|
import { Session } from './../src/modules/session/entities/session.entity';
|
|
import { WebhookService } from './../src/modules/webhook/webhook.service';
|
|
|
|
/**
|
|
* End-to-end coverage for the webhooks module across the seam the unit specs can't reach: the REST
|
|
* surface (CRUD + auth + validation, with the global ValidationPipe), persistence, and real
|
|
* HMAC-signed HTTP delivery to a live local receiver. Dispatch is the one thing the WhatsApp socket
|
|
* would normally trigger, so we call WebhookService.dispatch() directly - that boundary isn't
|
|
* webhook logic.
|
|
*
|
|
* SSRF protection is ON by default and would reject the 127.0.0.1 receiver at both registration and
|
|
* delivery, so the suite runs with WEBHOOK_SSRF_PROTECT=false (one test flips it back on locally).
|
|
*/
|
|
describe('Webhooks (e2e)', () => {
|
|
let app: INestApplication<App>;
|
|
let webhookService: WebhookService;
|
|
let sessionRepo: Repository<Session>;
|
|
let apiKey: string;
|
|
let viewerKey: string;
|
|
let received: Array<{ headers: http.IncomingHttpHeaders; raw: string; body: Record<string, unknown> }>;
|
|
let receiver: http.Server;
|
|
let receiverUrl: string;
|
|
const prevSsrf = process.env.WEBHOOK_SSRF_PROTECT;
|
|
|
|
// Webhooks carry a CASCADE foreign key to a session row, and dispatch looks them up by sessionId.
|
|
// Persisting one real session per test both satisfies the FK and isolates each case (and prior
|
|
// runs) from the others, since dispatch and the session-scoped routes only see that session's id.
|
|
let sessionSeq = 0;
|
|
const nextSession = async (): Promise<string> => {
|
|
const session = await sessionRepo.save(sessionRepo.create({ name: `e2e-webhooks-${Date.now()}-${sessionSeq++}` }));
|
|
return session.id;
|
|
};
|
|
|
|
const waitFor = async (predicate: () => boolean, timeoutMs = 1000): Promise<void> => {
|
|
const start = Date.now();
|
|
while (!predicate()) {
|
|
if (Date.now() - start > timeoutMs) throw new Error('timed out waiting for condition');
|
|
await new Promise(r => setTimeout(r, 10));
|
|
}
|
|
};
|
|
|
|
// Create a webhook via the REST API (exercises CreateWebhookDto + filter validation) and return the
|
|
// response DTO. Defaults to the receiver URL + a subscribe-all event so dispatch tests can refine
|
|
// behaviour purely through filters/overrides.
|
|
const createWebhook = async (
|
|
session: string,
|
|
overrides: Record<string, unknown> = {},
|
|
): Promise<Record<string, unknown>> => {
|
|
const res = await request(app.getHttpServer())
|
|
.post(`/api/sessions/${session}/webhooks`)
|
|
.set('X-API-Key', apiKey)
|
|
.send({ url: receiverUrl, events: ['*'], ...overrides })
|
|
.expect(201);
|
|
return res.body as Record<string, unknown>;
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
process.env.WEBHOOK_SSRF_PROTECT = 'false';
|
|
|
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
imports: [AppModule],
|
|
}).compile();
|
|
|
|
app = moduleFixture.createNestApplication();
|
|
app.setGlobalPrefix('api');
|
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
|
await app.init();
|
|
|
|
webhookService = app.get(WebhookService);
|
|
sessionRepo = app.get(getRepositoryToken(Session, 'data'));
|
|
|
|
// Mint real keys so the suite doesn't depend on seed/DB state; ADMIN covers the OPERATOR routes.
|
|
const authService = app.get(AuthService);
|
|
apiKey = (await authService.createApiKey({ name: 'e2e-admin', role: ApiKeyRole.ADMIN })).rawKey;
|
|
viewerKey = (await authService.createApiKey({ name: 'e2e-viewer', role: ApiKeyRole.VIEWER })).rawKey;
|
|
|
|
received = [];
|
|
receiver = http.createServer((req, res) => {
|
|
let raw = '';
|
|
req.on('data', chunk => (raw += chunk));
|
|
req.on('end', () => {
|
|
received.push({ headers: req.headers, raw, body: JSON.parse(raw || '{}') as Record<string, unknown> });
|
|
res.writeHead(200).end();
|
|
});
|
|
});
|
|
await new Promise<void>(resolve => receiver.listen(0, '127.0.0.1', resolve));
|
|
receiverUrl = `http://127.0.0.1:${(receiver.address() as AddressInfo).port}/hook`;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await new Promise<void>(resolve => receiver.close(() => resolve()));
|
|
if (prevSsrf === undefined) delete process.env.WEBHOOK_SSRF_PROTECT;
|
|
else process.env.WEBHOOK_SSRF_PROTECT = prevSsrf;
|
|
try {
|
|
await app?.close();
|
|
} catch {
|
|
/* ignore teardown-only multi-datasource quirk */
|
|
}
|
|
});
|
|
|
|
beforeEach(() => {
|
|
received = [];
|
|
});
|
|
|
|
// ── CRUD over the REST surface ────────────────────────────────────
|
|
|
|
describe('CRUD over REST', () => {
|
|
it('creates a webhook and never leaks the secret or headers in the response', async () => {
|
|
const session = await nextSession();
|
|
const dto = await createWebhook(session, {
|
|
secret: 'top-secret',
|
|
headers: { 'X-Custom': 'v' },
|
|
filters: { conditions: [{ field: 'sender', operator: 'is', value: ['a@c.us'] }] },
|
|
});
|
|
|
|
expect(dto.id).toBeDefined();
|
|
expect(dto.sessionId).toBe(session);
|
|
expect(dto.active).toBe(true);
|
|
// Write-only fields must never appear in any API response.
|
|
expect(dto.secret).toBeUndefined();
|
|
expect(dto.headers).toBeUndefined();
|
|
});
|
|
|
|
it('lists webhooks for a session', async () => {
|
|
const session = await nextSession();
|
|
const created = await createWebhook(session);
|
|
|
|
const res = await request(app.getHttpServer())
|
|
.get(`/api/sessions/${session}/webhooks`)
|
|
.set('X-API-Key', apiKey)
|
|
.expect(200);
|
|
|
|
const list = res.body as Array<{ id: string }>;
|
|
expect(list).toHaveLength(1);
|
|
expect(list[0].id).toBe(created.id);
|
|
});
|
|
|
|
it('gets a webhook by id, and returns 404 for an unknown id', async () => {
|
|
const session = await nextSession();
|
|
const created = await createWebhook(session);
|
|
|
|
await request(app.getHttpServer())
|
|
.get(`/api/sessions/${session}/webhooks/${created.id as string}`)
|
|
.set('X-API-Key', apiKey)
|
|
.expect(200)
|
|
.expect(res => {
|
|
if ((res.body as { id: string }).id !== created.id) throw new Error('wrong webhook returned');
|
|
});
|
|
|
|
await request(app.getHttpServer())
|
|
.get(`/api/sessions/${session}/webhooks/00000000-0000-0000-0000-000000000000`)
|
|
.set('X-API-Key', apiKey)
|
|
.expect(404);
|
|
});
|
|
|
|
it('updates fields and persists them', async () => {
|
|
const session = await nextSession();
|
|
const created = await createWebhook(session);
|
|
|
|
const res = await request(app.getHttpServer())
|
|
.put(`/api/sessions/${session}/webhooks/${created.id as string}`)
|
|
.set('X-API-Key', apiKey)
|
|
.send({ events: ['message.received', 'session.status'], active: false })
|
|
.expect(200);
|
|
|
|
const updated = res.body as { events: string[]; active: boolean };
|
|
expect(updated.events).toEqual(['message.received', 'session.status']);
|
|
expect(updated.active).toBe(false);
|
|
});
|
|
|
|
it('lists the webhook across all sessions via GET /api/webhooks', async () => {
|
|
const session = await nextSession();
|
|
const created = await createWebhook(session);
|
|
|
|
const res = await request(app.getHttpServer()).get('/api/webhooks').set('X-API-Key', apiKey).expect(200);
|
|
|
|
const ids = (res.body as Array<{ id: string }>).map(w => w.id);
|
|
expect(ids).toContain(created.id);
|
|
});
|
|
|
|
it('deletes a webhook (204) and then it is gone (404)', async () => {
|
|
const session = await nextSession();
|
|
const created = await createWebhook(session);
|
|
|
|
await request(app.getHttpServer())
|
|
.delete(`/api/sessions/${session}/webhooks/${created.id as string}`)
|
|
.set('X-API-Key', apiKey)
|
|
.expect(204);
|
|
|
|
await request(app.getHttpServer())
|
|
.get(`/api/sessions/${session}/webhooks/${created.id as string}`)
|
|
.set('X-API-Key', apiKey)
|
|
.expect(404);
|
|
});
|
|
});
|
|
|
|
// ── auth boundaries ───────────────────────────────────────────────
|
|
|
|
describe('auth', () => {
|
|
it('rejects a request with no API key (401)', async () => {
|
|
const session = await nextSession();
|
|
await request(app.getHttpServer()).get(`/api/sessions/${session}/webhooks`).expect(401);
|
|
});
|
|
|
|
it('forbids a viewer-role key from creating a webhook (403)', async () => {
|
|
const session = await nextSession();
|
|
await request(app.getHttpServer())
|
|
.post(`/api/sessions/${session}/webhooks`)
|
|
.set('X-API-Key', viewerKey)
|
|
.send({ url: receiverUrl })
|
|
.expect(403);
|
|
});
|
|
});
|
|
|
|
// ── registration validation ───────────────────────────────────────
|
|
|
|
describe('registration validation', () => {
|
|
it('rejects an internal URL with 400 when SSRF protection is on', async () => {
|
|
const session = await nextSession();
|
|
// Self-contained: turn protection on and clear any ambient SSRF_ALLOWED_HOSTS (a dev .env may
|
|
// allowlist 127.0.0.1) so the assertion holds regardless of the local environment.
|
|
const prevAllow = process.env.SSRF_ALLOWED_HOSTS;
|
|
process.env.WEBHOOK_SSRF_PROTECT = 'true';
|
|
process.env.SSRF_ALLOWED_HOSTS = '';
|
|
try {
|
|
await request(app.getHttpServer())
|
|
.post(`/api/sessions/${session}/webhooks`)
|
|
.set('X-API-Key', apiKey)
|
|
.send({ url: 'http://169.254.169.254/hook' }) // link-local cloud metadata, a canonical SSRF target
|
|
.expect(400);
|
|
} finally {
|
|
process.env.WEBHOOK_SSRF_PROTECT = 'false';
|
|
if (prevAllow === undefined) delete process.env.SSRF_ALLOWED_HOSTS;
|
|
else process.env.SSRF_ALLOWED_HOSTS = prevAllow;
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── dispatch over real HTTP ───────────────────────────────────────
|
|
|
|
describe('dispatch over real HTTP', () => {
|
|
it('delivers a correctly HMAC-signed POST when a filter matches', async () => {
|
|
const session = await nextSession();
|
|
const secret = 'sig-secret';
|
|
await createWebhook(session, {
|
|
secret,
|
|
filters: { conditions: [{ field: 'sender', operator: 'is', value: ['boss@c.us'] }] },
|
|
});
|
|
|
|
await webhookService.dispatch(session, 'message.received', { from: 'boss@c.us', body: 'hi' });
|
|
await waitFor(() => received.length === 1);
|
|
|
|
const { headers, raw, body } = received[0];
|
|
expect(headers['x-openwa-event']).toBe('message.received');
|
|
// Verify the signature over the exact bytes that were sent, not a re-serialization.
|
|
const expected = `sha256=${crypto.createHmac('sha256', secret).update(raw).digest('hex')}`;
|
|
expect(headers['x-openwa-signature']).toBe(expected);
|
|
expect((body as { data: { from: string } }).data.from).toBe('boss@c.us');
|
|
});
|
|
|
|
it('does not deliver when the filter does not match', async () => {
|
|
const session = await nextSession();
|
|
await createWebhook(session, {
|
|
filters: { conditions: [{ field: 'sender', operator: 'is', value: ['boss@c.us'] }] },
|
|
});
|
|
|
|
await webhookService.dispatch(session, 'message.received', { from: 'spammer@c.us', body: 'spam' });
|
|
// No way to await a non-event; give dispatch a real chance to (not) deliver, then assert silence.
|
|
await new Promise(r => setTimeout(r, 100));
|
|
expect(received).toHaveLength(0);
|
|
});
|
|
|
|
it('passes a non-message event through a message-only filter', async () => {
|
|
const session = await nextSession();
|
|
await createWebhook(session, {
|
|
filters: { conditions: [{ field: 'sender', operator: 'is', value: ['nobody@c.us'] }] },
|
|
});
|
|
|
|
await webhookService.dispatch(session, 'session.status', { status: 'connected' });
|
|
await waitFor(() => received.length === 1);
|
|
expect(received[0].headers['x-openwa-event']).toBe('session.status');
|
|
});
|
|
|
|
it('does not deliver to an inactive webhook', async () => {
|
|
const session = await nextSession();
|
|
const created = await createWebhook(session);
|
|
await request(app.getHttpServer())
|
|
.put(`/api/sessions/${session}/webhooks/${created.id as string}`)
|
|
.set('X-API-Key', apiKey)
|
|
.send({ active: false })
|
|
.expect(200);
|
|
|
|
await webhookService.dispatch(session, 'message.received', { from: 'a@c.us' });
|
|
await new Promise(r => setTimeout(r, 100));
|
|
expect(received).toHaveLength(0);
|
|
});
|
|
|
|
it('drops forged reserved headers but keeps custom ones on the wire', async () => {
|
|
const session = await nextSession();
|
|
await createWebhook(session, {
|
|
headers: { 'X-OpenWA-Event': 'forged', 'Content-Type': 'text/plain', 'X-Custom': 'ok' },
|
|
});
|
|
|
|
await webhookService.dispatch(session, 'message.received', {});
|
|
await waitFor(() => received.length === 1);
|
|
|
|
const { headers } = received[0];
|
|
expect(headers['x-openwa-event']).toBe('message.received'); // system value wins, not 'forged'
|
|
expect(headers['content-type']).toBe('application/json');
|
|
expect(headers['x-custom']).toBe('ok'); // legitimate custom header preserved
|
|
});
|
|
});
|
|
|
|
// ── the test endpoint ─────────────────────────────────────────────
|
|
|
|
describe('test endpoint', () => {
|
|
it('POST /:id/test delivers a test event to the receiver and reports success', async () => {
|
|
const session = await nextSession();
|
|
const created = await createWebhook(session);
|
|
|
|
const res = await request(app.getHttpServer())
|
|
.post(`/api/sessions/${session}/webhooks/${created.id as string}/test`)
|
|
.set('X-API-Key', apiKey)
|
|
.expect(201);
|
|
|
|
expect((res.body as { success: boolean }).success).toBe(true);
|
|
await waitFor(() => received.length === 1);
|
|
expect(received[0].headers['x-openwa-event']).toBe('test');
|
|
});
|
|
});
|
|
});
|