feat: implement OTP login flow for non-staff accounts in QA driver
This commit is contained in:
@@ -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` میگذارد و وارنینگ نویزیاش را خفه میکند.
|
||||
- **خطاهای صفحهٔ لاگین به حساب صفحهٔ تحت تست نوشته نشوند.** درایور بافر خطا را بعد از
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user