fix(test): read OTP from Redis instead of requiring OTP_DEV_MODE

نصيحتي السابقة بتفعيل `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 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-19 01:01:51 +03:00
co-authored by Claude Opus 4.8
parent 7501b0bb33
commit 5838e92980
+125 -18
View File
@@ -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;