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:
co-authored by
Claude Opus 4.8
parent
7501b0bb33
commit
5838e92980
@@ -7,15 +7,27 @@
|
|||||||
//
|
//
|
||||||
// التشغيل (من داخل شبكة الحاوية، بعد نجاح البناء):
|
// التشغيل (من داخل شبكة الحاوية، بعد نجاح البناء):
|
||||||
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
|
// 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
|
// -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 BASE = process.env.BASE || 'http://localhost:4010/api';
|
||||||
const CODE = '1234';
|
|
||||||
const PLATFORM_SECRET = process.env.PLATFORM_SECRET || '';
|
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) {
|
if (!PLATFORM_SECRET) {
|
||||||
console.error('❌ PLATFORM_SECRET غير مضبوط — لا يمكن إنشاء مستأجرَي الاختبار.');
|
console.error('❌ PLATFORM_SECRET غير مضبوط — لا يمكن إنشاء مستأجرَي الاختبار.');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -59,6 +71,71 @@ const TENANTS = [
|
|||||||
{ slug: 'tripz-test-sy', countryPack: 'sy', currency: 'SYP', create: true, name: 'Tripz Test SY' },
|
{ 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 } = {}) {
|
async function api(method, path, { token, tenant, body, platform } = {}) {
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
if (token) headers.Authorization = `Bearer ${token}`;
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
@@ -112,29 +189,45 @@ function testPhone(countryPack, seed) {
|
|||||||
return `${plan.code}${plan.prefix}${digits}`;
|
return `${plan.code}${plan.prefix}${digits}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loginRider(tenantSlug, countryPack, seed) {
|
async function loginRider(tenantSlug, tenantId, countryPack, seed) {
|
||||||
const phone = testPhone(countryPack, seed);
|
const phone = testPhone(countryPack, seed);
|
||||||
|
|
||||||
// **يجب** المرور بـ`send-otp` أولاً: تجاوز التطوير في `verifyOtp` مشروط
|
// `send-otp` يخزّن الرمز في Redis **قبل** محاولة الإرسال، فحتى لو فشل
|
||||||
// بـ`OTP_DEV_MODE=true`، وبدونه لا يوجد رمز في Redis أصلاً فيُرفض أي رمز
|
// الإرسال لرقم وهمي (503) يكون الرمز محفوظاً — وهو ما نقرأه.
|
||||||
// نرسله. النسخة الأولى نادت `verify-otp` مباشرةً بالرمز الثابت 1234
|
|
||||||
// فسقطت بـ401 على سيرفر وضعه التطويري مطفأ — وهو الوضع الصحيح لسيرفر حي.
|
|
||||||
const sent = await api('POST', '/auth/send-otp', { tenant: tenantSlug, body: { phone } });
|
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(
|
console.error(
|
||||||
` ⚠️ لا يوجد dev_code في استجابة send-otp — يعني OTP_DEV_MODE مطفأ.\n` +
|
` ❌ لا رمز في Redis للمفتاح ${key} — ` +
|
||||||
` هذا السكربت يحتاجه لتسجيل دخول راكب اختبار. فعّله مؤقتاً في .env ثم` +
|
`استجابة send-otp كانت ${sent.status} ${JSON.stringify(sent.body)}`,
|
||||||
` أعد تشغيل الـapi، وأطفئه بعد الانتهاء.`,
|
|
||||||
);
|
);
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const r = await api('POST', '/auth/verify-otp', { tenant: tenantSlug, body: { phone, code } });
|
const r = await verify(tenantSlug, phone, code);
|
||||||
if (!r.body?.access_token) {
|
if (!r?.access_token) {
|
||||||
console.error(` ❌ فشل تسجيل الدخول (${phone}): ${r.status} ${JSON.stringify(r.body)}`);
|
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) {
|
async function quoteFor(tenantSlug, token, serviceClass, ride) {
|
||||||
@@ -157,12 +250,26 @@ async function main() {
|
|||||||
|
|
||||||
for (const t of TENANTS) await ensureTenant(t);
|
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 = [];
|
const rows = [];
|
||||||
|
|
||||||
for (const t of TENANTS) {
|
for (const t of TENANTS) {
|
||||||
console.log(`\n--- ${t.slug} (${t.countryPack} / ${t.currency}) ---`);
|
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;
|
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) {
|
if (!token) {
|
||||||
console.error(`❌ تعذّر تسجيل دخول راكب اختبار لـ${t.slug} — تخطّي`);
|
console.error(`❌ تعذّر تسجيل دخول راكب اختبار لـ${t.slug} — تخطّي`);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
Reference in New Issue
Block a user