Files
tripz-llc/backend/src/integrations/payments/paymob.adapter.spec.ts
T
Hamza-AyedandClaude Sonnet 5 d4ac38ca87 feat: P1 — بوابات دفع حقيقية (PayMob) + إغلاق ثغرة webhook حرجة
الأهم أولاً: `PaymentsService.webhook()` كان يقبل أي جسم `{ payment_id }`
بلا أي تحقّق توقيع — من يعرف معرّف دفعة معلَّقة كان يستطيع تحويلها «ناجحة»
ويشحن رصيداً من عدم (محفظة راكب أو رصيد سائق تشغيلي). الآن كل تغيير حالة
محروس بـ`adapter.verifyWebhook(headers, payload, tenant)`، ولا شيء يُقرأ من
الحمولة قبل ذلك كقرار ثقة — قراءة المرجع لتحديد المستأجر ليست قراراً.

البنية (docs/07 · docs/24 — P1):
- `PaymentAdapter`: charge() يعيد instant (كاش) · redirect (بوابة API حقيقية)
  · invoice (بلا API، تسوية عبر P2).
- PayMob (مصر): تسلسل auth→order→payment_key→iframe حقيقي عبر fetch،
  وتحقّق HMAC-SHA512 على تسلسل حقول ثابت (بروتوكول PayMob الرسمي بالضبط)
  بمقارنة ثابتة الزمن. مفاتيح كل مستأجر مستقلة — حساب تاجر خاص به.
- كليق/شام كاش/MTN/سيرياتيل/زين كاش: محوّل مشترك واحد لأن سلوكها متطابق
  فعلياً في سيرو (`create_*_invoice.php` تُنشئ فاتورة فقط، لا نداء بوابة
  حيّاً) — مرجع + حساب استلام معروض، والتسوية عبر رسالة SMS لا webhook.
  MTN/سيرياتيل الحقيقيَّين (توكن+OTP) موثَّقان كبند مفتوح: لا نبني تكاملاً
  لا نملك اعتماداً حيّاً للتحقّق منه.
- `PATCH /payments/settings` لأدمن المستأجر: مفاتيح PayMob · حسابات
  الاستلام · سرّ webhook الرسائل — الاستجابة لا تُعيد الأسرار.

تنظيف: إزالة الإشارات المتبقّية لحاوية `martin` من docs/07 (أُزيلت فعلياً
سابقاً)، وتحديث هيكل الكود الموثَّق ليطابق ما هو مبنيّ فعلاً.

25 اختباراً جديداً (226 إجمالاً) — منها توقيع PayMob محسوب فعلياً ومُتحقَّق،
وتلاعبٌ بالحمولة بعد التوقيع يُرفض، وسبع حالات تثبت إغلاق ثغرة الـwebhook.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 15:50:05 +03:00

163 lines
6.6 KiB
TypeScript

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<Record<string, any>> = {}) {
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);
});
});