import { BadRequestException } from '@nestjs/common'; import { createHmac } from 'crypto'; import { PaymobAdapter } from './paymob.adapter'; const CREDS = { api_key: 'test-api-key', integration_id: '12345', iframe_id: '837992', hmac_secret: 'super-secret-hmac-key', }; function tenant(overrides: any = {}) { return { settings: { payments: { paymob: CREDS, ...overrides } } } as any; } function configStub() { return { get: () => undefined } as any; // بلا احتياط env — الاعتماد كله على إعداد المستأجر } /** يبني حمولة webhook صالحة موقَّعة فعلياً — تُستعمل لإثبات القبول والتلاعب معاً. */ function signedPayload(overrides: Partial> = {}) { const obj = { amount_cents: 2500, created_at: '2026-07-18T10:00:00Z', currency: 'EGP', error_occured: false, has_parent_transaction: false, id: 999, integration_id: CREDS.integration_id, is_3d_secure: false, is_auth: false, is_capture: false, is_refunded: false, is_standalone_payment: true, is_voided: false, order: { id: 111, merchant_order_id: 'payment-uuid-1' }, owner: 55, pending: false, source_data: { pan: '1234', sub_type: 'MASTERCARD', type: 'card' }, success: true, ...overrides, }; const norm = (v: any) => (v === true ? 'true' : v === false ? 'false' : v == null ? '' : String(v)); const fields = [ norm(obj.amount_cents), norm(obj.created_at), norm(obj.currency), norm(obj.error_occured), norm(obj.has_parent_transaction), norm(obj.id), norm(obj.integration_id), norm(obj.is_3d_secure), norm(obj.is_auth), norm(obj.is_capture), norm(obj.is_refunded), norm(obj.is_standalone_payment), norm(obj.is_voided), norm(obj.order?.id), norm(obj.owner), norm(obj.pending), norm(obj.source_data?.pan), norm(obj.source_data?.sub_type), norm(obj.source_data?.type), norm(obj.success), ]; const hmac = createHmac('sha512', CREDS.hmac_secret).update(fields.join('')).digest('hex'); return { payload: { obj }, hmac }; } describe('PaymobAdapter.verifyWebhook — توقيع حقيقي (docs/24 — P1)', () => { it('يقبل توقيعاً صحيحاً محسوباً بنفس خوارزمية PayMob', () => { const adapter = new PaymobAdapter(configStub()); const { payload, hmac } = signedPayload(); expect(adapter.verifyWebhook({ hmac }, payload, tenant())).toBe(true); }); it('يرفض حمولة مُتلاعَباً بها بعد التوقيع — أهمّ اختبار هنا', () => { const adapter = new PaymobAdapter(configStub()); const { payload, hmac } = signedPayload(); // مهاجمٌ اعترض webhook حقيقياً وغيّر المبلغ بعد أن أُخذ التوقيع لمبلغ آخر. payload.obj.amount_cents = 999999; expect(adapter.verifyWebhook({ hmac }, payload, tenant())).toBe(false); }); it('يرفض توقيعاً بسرّ خاطئ', () => { const adapter = new PaymobAdapter(configStub()); const { payload } = signedPayload(); const wrongHmac = createHmac('sha512', 'wrong-secret').update('x').digest('hex'); expect(adapter.verifyWebhook({ hmac: wrongHmac }, payload, tenant())).toBe(false); }); it('يرفض بلا توقيع إطلاقاً', () => { const adapter = new PaymobAdapter(configStub()); const { payload } = signedPayload(); expect(adapter.verifyWebhook({}, payload, tenant())).toBe(false); }); it('يقرأ hmac من الـquery كما من الترويسة (توثيق PayMob الرسمي)', () => { const adapter = new PaymobAdapter(configStub()); const { payload, hmac } = signedPayload(); expect(adapter.verifyWebhook({}, { ...payload, hmac }, tenant())).toBe(true); }); it('مستأجر بلا إعداد PayMob = رفض دائم، لا سقوط لسرّ افتراضي', () => { const adapter = new PaymobAdapter(configStub()); const { payload, hmac } = signedPayload(); expect(adapter.verifyWebhook({ hmac }, payload, { settings: {} } as any)).toBe(false); }); it('parseWebhook يستخرج merchant_order_id كمرجع لا معرّف PayMob الداخلي', () => { const adapter = new PaymobAdapter(configStub()); const { payload } = signedPayload(); const parsed = adapter.parseWebhook(payload); expect(parsed).toEqual({ providerRef: 'payment-uuid-1', success: true, amount: 25 }); }); it('is_voided يقلب النجاح رغم success:true — استرجاع لا يُعامَل كدفع ناجح', () => { const adapter = new PaymobAdapter(configStub()); const { payload } = signedPayload({ is_voided: true }); expect(adapter.parseWebhook(payload)?.success).toBe(false); }); }); describe('PaymobAdapter.charge — تسلسل auth → order → payment_key', () => { const originalFetch = global.fetch; afterEach(() => { global.fetch = originalFetch; }); it('يبني رابط iframe من التسلسل الثلاثي', async () => { const calls: string[] = []; global.fetch = jest.fn(async (url: any) => { calls.push(String(url)); if (String(url).includes('/auth/tokens')) { return { ok: true, json: async () => ({ token: 'AUTH123' }) } as any; } if (String(url).includes('/ecommerce/orders')) { return { ok: true, json: async () => ({ id: 4242 }) } as any; } if (String(url).includes('/payment_keys')) { return { ok: true, json: async () => ({ token: 'PAYKEY456' }) } as any; } throw new Error('unexpected url ' + url); }) as any; const adapter = new PaymobAdapter(configStub()); const result = await adapter.charge( { paymentId: 'pay-1', amount: 25, currency: 'EGP' }, tenant(), ); expect(result).toEqual({ mode: 'redirect', redirectUrl: expect.stringContaining('PAYKEY456'), providerRef: '4242', }); expect(calls).toHaveLength(3); }); it('مستأجر بلا مفاتيح PayMob يُرفض قبل أي نداء شبكة', async () => { global.fetch = jest.fn(); const adapter = new PaymobAdapter(configStub()); await expect( adapter.charge({ paymentId: 'p', amount: 10, currency: 'EGP' }, { settings: {} } as any), ).rejects.toThrow(BadRequestException); expect(global.fetch).not.toHaveBeenCalled(); }); it('فشل استدعاء PayMob يفشل بوضوح لا بصمت', async () => { global.fetch = jest.fn(async () => ({ ok: false, status: 401, text: async () => 'unauthorized' }) as any); const adapter = new PaymobAdapter(configStub()); await expect( adapter.charge({ paymentId: 'p', amount: 10, currency: 'EGP' }, tenant()), ).rejects.toThrow(BadRequestException); }); });