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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
da035e46a4
commit
d4ac38ca87
@@ -0,0 +1,162 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user