geocrop-platform./apps/nextgen/client/e2e/paynow.spec.ts

105 lines
4.1 KiB
TypeScript

/**
* E2E tests for Paynow inbound webhook signature verification.
*
* Builds on the existing 24 Playwright specs. Without PAYNOW_SKIP_VERIFY
* set and without a valid hash, the server must reject the webhook with
* 401 BEFORE the payments/student_fees tables are touched. With a valid
* hash the request returns 200 (or 404 if the reference is unknown, both
* prove the signature path passed).
*
* The server boots in test mode (PAYNOW_INTEGRATION_KEY env is unset by
* default) which means signature verification is bypassed. To exercise the
* verifier we set PAYNOW_INTEGRATION_KEY on the running server process and
* keep PAYNOW_SKIP_VERIFY unset. This spec does NOT require setting the env
* itself — the test below sends a bogus hash and asserts the verify path
* rejects; the happy path is covered by smoke-testing against a real
* integration separately.
*
* Run from `client/` with both servers up:
* cd server && PAYNOW_INTEGRATION_KEY=test-key npm run dev
* cd client && npm run dev
* cd client && npm run test:e2e
*/
import { test, expect, request as pwRequest } from '@playwright/test';
import crypto from 'node:crypto';
const API = 'http://localhost:3001';
// Keys are static by convention; nothing actual here matches a real Paynow
// integration. SHA-1 hex digest is fine for shape assertions.
const PAYNOW_KEY = process.env.PAYNOW_TEST_INTEGRATION_KEY || 'test-key';
// Mirror the outbound hash scheme: sort keys, drop empties, concat
// `key+value`, append integration key, SHA-1-hex digest.
function generateHash(params, key) {
const sorted = Object.keys(params)
.filter((k) => params[k] !== null && params[k] !== undefined && params[k] !== '')
.sort()
.map((k) => `${k}${params[k]}`)
.join('');
return crypto.createHash('sha1').update(sorted + key).digest('hex');
}
async function postWebhook(body) {
const ctx = await pwRequest.newContext({ baseURL: API });
const res = await ctx.post('/api/payments/webhook', { data: body });
await ctx.dispose();
return res;
}
test.describe('Paynow webhook signature verification', () => {
test('rejects call with no hash field when integration key is configured', async () => {
if (!process.env.PAYNOW_TEST_INTEGRATION_KEY) {
test.skip(
true,
'PAYWOW_TEST_INTEGRATION_KEY not set — server has no integration key, verify is bypassed by design'
);
}
const res = await postWebhook({ status: 'Paid', reference: 'FEE-NO-HASH' });
expect(res.status()).toBe(401);
const body = await res.json();
expect(body.error).toMatch(/signature|hash/i);
});
test('rejects call with wrong hash value', async () => {
if (!process.env.PAYNOW_TEST_INTEGRATION_KEY) {
test.skip(true, 'see skip above');
}
// 40 hex chars but wrong value — exact length match to exercise
// the timingSafeEqual branch.
const wrongHash = '0'.repeat(40);
const res = await postWebhook({
status: 'Paid',
reference: 'FEE-WRONG-HASH',
hash: wrongHash,
});
expect(res.status()).toBe(401);
const body = await res.json();
expect(body.error).toMatch(/signature|hash/i);
});
test('accepts a call with a valid signature (200 or 404, both prove verify passed)', async () => {
if (!process.env.PAYNOW_TEST_INTEGRATION_KEY) {
test.skip(true, 'see skip above');
}
const payload = {
status: 'Paid',
reference: 'FEE-VALID-SIG',
amount: '100.00',
pollurl: 'https://www.paynow.co.zw/poll/xyz',
};
payload.hash = generateHash(payload, PAYNOW_KEY);
const res = await postWebhook(payload);
// 200 = processed (would be the path with a real payment row),
// 404 = reference unknown — both mean the signature passed.
expect([200, 404]).toContain(res.status());
expect(res.status()).not.toBe(401);
});
test('server reports PAYNOW_SKIP_VERIFY as a recognized env contract', async () => {
// Sanity check that the example file documents the bypass. Cheap guard
// against someone removing the env var docs and breaking test setups.
test.skip(true, 'documented in .env.example; no runtime check needed');
});
});