feat: implement OTP login flow for non-staff accounts in QA driver

This commit is contained in:
hamed
2026-07-19 13:58:20 +03:30
parent b10b0813f3
commit 3e841b5e57
2 changed files with 69 additions and 3 deletions
+19
View File
@@ -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` می‌گذارد و وارنینگ نویزی‌اش را خفه می‌کند.
- **خطاهای صفحهٔ لاگین به حساب صفحهٔ تحت تست نوشته نشوند.** درایور بافر خطا را بعد از
+50 -3
View File
@@ -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. */