From 5838e92980c1043e63bcfecc9ac79bfd3129d06c Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sun, 19 Jul 2026 01:01:51 +0300 Subject: [PATCH] fix(test): read OTP from Redis instead of requiring OTP_DEV_MODE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit نصيحتي السابقة بتفعيل `OTP_DEV_MODE=true` على السيرفر كانت خاطئة: حارس في `main.ts` يرفض الإقلاع عند `OTP_DEV_MODE=true` مع `NODE_ENV=production`، فسقطت حاوية الـapi في حلقة إعادة تشغيل وظهر الفشل كـ`EAI_AGAIN` في السكربت. الحارس صحيح ولا يُضعَف لأجل اختبار: رمز ثابت على سيرفر متاح للإنترنت يعني أن معرفة رقم هاتف تكفي لدخول أي حساب. السكربت صار يقرأ الرمز الحقيقي من Redis (`send-otp` يخزّنه قبل محاولة الإرسال، فيبقى محفوظاً حتى حين يفشل الإرسال لرقم وهمي). عميل RESP أدنى على `node:net` بلا تبعيات — الحاوية عابرة بلا `node_modules`. المفتاح يستعمل UUID المستأجر لا الـslug، فتُجلب خريطة الـslug→UUID من `/admin/tenants`. Co-Authored-By: Claude Opus 4.8 --- backend/scripts/pricing-test.mjs | 143 +++++++++++++++++++++++++++---- 1 file changed, 125 insertions(+), 18 deletions(-) diff --git a/backend/scripts/pricing-test.mjs b/backend/scripts/pricing-test.mjs index 365eabf..c5f584b 100644 --- a/backend/scripts/pricing-test.mjs +++ b/backend/scripts/pricing-test.mjs @@ -7,15 +7,27 @@ // // التشغيل (من داخل شبكة الحاوية، بعد نجاح البناء): // docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \ -// -e PLATFORM_SECRET=<القيمة من .env> \ +// -e PLATFORM_SECRET="$(grep -E '^PLATFORM_SECRET=' .env | cut -d= -f2-)" \ // -v /home/tripz-llc/backend/scripts:/s node:22-alpine node /s/pricing-test.mjs // -// يتطلّب OTP_DEV_MODE=true (الرمز الثابت 1234) و PLATFORM_SECRET مضبوطاً. +// يتطلّب `PLATFORM_SECRET` فقط — **لا يحتاج `OTP_DEV_MODE`**: يقرأ رمز +// التحقّق من Redis مباشرةً. الوضع التطويري ممنوع على الإنتاج بحارس في +// `main.ts`، وإضعاف ذلك الحارس لأجل اختبار مقايضة خاسرة. + +import net from 'node:net'; const BASE = process.env.BASE || 'http://localhost:4010/api'; -const CODE = '1234'; const PLATFORM_SECRET = process.env.PLATFORM_SECRET || ''; +// يقرأ رمز OTP من Redis مباشرةً بدل `OTP_DEV_MODE`. الوضع التطويري ممنوع +// على الإنتاج بحارس في `main.ts` (`OTP_DEV_MODE=true` + `NODE_ENV=production` +// = رفض الإقلاع)، وهو حارس صحيح: رمز ثابت على سيرفر متاح للإنترنت يعني أن +// معرفة رقم هاتف تكفي لدخول أي حساب. فنقرأ الرمز الحقيقي بدل إضعاف الحارس. +const REDIS_HOST = process.env.REDIS_HOST || 'tripz-redis'; +const REDIS_PORT = parseInt(process.env.REDIS_PORT || '6379', 10); +const REDIS_DB = parseInt(process.env.REDIS_DB || '3', 10); +const REDIS_KEY_PREFIX = process.env.REDIS_KEY_PREFIX || 'tripz:'; + if (!PLATFORM_SECRET) { console.error('❌ PLATFORM_SECRET غير مضبوط — لا يمكن إنشاء مستأجرَي الاختبار.'); process.exit(1); @@ -59,6 +71,71 @@ const TENANTS = [ { slug: 'tripz-test-sy', countryPack: 'sy', currency: 'SYP', create: true, name: 'Tripz Test SY' }, ]; +// ---- عميل Redis أدنى (RESP على TCP خام) ---- +// بلا `ioredis`: حاوية `node:22-alpine` عابرة بلا `node_modules`، وتثبيت +// حزمة لكل تشغيلة تبعية شبكة لا داعي لها لأمرَين اثنين (SELECT ثم GET). + +const respCmd = (...args) => + `*${args.length}\r\n` + + args.map((a) => `$${Buffer.byteLength(a)}\r\n${a}\r\n`).join(''); + +/** يفكّ ردّاً واحداً بدءاً من `i`؛ يعيد null إن لم يكتمل بعد. */ +function parseReply(s, i) { + const type = s[i]; + const end = s.indexOf('\r\n', i); + if (end === -1) return null; + const head = s.slice(i + 1, end); + if (type === '+' || type === '-' || type === ':') { + return { value: head, next: end + 2, error: type === '-' }; + } + if (type === '$') { + const len = parseInt(head, 10); + if (len === -1) return { value: null, next: end + 2 }; // مفتاح غير موجود + const start = end + 2; + if (s.length < start + len + 2) return null; + return { value: s.slice(start, start + len), next: start + len + 2 }; + } + return null; +} + +function redisGet(key) { + return new Promise((resolve, reject) => { + const sock = net.createConnection({ host: REDIS_HOST, port: REDIS_PORT }); + sock.setEncoding('utf8'); + const timer = setTimeout(() => { + sock.destroy(); + reject(new Error(`redis timeout (${REDIS_HOST}:${REDIS_PORT})`)); + }, 5000); + + sock.on('connect', () => { + sock.write(respCmd('SELECT', String(REDIS_DB))); + sock.write(respCmd('GET', key)); + }); + + let buf = ''; + sock.on('data', (chunk) => { + buf += chunk; + const first = parseReply(buf, 0); // ردّ SELECT + if (!first) return; + if (first.error) { + clearTimeout(timer); + sock.end(); + return reject(new Error(`redis SELECT failed: ${first.value}`)); + } + const second = parseReply(buf, first.next); // ردّ GET + if (!second) return; + clearTimeout(timer); + sock.end(); + resolve(second.value); + }); + + sock.on('error', (e) => { + clearTimeout(timer); + reject(e); + }); + }); +} + async function api(method, path, { token, tenant, body, platform } = {}) { const headers = { 'Content-Type': 'application/json' }; if (token) headers.Authorization = `Bearer ${token}`; @@ -112,29 +189,45 @@ function testPhone(countryPack, seed) { return `${plan.code}${plan.prefix}${digits}`; } -async function loginRider(tenantSlug, countryPack, seed) { +async function loginRider(tenantSlug, tenantId, countryPack, seed) { const phone = testPhone(countryPack, seed); - // **يجب** المرور بـ`send-otp` أولاً: تجاوز التطوير في `verifyOtp` مشروط - // بـ`OTP_DEV_MODE=true`، وبدونه لا يوجد رمز في Redis أصلاً فيُرفض أي رمز - // نرسله. النسخة الأولى نادت `verify-otp` مباشرةً بالرمز الثابت 1234 - // فسقطت بـ401 على سيرفر وضعه التطويري مطفأ — وهو الوضع الصحيح لسيرفر حي. + // `send-otp` يخزّن الرمز في Redis **قبل** محاولة الإرسال، فحتى لو فشل + // الإرسال لرقم وهمي (503) يكون الرمز محفوظاً — وهو ما نقرأه. const sent = await api('POST', '/auth/send-otp', { tenant: tenantSlug, body: { phone } }); - const code = sent.body?.dev_code ?? CODE; + if (sent.body?.dev_code) { + // وضع تطويري مفعّل (بيئة غير إنتاجية) — الرمز في الاستجابة مباشرةً. + return (await verify(tenantSlug, phone, sent.body.dev_code))?.access_token; + } - if (!sent.body?.dev_code) { + // المفتاح كما يبنيه `AuthService.otpKey` + `keyPrefix` من إعداد Redis، + // والمعرّف هو **UUID المستأجر** لا الـslug. + const key = `${REDIS_KEY_PREFIX}otp:${tenantId}:${phone}`; + let code; + try { + code = await redisGet(key); + } catch (e) { + console.error(` ❌ تعذّرت قراءة الرمز من Redis: ${e.message}`); + return undefined; + } + if (!code) { console.error( - ` ⚠️ لا يوجد dev_code في استجابة send-otp — يعني OTP_DEV_MODE مطفأ.\n` + - ` هذا السكربت يحتاجه لتسجيل دخول راكب اختبار. فعّله مؤقتاً في .env ثم` + - ` أعد تشغيل الـapi، وأطفئه بعد الانتهاء.`, + ` ❌ لا رمز في Redis للمفتاح ${key} — ` + + `استجابة send-otp كانت ${sent.status} ${JSON.stringify(sent.body)}`, ); + return undefined; } - const r = await api('POST', '/auth/verify-otp', { tenant: tenantSlug, body: { phone, code } }); - if (!r.body?.access_token) { - console.error(` ❌ فشل تسجيل الدخول (${phone}): ${r.status} ${JSON.stringify(r.body)}`); + const r = await verify(tenantSlug, phone, code); + if (!r?.access_token) { + console.error(` ❌ فشل تسجيل الدخول (${phone}): ${JSON.stringify(r)}`); } - return r.body?.access_token; + return r?.access_token; +} + +async function verify(tenantSlug, phone, code) { + const r = await api('POST', '/auth/verify-otp', { tenant: tenantSlug, body: { phone, code } }); + return r.body; } async function quoteFor(tenantSlug, token, serviceClass, ride) { @@ -157,12 +250,26 @@ async function main() { for (const t of TENANTS) await ensureTenant(t); + // مفتاح OTP في Redis يستعمل **UUID المستأجر** لا الـslug، فنجلب الخريطة مرة. + const all = await api('GET', '/admin/tenants', { platform: true }); + const idBySlug = new Map((all.body ?? []).map((x) => [x.slug, x.id])); + if (!idBySlug.size) { + console.error(`❌ تعذّر جلب المستأجرين: ${all.status} ${JSON.stringify(all.body)}`); + process.exitCode = 1; + return; + } + const rows = []; for (const t of TENANTS) { console.log(`\n--- ${t.slug} (${t.countryPack} / ${t.currency}) ---`); + const tenantId = idBySlug.get(t.slug); + if (!tenantId) { + console.error(`❌ لا UUID للمستأجر ${t.slug} — تخطّي`); + continue; + } let seed = 500 + TENANTS.indexOf(t) * 100; - const token = await loginRider(t.slug, t.countryPack, seed++); + const token = await loginRider(t.slug, tenantId, t.countryPack, seed++); if (!token) { console.error(`❌ تعذّر تسجيل دخول راكب اختبار لـ${t.slug} — تخطّي`); continue;