Files
tripz-llc/backend/scripts/wallet-race-test.mjs
T
Hamza-AyedandClaude Opus 4.8 1605754722 feat: باقي المجموعة I — أمان السحب (OTP + بصمة + HMAC + تدقيق)
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>
2026-07-17 16:22:00 +03:00

123 lines
5.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// اختبار تسابق المحفظة (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);
});