/** * 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'); }); });