الأهم أولاً: `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>
163 lines
6.6 KiB
TypeScript
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);
|
|
});
|
|
});
|