feat: D5 — OTP متعدد المزوّدين حسب الدولة (من سيرو) + قرار بلا كلمة مرور

مراجعة سيرو (auth/otp/): مصر تستعمل Kazumi SMS مع failover لواتساب،
وسوريا/الأردن Nabeh. عندنا كان مزوّد واحد فقط.

- integrations/otp/: واجهة OtpProvider + OtpDispatcher يوجّه حسب
  tenant.countryPack بسلسلة failover (eg: [kazumi, nabeh] · jo/sy: [nabeh])
- KazumiSmsProvider (مصر SMS) + NabehOtpProvider (غلاف على NabehService)
- كل مزوّد يرسل رمزاً نولّده نحن (Redis) — استبعدنا نمط Intaleq (يولّد الرمز
  بنفسه) حفاظاً على مصدر واحد للرمز
- موحَّد: AuthService.sendOtp و PayoutsService.issueOtp يمرّان بنفس المُوزِّع
  الآن — أُزيل كل استدعاء Nabeh مباشر. أي مسار يحتاج رسالة يستدعي الخدمة
  الموحّدة (طلب المالك)
- فشل السلسلة كاملة = ServiceUnavailable صريح، لا صمت

قرار المالك (2026-07-17): بلا كلمة مرور إطلاقاً. المصادقة = هاتف + OTP مرة
→ جلسة مربوطة بالجهاز (D2). التسجيل بالهاتف لا Google/Apple (محظوران في بعض
الدول). سيرو نفسه لا كلمة مرور حقيقية له (password = hash(email) وهمي،
ومقارنة نصّية غير آمنة في مسار التجربة). يطابق أوبر/كريم/inDrive.

مراجعة حدّ الطلبات في سيرو (RateLimiter.php): حدود مسمّاة لكل نوع + fallback
بملف عند تعطّل Redis (fail-closed). عندنا مسار OTP fail-closed أصلاً؛
تخزين Throttler على Redis مؤجَّل للتوسّع الأفقي الفعلي (موثّق في D4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-17 19:24:01 +03:00
co-authored by Claude Opus 4.8
parent c258a8c4b8
commit 8f6c46f0ac
13 changed files with 343 additions and 15 deletions
@@ -0,0 +1,73 @@
import { OtpDispatcher } from './otp-dispatcher.service';
import { OtpProvider } from './otp-provider.interface';
function provider(name: string, result: boolean | (() => boolean)): OtpProvider {
return {
name,
supports: () => true,
send: jest.fn(async () => (typeof result === 'function' ? result() : result)),
};
}
describe('OtpDispatcher — توجيه حسب الدولة + failover (docs/17 D5)', () => {
it('مصر تجرّب Kazumi أولاً', async () => {
const kazumi = provider('kazumi', true);
const nabeh = provider('nabeh', true);
const d = new OtpDispatcher([kazumi, nabeh]);
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(true);
expect(kazumi.send).toHaveBeenCalledTimes(1);
expect(nabeh.send).not.toHaveBeenCalled(); // نجح الأول فلا failover
});
it('مصر: فشل Kazumi → يتحوّل إلى Nabeh', async () => {
const kazumi = provider('kazumi', false);
const nabeh = provider('nabeh', true);
const d = new OtpDispatcher([kazumi, nabeh]);
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(true);
expect(kazumi.send).toHaveBeenCalledTimes(1);
expect(nabeh.send).toHaveBeenCalledTimes(1); // جرّب البديل
});
it('الأردن يستعمل Nabeh فقط — لا يلمس Kazumi', async () => {
const kazumi = provider('kazumi', true);
const nabeh = provider('nabeh', true);
const d = new OtpDispatcher([kazumi, nabeh]);
expect(await d.send('962790000000', '1234', { countryPack: 'jo' })).toBe(true);
expect(nabeh.send).toHaveBeenCalledTimes(1);
expect(kazumi.send).not.toHaveBeenCalled();
});
it('سوريا تستعمل Nabeh', async () => {
const nabeh = provider('nabeh', true);
const d = new OtpDispatcher([nabeh]);
expect(await d.send('963944000000', '1234', { countryPack: 'sy' })).toBe(true);
});
it('فشل السلسلة كاملة يرجع false — المُستدعي يُفشل الطلب', async () => {
const kazumi = provider('kazumi', false);
const nabeh = provider('nabeh', false);
const d = new OtpDispatcher([kazumi, nabeh]);
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(false);
expect(kazumi.send).toHaveBeenCalledTimes(1);
expect(nabeh.send).toHaveBeenCalledTimes(1);
});
it('دولة غير معروفة تسقط لـNabeh (السلسلة الافتراضية)', async () => {
const nabeh = provider('nabeh', true);
const d = new OtpDispatcher([nabeh]);
expect(await d.send('491234567', '1234', { countryPack: 'de' })).toBe(true);
expect(nabeh.send).toHaveBeenCalledTimes(1);
});
it('مزوّد مفقود من التسجيل لا يُسقط السلسلة — يتخطّاه', async () => {
// Kazumi غير مسجَّل، فمصر تسقط لـNabeh وحده.
const nabeh = provider('nabeh', true);
const d = new OtpDispatcher([nabeh]);
expect(await d.send('201012345678', '1234', { countryPack: 'eg' })).toBe(true);
expect(nabeh.send).toHaveBeenCalledTimes(1);
});
});