From 3e841b5e57473282ed0aeb167782c76448809476 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 19 Jul 2026 13:58:20 +0330 Subject: [PATCH] feat: implement OTP login flow for non-staff accounts in QA driver --- .claude/skills/qa-clinicpro/SKILL.md | 19 +++++++++ .claude/skills/qa-clinicpro/driver.mjs | 53 ++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/.claude/skills/qa-clinicpro/SKILL.md b/.claude/skills/qa-clinicpro/SKILL.md index 9fcb3906..ff49d90f 100644 --- a/.claude/skills/qa-clinicpro/SKILL.md +++ b/.claude/skills/qa-clinicpro/SKILL.md @@ -478,6 +478,25 @@ node .claude/skills/redesign-page/driver.mjs inspect "https://clinic-pro.ddev.si - **`TEST_USERS.md` دروغ می‌گوید** (بالا). به آن استناد نکن. - **`CLAUDE.md` هم روی `/api/v1/categorys/{bundle}` منسوخ است** — آن مسیر حالا ۳۰۱ با `ERR_MOVED` می‌دهد و مسیر واقعی `/api/v1/provinces` است. +- **کد OTP در محیط dev همیشه `12345` است** (`OtpService::sendCode` — در غیر dev کد تصادفی + ۵رقمی می‌سازد و SMS می‌کند). پس زنجیرهٔ کامل ورود بدون رمز اسکریپت‌پذیر است: + `POST /api/v1/user/send-code` → `POST /api/v1/user/verify-code` با `code=12345` → + `grant` → `POST /api/v1/user/otp-login`. +- **`patient` و `unclaimed_doctor` با رمز وارد نمی‌شوند و این باگ نیست.** + `PasswordAuthenticator::onAuthenticationSuccess` هر کاربری که `User::isStaff()` نباشد را + با ۴۰۳ و `ERR_AUTH_006` رد می‌کند (staff = doctor/clinic/secretary/admin/representation/importer). + این دو پرسونا فقط OTP-only هستند؛ درایور خودش به زنجیرهٔ OTP بالا fallback می‌کند. + **گاردش را برای سبزشدن تست باز نکن.** +- **`send-code` سقف ۵ درخواست در ساعت به‌ازای هر IP دارد** (`config/packages/rate_limiter.yaml`). + یک جاروی کامل authz این سقف را می‌سوزاند و بعدش پرسوناهای OTP-only شکست می‌خورند + (`ERR_RATE_LIMIT_001`). راه‌حل بدون دست‌زدن به محصول: توکن را مستقیم با کامند خود اپ بساز — + ```bash + ddev exec 'php bin/console lexik:jwt:generate-token 09129000006 --user-class="App\\Auth\\Entity\\User"' + ``` + همان کلید و همان claimها؛ فقط محدودیت نرخ را دور می‌زند. +- **برای جاروی ماتریس، درایور را در حلقه صدا نزن.** هر فراخوانی دوباره لاگین می‌کند + (۱۶۸ مسیر × ۱۳ پرسونا ≈ ۲۲۰۰ لاگین) — هم چند ده دقیقه طول می‌کشد هم rate limit را می‌سوزاند. + یک‌بار برای هر پرسونا توکن بگیر و همان را در همهٔ مسیرها استفاده کن. - **گواهی TLS ddev را Node قبول نمی‌کند.** درایور فقط برای هاست‌های `*.ddev.site` / `localhost` `NODE_TLS_REJECT_UNAUTHORIZED=0` می‌گذارد و وارنینگ نویزی‌اش را خفه می‌کند. - **خطاهای صفحهٔ لاگین به حساب صفحهٔ تحت تست نوشته نشوند.** درایور بافر خطا را بعد از diff --git a/.claude/skills/qa-clinicpro/driver.mjs b/.claude/skills/qa-clinicpro/driver.mjs index 8ae7ed1c..866f1af1 100644 --- a/.claude/skills/qa-clinicpro/driver.mjs +++ b/.claude/skills/qa-clinicpro/driver.mjs @@ -16,7 +16,7 @@ * into localStorage, navigates, then reports console errors, failed network * requests, the path it actually landed on, and a screenshot. */ -import { spawn } from 'node:child_process'; +import { spawn, execSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; const BASE = process.env.CLINICPRO_BASE ?? 'https://clinic-pro.ddev.site'; @@ -66,6 +66,43 @@ function creds(role) { throw new Error(`unknown role "${role}". Known: ${Object.keys(ROLES).join(', ')}`); } +/** + * Non-staff accounts (ROLE_USER, ROLE_UNCLAIMED_DOCTOR) are rejected by + * PasswordAuthenticator with ERR_AUTH_006 by design — they are OTP-only. + * In dev the OTP is the fixed '12345' (OtpService::sendCode), so the whole + * send-code → verify-code → otp-login chain is scriptable. + */ +async function otpLogin(mobile) { + const post = async (path, body) => { + const r = await fetch(`${BASE}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return r.json(); + }; + + const sent = await post('/api/v1/user/send-code', { mobile }); + if (!sent.uuid) throw new Error(`send-code failed: ${JSON.stringify(sent).slice(0, 200)}`); + + const ver = await post('/api/v1/user/verify-code', { uuid: sent.uuid, code: '12345' }); + const grant = ver?.data?.grant; + if (!grant) throw new Error(`verify-code failed: ${JSON.stringify(ver).slice(0, 200)}`); + + return post('/api/v1/user/otp-login', { grant }); +} + +/** Same signing key and claims as a real login — only skips the rate limiter. */ +function mintToken(mobile) { + const out = execSync( + `ddev exec 'php bin/console lexik:jwt:generate-token ${mobile} --user-class="App\\\\Auth\\\\Entity\\\\User"'`, + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], cwd: process.cwd() }, + ); + const tok = out.trim().split('\n').pop().trim(); + if (!tok.startsWith('ey')) throw new Error(`could not mint token for ${mobile}`); + return tok; +} + async function login(role) { const [mobile_number, password] = creds(role); const r = await fetch(`${BASE}/api/v1/user/login`, { @@ -74,8 +111,18 @@ async function login(role) { body: JSON.stringify({ mobile_number, password }), }); const j = await r.json(); - if (!j.access_token) throw new Error(`login as ${role} failed: ${JSON.stringify(j).slice(0, 300)}`); - return j; + if (j.access_token) return j; + + // ERR_AUTH_006 here means "staff-only endpoint", not "wrong password". + if (j?.errors?.some((e) => e.code === 'ERR_AUTH_006')) { + const o = await otpLogin(mobile_number).catch((e) => ({ _err: e.message })); + if (o.access_token) return o; + // send-code is capped at 5/hour/IP; once burned, mint the JWT with the app's + // own command rather than loosening a real product limit for a test. + return { access_token: mintToken(mobile_number) }; + } + + throw new Error(`login as ${role} failed: ${JSON.stringify(j).slice(0, 300)}`); } /** JWT is unsigned-read here purely to report which roles a token carries. */