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:
co-authored by
Claude Opus 4.8
parent
c258a8c4b8
commit
8f6c46f0ac
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user