I4 — OTP على السحب عبر نبيه. تدفّق خطوتين، وقاعدته: **لا يتحرك مال قبل إثبات الهوية**: - POST /payouts/request → يرسل رمزاً، حالة pending_otp، **بلا خصم** - POST /payouts/:id/confirm → يتحقق ثم يحجز ذرّياً (خصم I1) - حدّ 5 محاولات (رمز 4 خانات يُخمَّن في دقائق بلا حدّ)، الرمز يُستهلك مرة - complete يرفض طلباً بلا otp_verified_at (حارس ضد تخطّي التأكيد) - fail لا يردّ مالاً لطلب pending_otp — لم يُخصم منه شيء، وردّه يخلق مالاً I5 — biometric_method/at + device_id + request_ip على السحب. أثرٌ للتحقيق لا مصادقة: العميل يستطيع ادّعاءها، والمصادقة الحقيقية JWT + رمز واتساب. I6 — HMAC **مبنيّ ومطفأ** (PAYMENTS_REQUIRE_SIGNATURE=false) حتى يوقّع فلاتر؛ تفعيله الآن يقطع كل سحب. مفتاح **لكل جلسة** يُصدره الدخول لا سرّ ثابت في التطبيق (الثابت يُستخرج بالهندسة العكسية فيصير التوقيع مسرحية). يوقّع timestamp.METHOD.path.body بنافذة 5 دقائق؛ rawBody مفعّل في main. I7 — tripz_audit_log append-only: من·ماذا·متى·أي IP وجهاز. لا يرمي أبداً — فشل التدقيق يجب ألّا يُسقط عمليةً مالية نجحت. I2 أُلغيت الحاجة إليها: إيراد المنصة = الشحن، و credit_txns دفتره فعلاً؛ محفظة ثانية = دفتر مزدوج يحتاج مطابقة. الباقي تقرير لا محفظة. I3/I8 مؤجَّلتان بوعي (توثيق في docs/17). wallet-race-test.mjs حُدِّث: السباق انتقل من request إلى confirm، وأُضيف محكّ أن الطلب وحده لا يمسّ الرصيد. هجرة: PayoutSecurityAndAudit (تعتبر السحوبات القائمة مُتحقَّقة وإلا رفض complete صرفها للأبد). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
123 lines
5.4 KiB
JavaScript
123 lines
5.4 KiB
JavaScript
// اختبار تسابق المحفظة (docs/17 — I1): يثبت أن المال لا يُفقد ولا يُخلق تحت التزامن.
|
||
// يستخدم fetch المدمج (Node 18+) — بلا تبعيات.
|
||
//
|
||
// لماذا سكربت منفصل: pg-mem في اختبارات jest أحادي الخيط فيثبت صحة الـSQL فقط،
|
||
// أما التزامن الحقيقي على نفس الصف فيحتاج Postgres حقيقياً.
|
||
//
|
||
// التشغيل (حاوية node مؤقتة على شبكة tripz):
|
||
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
|
||
// -v /home/tripz-llc/backend/scripts:/s node:22-alpine node /s/wallet-race-test.mjs 100 5
|
||
// الوسائط: <عدد محاولات الخصم المتزامنة> <مبلغ كل خصم>
|
||
|
||
const BASE = process.env.BASE || 'http://localhost:4010/api';
|
||
const TENANT = process.env.TENANT || 'siro';
|
||
const ATTEMPTS = parseInt(process.argv[2] || '100', 10);
|
||
const AMOUNT = parseFloat(process.argv[3] || '5');
|
||
const CODE = '1234';
|
||
|
||
const H = (t) => ({ 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) });
|
||
|
||
async function api(method, path, token, body) {
|
||
const res = await fetch(`${BASE}${path}`, {
|
||
method,
|
||
headers: { ...H(token), 'x-tenant-id': TENANT },
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
});
|
||
const text = await res.text().catch(() => '');
|
||
let json = {};
|
||
try { json = text ? JSON.parse(text) : {}; } catch { /* نص غير JSON */ }
|
||
return { ok: res.ok, status: res.status, body: json };
|
||
}
|
||
|
||
async function token(phone) {
|
||
const r = await api('POST', '/auth/verify-otp', null, { phone, code: CODE });
|
||
if (!r.ok) throw new Error(`auth failed: ${r.status} ${JSON.stringify(r.body)}`);
|
||
return r.body.access_token;
|
||
}
|
||
|
||
async function main() {
|
||
// مبلغ يكفي نصف المحاولات فقط — النصف الآخر *يجب* أن يُرفض.
|
||
const expectedSuccesses = Math.floor(ATTEMPTS / 2);
|
||
const funded = expectedSuccesses * AMOUNT;
|
||
|
||
const phone = `0796${String(Date.now()).slice(-6)}`;
|
||
const t = await token(phone);
|
||
|
||
await api('POST', '/wallet/topup', t, { amount: funded });
|
||
const before = await api('GET', '/wallet', t);
|
||
const startBalance = Number(before.body.balance);
|
||
console.log(`المحفظة: ${phone} | الرصيد الابتدائي = ${startBalance}`);
|
||
if (startBalance !== funded) {
|
||
console.log(`⚠️ الشحن لم يطابق المتوقع (${funded}) — أوقفت الاختبار.`);
|
||
process.exit(1);
|
||
}
|
||
|
||
// تدفّق السحب صار خطوتين (docs/17 — I4): الطلب يرسل رمزاً ولا يخصم شيئاً،
|
||
// والخصم يقع عند التأكيد. فالسباق هنا على `confirm` لا على `request`.
|
||
// يعتمد dev_code الذي ترجعه النقطة في وضع التطوير (OTP_DEV_MODE=true).
|
||
console.log(`تحضير ${ATTEMPTS} طلب سحب (بلا خصم)…`);
|
||
const requested = await Promise.all(
|
||
Array.from({ length: ATTEMPTS }, () =>
|
||
api('POST', '/payouts/request', t, { amount: AMOUNT, channel: 'cliq' }).catch(() => null),
|
||
),
|
||
);
|
||
const pending = requested.filter((r) => r?.ok && r.body?.payout?.id && r.body?.dev_code);
|
||
if (pending.length !== ATTEMPTS) {
|
||
console.log(`⚠️ حُضّر ${pending.length}/${ATTEMPTS} فقط.`);
|
||
if (pending.length === 0) {
|
||
console.log('لا شيء لتأكيده — تأكّد أن OTP_DEV_MODE=true (يُرجع dev_code).');
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// فحص وسيط: الطلب وحده يجب ألّا يمسّ الرصيد إطلاقاً.
|
||
const mid = await api('GET', '/wallet', t);
|
||
const midBalance = Number(mid.body.balance);
|
||
|
||
console.log(`إطلاق ${pending.length} تأكيد متزامن × ${AMOUNT} (يكفي ${expectedSuccesses} فقط)…`);
|
||
const t0 = Date.now();
|
||
const results = await Promise.all(
|
||
pending.map((r) =>
|
||
api('POST', `/payouts/${r.body.payout.id}/confirm`, t, {
|
||
code: String(r.body.dev_code),
|
||
}).catch((e) => ({ ok: false, status: 0, body: { message: String(e) } })),
|
||
),
|
||
);
|
||
const elapsed = Date.now() - t0;
|
||
|
||
const ok = results.filter((r) => r.ok).length;
|
||
const rejected = results.filter((r) => !r.ok).length;
|
||
|
||
const after = await api('GET', '/wallet', t);
|
||
const endBalance = Number(after.body.balance);
|
||
const spent = startBalance - endBalance;
|
||
|
||
console.log('');
|
||
console.log(`نجح : ${ok} (المتوقع ${expectedSuccesses})`);
|
||
console.log(`رُفض : ${rejected}`);
|
||
console.log(`الرصيد : ${startBalance} → ${endBalance} (خُصم ${spent})`);
|
||
console.log(`الزمن : ${elapsed}ms`);
|
||
console.log('');
|
||
|
||
const checks = [
|
||
['الطلب وحده لا يمسّ الرصيد (لا مال قبل إثبات الهوية)', midBalance === startBalance],
|
||
['الرصيد لم يصبح سالباً', endBalance >= 0],
|
||
['عدد النجاحات = ما يسمح به الرصيد', ok === expectedSuccesses],
|
||
['المخصوم = النجاحات × المبلغ (لا مال ضائع/مخلوق)', Math.abs(spent - ok * AMOUNT) < 1e-6],
|
||
];
|
||
let failed = 0;
|
||
for (const [name, pass] of checks) {
|
||
console.log(`${pass ? '✅' : '❌'} ${name}`);
|
||
if (!pass) failed++;
|
||
}
|
||
|
||
console.log('');
|
||
console.log(failed === 0 ? '✅ لا سباق: المحفظة ذرّية.' : `❌ ${failed} محكّ فشل — يوجد سباق.`);
|
||
process.exit(failed === 0 ? 0 : 1);
|
||
}
|
||
|
||
main().catch((e) => {
|
||
console.error('خطأ:', e.message);
|
||
process.exit(1);
|
||
});
|