Skip to content

Commit 61d7e94

Browse files
authored
Merge pull request #1173 from sheyman546/fix/issues-1084-webhook-integration-tests
test: add webhook route integration tests for Stripe, Paystack, PayPa…
2 parents 22aa51f + babfd3b commit 61d7e94

4 files changed

Lines changed: 610 additions & 0 deletions

File tree

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/**
2+
* Integration tests for PayPal webhook route (POST /api/webhooks/paypal)
3+
* Covers: success, idempotent-replay, rejected-forgery (#1084)
4+
*/
5+
import request from 'supertest';
6+
import express, { type Express } from 'express';
7+
8+
jest.mock('../src/config/logger', () => ({
9+
__esModule: true,
10+
default: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
11+
}));
12+
13+
jest.mock('@sentry/node', () => ({
14+
captureMessage: jest.fn(),
15+
}));
16+
17+
import paypalWebhookRoutes from '../src/routes/paypal-webhook';
18+
19+
function buildApp(): Express {
20+
const app = express();
21+
app.use('/api/webhooks/paypal', express.raw({ type: 'application/json' }), paypalWebhookRoutes);
22+
return app;
23+
}
24+
25+
const app = buildApp();
26+
27+
describe('PayPal webhook route integration', () => {
28+
const originalEnv: Record<string, string | undefined> = {};
29+
const PAYPAL_WEBHOOK_ID = 'WH-TEST-12345';
30+
const PAYPAL_CLIENT_ID = 'test_client_id';
31+
const PAYPAL_CLIENT_SECRET = 'test_client_secret';
32+
33+
const originalFetch = global.fetch;
34+
35+
beforeEach(() => {
36+
originalEnv.PAYPAL_WEBHOOK_ID = process.env.PAYPAL_WEBHOOK_ID;
37+
originalEnv.PAYPAL_CLIENT_ID = process.env.PAYPAL_CLIENT_ID;
38+
originalEnv.PAYPAL_CLIENT_SECRET = process.env.PAYPAL_CLIENT_SECRET;
39+
originalEnv.PAYPAL_MODE = process.env.PAYPAL_MODE;
40+
41+
process.env.PAYPAL_WEBHOOK_ID = PAYPAL_WEBHOOK_ID;
42+
process.env.PAYPAL_CLIENT_ID = PAYPAL_CLIENT_ID;
43+
process.env.PAYPAL_CLIENT_SECRET = PAYPAL_CLIENT_SECRET;
44+
process.env.PAYPAL_MODE = 'sandbox';
45+
});
46+
47+
afterEach(() => {
48+
Object.entries(originalEnv).forEach(([key, value]) => {
49+
if (value === undefined) delete process.env[key];
50+
else process.env[key] = value;
51+
});
52+
global.fetch = originalFetch;
53+
});
54+
55+
const validPayload = JSON.stringify({
56+
id: 'WH-58D07950BU892453L',
57+
event_type: 'PAYMENT.CAPTURE.COMPLETED',
58+
resource: { id: 'CAP-123', amount: { total: '29.99', currency: 'USD' } },
59+
});
60+
61+
const validHeaders = {
62+
'paypal-transmission-id': 'tx-abc-123',
63+
'paypal-transmission-time': '2026-07-01T12:00:00Z',
64+
'paypal-cert-url': 'https://api.sandbox.paypal.com/v1/notifications/certs/CERT-ID',
65+
'paypal-auth-algo': 'SHA256withRSA',
66+
'paypal-transmission-sig': 'mock-signature-value',
67+
};
68+
69+
function mockPayPalSuccess() {
70+
global.fetch = jest
71+
.fn()
72+
.mockResolvedValueOnce({
73+
ok: true,
74+
json: async () => ({ access_token: 'test_access_token' }),
75+
})
76+
.mockResolvedValueOnce({
77+
ok: true,
78+
json: async () => ({ verification_status: 'SUCCESS' }),
79+
}) as jest.Mock;
80+
}
81+
82+
function mockPayPalFailure() {
83+
global.fetch = jest
84+
.fn()
85+
.mockResolvedValueOnce({
86+
ok: true,
87+
json: async () => ({ access_token: 'test_access_token' }),
88+
})
89+
.mockResolvedValueOnce({
90+
ok: true,
91+
json: async () => ({ verification_status: 'FAILURE' }),
92+
}) as jest.Mock;
93+
}
94+
95+
it('accepts a valid PayPal webhook (success)', async () => {
96+
mockPayPalSuccess();
97+
98+
const res = await request(app)
99+
.post('/api/webhooks/paypal')
100+
.set(validHeaders)
101+
.set('content-type', 'application/json')
102+
.send(validPayload);
103+
104+
expect(res.status).toBe(200);
105+
expect(res.body).toEqual({ received: true });
106+
});
107+
108+
it('accepts the same webhook twice (idempotent-replay)', async () => {
109+
// Use a single mock that returns success for all fetch calls
110+
global.fetch = jest
111+
.fn()
112+
.mockResolvedValueOnce({
113+
ok: true,
114+
json: async () => ({ access_token: 'test_access_token' }),
115+
})
116+
.mockResolvedValueOnce({
117+
ok: true,
118+
json: async () => ({ verification_status: 'SUCCESS' }),
119+
})
120+
.mockResolvedValueOnce({
121+
ok: true,
122+
json: async () => ({ access_token: 'test_access_token' }),
123+
})
124+
.mockResolvedValueOnce({
125+
ok: true,
126+
json: async () => ({ verification_status: 'SUCCESS' }),
127+
}) as jest.Mock;
128+
129+
const res1 = await request(app)
130+
.post('/api/webhooks/paypal')
131+
.set(validHeaders)
132+
.set('content-type', 'application/json')
133+
.send(validPayload);
134+
135+
const res2 = await request(app)
136+
.post('/api/webhooks/paypal')
137+
.set(validHeaders)
138+
.set('content-type', 'application/json')
139+
.send(validPayload);
140+
141+
expect(res1.status).toBe(200);
142+
expect(res2.status).toBe(200);
143+
});
144+
145+
it('rejects a webhook with PayPal verification FAILURE (rejected-forgery)', async () => {
146+
mockPayPalFailure();
147+
148+
const res = await request(app)
149+
.post('/api/webhooks/paypal')
150+
.set(validHeaders)
151+
.set('content-type', 'application/json')
152+
.send(validPayload);
153+
154+
expect(res.status).toBe(401);
155+
expect(res.body.error).toBe('Invalid signature');
156+
});
157+
158+
it('rejects a webhook when transmission headers are missing (rejected-forgery)', async () => {
159+
const res = await request(app)
160+
.post('/api/webhooks/paypal')
161+
.set('content-type', 'application/json')
162+
.send(validPayload);
163+
164+
expect(res.status).toBe(401);
165+
expect(res.body.error).toBe('Invalid signature');
166+
});
167+
168+
it('rejects a webhook when some transmission headers are missing (rejected-forgery)', async () => {
169+
const res = await request(app)
170+
.post('/api/webhooks/paypal')
171+
.set('paypal-transmission-id', 'tx-partial')
172+
.set('content-type', 'application/json')
173+
.send(validPayload);
174+
175+
expect(res.status).toBe(401);
176+
});
177+
178+
describe('malformed-payload handling (#1084)', () => {
179+
it('rejects a non-JSON malformed payload', async () => {
180+
const res = await request(app)
181+
.post('/api/webhooks/paypal')
182+
.set(validHeaders)
183+
.set('content-type', 'application/json')
184+
.send('not valid json');
185+
186+
expect(res.status).toBe(401);
187+
});
188+
189+
it('rejects an empty body', async () => {
190+
const res = await request(app)
191+
.post('/api/webhooks/paypal')
192+
.set(validHeaders)
193+
.set('content-type', 'application/json')
194+
.send('');
195+
196+
expect(res.status).toBe(401);
197+
});
198+
});
199+
});
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/**
2+
* Integration tests for Paystack webhook route (POST /api/webhooks/paystack)
3+
* Covers: success, idempotent-replay, rejected-forgery (#1084)
4+
*
5+
* Note: The Paystack route sends res.sendStatus(200) before signature
6+
* verification, so even forged signatures receive HTTP 200. The forgery
7+
* test verifies that the warning logger is called with the rejection reason.
8+
*/
9+
import request from 'supertest';
10+
import express, { type Express } from 'express';
11+
import crypto from 'crypto';
12+
13+
const mockLogger = { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() };
14+
jest.mock('../src/config/logger', () => ({
15+
__esModule: true,
16+
default: mockLogger,
17+
}));
18+
19+
jest.mock('@sentry/node', () => ({
20+
captureMessage: jest.fn(),
21+
}));
22+
23+
import paystackWebhookRoutes from '../src/routes/paystack-webhook';
24+
25+
const PAYSTACK_SECRET_KEY = 'sk_test_secret_key_for_integration_testing';
26+
27+
function buildApp(): Express {
28+
const app = express();
29+
app.use('/api/webhooks/paystack', express.raw({ type: 'application/json' }), paystackWebhookRoutes);
30+
return app;
31+
}
32+
33+
const app = buildApp();
34+
35+
describe('Paystack webhook route integration', () => {
36+
const originalPaystackSecretKey = process.env.PAYSTACK_SECRET_KEY;
37+
const originalNodeEnv = process.env.NODE_ENV;
38+
39+
beforeEach(() => {
40+
process.env.PAYSTACK_SECRET_KEY = PAYSTACK_SECRET_KEY;
41+
process.env.NODE_ENV = 'production';
42+
mockLogger.warn.mockClear();
43+
});
44+
45+
afterEach(() => {
46+
if (originalPaystackSecretKey === undefined) {
47+
delete process.env.PAYSTACK_SECRET_KEY;
48+
} else {
49+
process.env.PAYSTACK_SECRET_KEY = originalPaystackSecretKey;
50+
}
51+
if (originalNodeEnv === undefined) {
52+
delete process.env.NODE_ENV;
53+
} else {
54+
process.env.NODE_ENV = originalNodeEnv;
55+
}
56+
});
57+
58+
const validPayload = JSON.stringify({
59+
event: 'charge.success',
60+
data: { reference: 'ref_test_123', amount: 5000, status: 'success' },
61+
});
62+
63+
function generateValidSignature(payload: string): string {
64+
return crypto.createHmac('sha512', PAYSTACK_SECRET_KEY).update(payload).digest('hex');
65+
}
66+
67+
it('accepts a valid Paystack webhook (success)', async () => {
68+
const signature = generateValidSignature(validPayload);
69+
70+
const res = await request(app)
71+
.post('/api/webhooks/paystack')
72+
.set('x-paystack-signature', signature)
73+
.set('content-type', 'application/json')
74+
.send(validPayload);
75+
76+
// Paystack route always responds 200 immediately
77+
expect(res.status).toBe(200);
78+
// No warning should be logged on valid signature
79+
expect(mockLogger.warn).not.toHaveBeenCalledWith(
80+
'[PaystackWebhook] Rejected — invalid signature',
81+
expect.anything(),
82+
);
83+
});
84+
85+
it('accepts the same webhook twice (idempotent-replay)', async () => {
86+
const signature = generateValidSignature(validPayload);
87+
88+
const res1 = await request(app)
89+
.post('/api/webhooks/paystack')
90+
.set('x-paystack-signature', signature)
91+
.set('content-type', 'application/json')
92+
.send(validPayload);
93+
94+
const res2 = await request(app)
95+
.post('/api/webhooks/paystack')
96+
.set('x-paystack-signature', signature)
97+
.set('content-type', 'application/json')
98+
.send(validPayload);
99+
100+
expect(res1.status).toBe(200);
101+
expect(res2.status).toBe(200);
102+
});
103+
104+
it('logs a warning on forged signature (rejected-forgery)', async () => {
105+
const forgedPayload = JSON.stringify({
106+
event: 'charge.success',
107+
data: { reference: 'ref_forged', amount: 99999 },
108+
});
109+
110+
const res = await request(app)
111+
.post('/api/webhooks/paystack')
112+
.set('x-paystack-signature', 'deadbeef' + '0'.repeat(120))
113+
.set('content-type', 'application/json')
114+
.send(forgedPayload);
115+
116+
// Paystack always returns 200 (response sent before verification)
117+
expect(res.status).toBe(200);
118+
// But it should log the forgery warning
119+
expect(mockLogger.warn).toHaveBeenCalledWith(
120+
'[PaystackWebhook] Rejected — invalid signature',
121+
expect.objectContaining({ error: expect.any(String) }),
122+
);
123+
});
124+
125+
it('rejects (logs warning) when Paystack signature header is missing', async () => {
126+
const res = await request(app)
127+
.post('/api/webhooks/paystack')
128+
.set('content-type', 'application/json')
129+
.send(validPayload);
130+
131+
expect(res.status).toBe(200);
132+
expect(mockLogger.warn).toHaveBeenCalledWith(
133+
'[PaystackWebhook] Rejected — invalid signature',
134+
expect.anything(),
135+
);
136+
});
137+
138+
describe('malformed-payload handling (#1084)', () => {
139+
it('returns 200 for a non-JSON malformed payload (body always accepted)', async () => {
140+
const res = await request(app)
141+
.post('/api/webhooks/paystack')
142+
.set('x-paystack-signature', 'deadbeef' + '0'.repeat(120))
143+
.set('content-type', 'application/json')
144+
.send('not valid json');
145+
146+
// Paystack always responds 200 first, regardless of payload validity
147+
expect(res.status).toBe(200);
148+
// Should log the forgery warning
149+
expect(mockLogger.warn).toHaveBeenCalledWith(
150+
'[PaystackWebhook] Rejected — invalid signature',
151+
expect.anything(),
152+
);
153+
});
154+
155+
it('returns 200 for an empty body', async () => {
156+
const res = await request(app)
157+
.post('/api/webhooks/paystack')
158+
.set('content-type', 'application/json')
159+
.send('');
160+
161+
expect(res.status).toBe(200);
162+
// Missing signature should trigger warning in production
163+
expect(mockLogger.warn).toHaveBeenCalledWith(
164+
'[PaystackWebhook] Rejected — invalid signature',
165+
expect.anything(),
166+
);
167+
});
168+
});
169+
});

0 commit comments

Comments
 (0)