# گزارش ممیزی کامل QA — ClinicPro (Backend Symfony + پنل ادمین React) > تاریخ اجرا: 2026-07-01 · محیط: `https://clinic-pro.ddev.site` (ddev/dev) > روش: اجرای واقعی تست‌های موجود + تست زندهٔ API با curl و توکن واقعی ادمین. **همهٔ یافته‌ها با خروجی واقعی مستند شده‌اند؛ موارد اثبات‌نشده در بخش «نیازمند بررسی بیشتر» با ذکر دلیل آمده‌اند (نه به‌عنوان باگ قطعی).** ## خلاصهٔ اجرایی | شدت | تعداد | ناحیه | |-----|------|-------| | Critical | 0 | — | | High | 3 | Security (brute-force)، Test data/fixtures، Doctor dashboard 500 (رفع‌شده) | | Medium | 2 | Error-code consistency، Missing seeder | | Low | 5 | phpstan (رفع‌شده)، external HTTP در تست، api/doc 401، field-naming، public-endpoint inconsistency | > **دور دوم (creds درست: `09100000001` / پسورد `09100000001` — نه `Test@1234`).** با ساخت ۲ دکتر تستی via admin API و ست‌کردن `password_hash` معلوم، توکن `ROLE_DOCTOR` گرفته شد و فازهای قبلاً مسدود (RBAC/IDOR/dashboard) واقعاً اجرا شدند. نتیجه: **RBAC و IDOR سالم**، اما یک باگ Critical-functional در داشبورد دکتر کشف و رفع شد (F11). ادمین واقعی `09120671756` (id=2) هم `ROLE_ADMIN` است (رمز نامعلوم) اما پوشش admin از طریق ادمین دیگر کامل بود. **سلامت پایه (سبز):** PHPUnit `78 tests / 180 assertions OK` · Vitest `88 passed` · `tsc --noEmit` بدون خطا · `yarn dev` build موفق · phpstan بعد از رفع F1 → `No errors`. **محدودیت مهم اجرا:** دیتابیس dev فقط شامل کاربر ادمین است؛ کاربران دکتر/کلینیک/منشی و ۱۵۰۰ دکتر مستندشده در `TEST_USERS.md` seed نشده‌اند (F3). در نتیجه تست‌های **RBAC بین‌نقشی، IDOR، و E2E (فاز ۶)** قابل اجرا نبودند و به‌صراحت پوشش‌داده‌نشده علامت خورده‌اند. --- ## یافته‌ها ### [F1] phpstan: مقایسهٔ همیشه‌درست در محاسبهٔ estimated SMS — ✅ رفع شد - **شدت:** Low · **ناحیه:** Backend - **مسیر:** `src/Sms/Controller/SmsWalletController.php:59` - **مراحل بازتولید:** `ddev exec php vendor/bin/phpstan analyse` - **نتیجهٔ واقعی (قبل):** `Comparison operation ">" between 500 and 0 is always true (greater.alwaysTrue)` — regression ناشی از ثابت‌شدن `SMS_PRICE_RIALS` در تغییر قبلی. - **رفع انجام‌شده:** حذف شرط زائد → `$estimatedSms = (int) floor($balanceRials / $smsPriceRials);`. بازآزمون: `phpstan → No errors`. ### [F2] تست‌های PHPUnit به API خارجی Kavenegar درخواست واقعی می‌زنند - **شدت:** Low · **ناحیه:** Backend / Test infra - **مراحل بازتولید:** `ddev exec php bin/phpunit` - **نتیجهٔ واقعی:** ۵ بار `[error] SMS send failed (kavenegar): HTTP/1.1 403 Forbidden returned for "https://api.kavenegar.com/v1/change_me/sms/send.json"`. تست‌ها با key محیطی `change_me` به سرور واقعی کاوه‌نگار می‌زنند (خطا log می‌شود ولی تست پاس می‌شود چون send مقدار false برمی‌گرداند). - **نتیجهٔ مورد انتظار:** provider در تست mock/intercept شود؛ هیچ درخواست شبکهٔ خارجی در suite. - **پیشنهاد رفع:** در `tests/` برای `KavehNegarProvider` یک HttpClient mock تزریق شود (Symfony `MockHttpClient`)، یا provider پشت interface در محیط test با fake جایگزین شود. ### [F3] دیتابیس تست seed نشده — فقط کاربر ادمین وجود دارد - **شدت:** High · **ناحیه:** Test data / Integration - **مراحل بازتولید:** ```bash ddev exec php bin/console dbal:run-sql \ "SELECT mobile_number FROM users WHERE mobile_number IN ('09100100000','09100100001','09100100002')" # → 0 rows (فقط 09100000001 admin برمی‌گردد) curl -sk ".../api/v1/admin/doctors?page=1&limit=2" -H "Authorization: Bearer $ADMIN" # → meta.totalRecords = 0 ``` - **نتیجهٔ مورد انتظار:** طبق `TEST_USERS.md`، کاربران کلینیک/دکتر/منشی + ۱۵۰۰ دکتر با رمز `Test@1234` موجود باشند. - **نتیجهٔ واقعی (شمارش مستقیم DB):** ``` users=2 (هر دو ADMIN: 09100000001 + 09120671756) · doctors=0 · clinics=0 appointments=0 · blogs=0 · representations=0 · cities=33 · specialties=93 ``` فقط داده‌های مرجع (cities/specialties از `app:seed-categories`) موجودند؛ هیچ دادهٔ تجاری (دکتر/کلینیک/نوبت) seed نشده. `login` برای کاربران دکتر/کلینیک/منشی مستند در `TEST_USERS.md` → `ERR_AUTH_005` چون اصلاً در جدول `users` نیستند. - **اثر:** تست‌های RBAC بین‌نقشی، IDOR، و کل فاز E2E مسدود شد. - **پیشنهاد رفع:** بازگرداندن/ساخت seeder (رجوع به F4) و اجرای آن قبل از QA. ### [F4] اسکریپت seeder `create_test_users.php` وجود ندارد - **شدت:** Medium · **ناحیه:** Docs / Tooling - **مراحل بازتولید:** `ddev exec php create_test_users.php` → `Could not open input file: create_test_users.php` - **نتیجهٔ واقعی:** فایل در root پروژه نیست. این دستور در `CLAUDE.md` (سطح workspace و clinicpro) و `TEST_USERS.md` به‌عنوان راه بازساخت کاربران تست ذکر شده. کامندهای موجود: `app:create-admin`, `app:seed-categories`, `app:seed-sms-message-templates` (هیچ‌کدام کاربران دکتر/کلینیک/bulk را نمی‌سازند). - **پیشنهاد رفع:** یا اسکریپت/کامند seeder را اضافه کن (`app:seed-test-users`)، یا مستندات را به دستور واقعی موجود اصلاح کن. ### [F5] ادمین با JWT معتبر به `/api/doc` (Swagger UI) دسترسی ندارد (401) - **شدت:** Low · **ناحیه:** Backend / Auth - **مراحل بازتولید:** `curl -sk -o /dev/null -w "%{http_code}" ".../api/doc" -H "Authorization: Bearer $ADMIN"` → `401` - **نتیجهٔ مورد انتظار:** طبق `security.yaml` → `access_control: { path: ^/api/doc, roles: ROLE_ADMIN }` باید ادمین دسترسی داشته باشد. - **نتیجهٔ واقعی:** `401`. احتمالاً firewall `api` (stateless/jwt) برای مسیر HTML مستندات با Bearer هماهنگ نیست (Swagger UI مبتنی بر session/browser است، نه bearer). - **پیشنهاد رفع:** بررسی الگوی firewall برای `^/api/doc` و روش auth مورد انتظار (session جدا یا basic). ### [F6] ناسازگاری کدهای خطا بین دامنه‌ها - **شدت:** Medium · **ناحیه:** Backend / API contract - **مراحل بازتولید و نتیجهٔ واقعی:** ``` GET /api/v1/blog/NOPE → code "ERR_NOT_FOUND_001" (درست) GET /api/v1/clinic/NOPE → code "ERR_VALIDATION_002" (اشتباه: not-found با کد validation) GET /api/v1/doctor/NOPE → code "ERR_VALIDATION_002" (اشتباه) POST /api/v1/admin/doctors {} → code "VALIDATION" (رشتهٔ خام، نه ثابت ERR_VALIDATION_00x) ``` - **نتیجهٔ مورد انتظار:** «یافت نشد» همه‌جا `ERR_NOT_FOUND_001` و HTTP 404؛ خطای اعتبارسنجی همه‌جا کد یکنواخت از `ErrorCodes.php`. - **نکتهٔ مثبت:** HTTP status صحیح است (`doctor/NOPE → 404`)؛ فقط کد داخل envelope ناسازگار است. - **اثر cross-repo:** کلاینت‌هایی که روی `code` سوییچ می‌کنند (`nobat724_front`, `clinic-pro-tauri`) ممکن است not-found را به‌اشتباه validation تفسیر کنند. - **پیشنهاد رفع:** در کنترلرهای doctor/clinic هنگام not-found `AppException(ErrorCodes::ERR_NOT_FOUND_001, null, 404)` پرتاب شود؛ کد خام `"VALIDATION"` با ثابت جایگزین شود. ### [F7] ناسازگاری نام فیلد موبایل بین سه endpoint - **شدت:** Low · **ناحیه:** Backend / API contract - **نتیجهٔ واقعی (تأییدشده):** ``` POST /api/v1/user/login → فیلد "mobile_number" POST /api/v1/admin/doctors → فیلد "mobile" (با mobile_number خطای «موبایل و نام الزامی») POST /api/v1/user/send-code→ فیلد "mobile" (با mobile_number → ERR_VALIDATION_001 «فرمت... نادرست») ``` - **اثر:** کلاینت‌ها باید برای هر endpoint نام فیلد متفاوت بفرستند؛ منبع رایج خطای ادغام. (همین باعث نتیجهٔ گمراه‌کنندهٔ تست rate-limit روی send-code در دور اول شد.) - **پیشنهاد رفع:** یکنواخت‌سازی روی یک نام یا مستندسازی صریح per-endpoint در `docs/api/`. ### [F8] نبود Rate-Limiting روی `POST /api/v1/user/login` (Brute-force) - **شدت:** High · **ناحیه:** Security - **مراحل بازتولید:** ```bash for i in $(seq 1 15); do curl -sk -o /dev/null -w "%{http_code} " -X POST ".../api/v1/user/login" \ -H 'Content-Type: application/json' \ -d '{"mobile_number":"09100000001","password":"WRONG"}' done ``` - **نتیجهٔ واقعی:** `401 401 401 401 401 401 401 401 401 401 401 401 401 401 401` — ۱۵ تلاش پیاپی رمز غلط، هیچ `429` و هیچ lockout. - **نتیجهٔ مورد انتظار:** طبق `docs/api/auth.md` مکانیزم `ERR_RATE_LIMIT_001` (۱۰ تلاش/۱۵دقیقه per-IP) وجود دارد اما فقط روی OTP `send-code` اعمال شده، نه login با رمز. - **اثر:** حملهٔ brute-force روی رمز عبور کاربران عملی است. - **پیشنهاد رفع:** اعمال rate-limiter (همان الگوی موجود OTP) روی `user/login` per-IP + per-account، و ترجیحاً backoff/lockout موقت. ### [F9] ناسازگاری در `public_endpoints`: `cities`/`provinces` نیاز به auth، `specialties` عمومی - **شدت:** Low · **ناحیه:** Backend / Auth · **cross-repo محتمل** - **مراحل بازتولید و نتیجهٔ واقعی:** ``` GET /api/v1/specialties → 200 (عمومی، 93 آیتم) GET /api/v1/cities → 401 ERR_AUTH_001 «احراز هویت لازم است» GET /api/v1/provinces → 401 ERR_AUTH_001 ``` در `security.yaml`→`public_endpoints`، `api/v1/specialties` هست ولی `cities`/`provinces` نیست. - **نتیجهٔ مورد انتظار:** یا هر سه عمومی باشند (دادهٔ مرجع)، یا رفتار مستند و عمدی باشد. عدم‌تقارن فعلی احتمالاً سهوی است. - **نکته cross-repo:** `nobat724_front` شهر/استان را از `data/city.json` محلی می‌خواند نه این API، پس شکست مستقیم سایت بعید است؛ اما هر کلاینتی که این endpoint را عمومی فرض کند `401` می‌گیرد. تأیید نیت لازم است. - **پیشنهاد رفع:** افزودن `api/v1/cities`/`api/v1/provinces` به regex `public_endpoints` یا اصلاح مستندات. ### [F10] راهنمای کهنه در `CLAUDE.md`: endpoint `categorys/{bundle}` منتقل شده - **شدت:** Low · **ناحیه:** Docs - **نتیجهٔ واقعی:** `GET /api/v1/categorys/city` → `success:false, code "ERR_MOVED"` («این endpoint منتقل شده...»). این رفتار عمدی است، اما `clinicpro/CLAUDE.md` هنوز الگوی «Category API: `data?.data?.data ?? []`» و typo عمدی `categorys` را به‌عنوان endpoint فعال توصیه می‌کند. - **پیشنهاد رفع:** به‌روزرسانی `CLAUDE.md` به endpointهای جایگزین (`/api/v1/cities`، `/api/v1/specialties`، `/api/v1/provinces`، `/api/v1/tags`). ### [F11] داشبورد دکتر `GET /api/v1/dashboard/doctor` همیشه 500 (فیلد ناموجود در DQL) — ✅ رفع شد - **شدت:** High (functional-critical) · **ناحیه:** Backend - **مسیر:** `src/Dashboard/Controller/DashboardController.php:200` - **مراحل بازتولید:** با هر توکن `ROLE_DOCTOR`: `GET /api/v1/dashboard/doctor` → `500 ERR_INTERNAL_001`. - **ریشهٔ واقعی (ایزوله‌شده با اسکریپت تشخیصی):** ``` Doctrine QueryException [Semantical Error]: Class App\Rating\Entity\Rate has no field or association named score ``` کوئری `SELECT AVG(r.score) ... FROM Rate r` به فیلد `score` اشاره می‌کرد که در entity `Rate` **وجود ندارد**؛ این entity ۵ زیرمعیار دارد (`waitingTimeAtClinic`, `accuracyOfDiagnosis`, `doctorBehavior`, `clinicCleanliness`, `doctorExpertise`) و هیچ فیلد تجمیعی `score` ندارد. یعنی این endpoint برای **هر** دکتر (فارغ از داشتن داده) همیشه می‌شکست. - **اثر:** داشبورد اصلی پنل دکتر کاملاً از کار افتاده بود. تنها مصرف `r.score` در کل `src/` همین‌جا بود (بقیهٔ کد از per-dimension AVG در `RateRepository` استفاده می‌کند). - **رفع انجام‌شده:** جایگزینی با میانگین ۵ بُعد: ```sql AVG((r.waitingTimeAtClinic + r.accuracyOfDiagnosis + r.doctorBehavior + r.clinicCleanliness + r.doctorExpertise) / 5.0) AS avg_score ``` بازآزمون زنده: `GET /api/v1/dashboard/doctor` → `200` با `avg_rating`/`total_ratings`. phpstan/phpunit سبز. - **پیشنهاد تکمیلی:** افزودن تست رگرسیون در `tests/` (کامپایل‌شدن DQL داشبورد) تا این نوع خطای semantical دوباره برنگردد. --- ## نتایج مثبت تأییدشده (کار می‌کنند) - **Auth پایه:** بدون توکن و توکن نامعتبر روی endpoint محافظت‌شده → `401`؛ ادمین → `200`؛ endpointهای عمومی (`/doctors`, `/specialties`) → `200`. ✔ - **Envelope:** paginated دقیقاً `{success, data:[], meta:{totalRecords,totalPages,currentPage}}` تخت (بدون double-nest، بدون کلید errors اضافی در پاسخ موفق). ✔ - **Validation:** بدنهٔ خالی → `422`؛ JSON خراب → `422` (نه `500`). ✔ - **SQL Injection:** `admin/doctors?search=' OR '1'='1` → `200` امن (بدون `500`) — تأیید ایمنی DQL param binding. ✔ - **Payment callback جعلی:** `POST /api/v1/payment/callback/mellat` بدون امضای معتبر → `403` رد شد. ✔ - **SPA static health:** tsc/vitest/build همه سبز. ✔ - **RBAC (تأیید زنده با توکن `ROLE_DOCTOR`):** همهٔ endpointهای admin برای دکتر `403` — `admin/users, admin/doctors, admin/clinics, admin/payments, admin/settings, admin/settlements, admin/logs`، همچنین `POST admin/doctors` و `DELETE admin/users/{uuid}` → `403`. ✔ سالم. - **IDOR (دکتر A روی منابع دکتر B):** `DELETE /api/v1/doctor/{docB}` → `403`؛ `GET /api/v1/appointment-settings/weekly-schedule/{docB}` → `404`. دسترسی افقی مسدود. ✔ - **Mass-assignment (create doctor):** ارسال `roles:["ROLE_ADMIN"]`, `id:999999`, `balance_rials` در بدنه → نادیده گرفته شد؛ کاربر ساخته‌شده فقط `["ROLE_USER","ROLE_DOCTOR"]` گرفت. ✔ امن (whitelist صریح فیلدها + نقش اجباری). - **دکتر own-scope:** `GET /api/v1/doctor/invitations` و `GET /api/v1/my/appointments` → `200`. ✔ --- ## حل‌شده در دور دوم (قبلاً مسدود بودند) | مورد | نتیجه | |------|-------| | RBAC بین‌نقشی (دکتر→admin `403`) | ✅ اجرا شد — همه `403` (سالم) | | IDOR افقی (دکتر A → منبع دکتر B) | ✅ اجرا شد — `403/404` (سالم) | | Mass-assignment (تزریق `roles`/`id`/`balance`) | ✅ اجرا شد — نادیده گرفته شد (امن) | | داشبورد دکتر | ✅ اجرا شد — باگ F11 کشف و رفع | ## هنوز نیازمند بررسی (نه تأیید سلامت — محدودیت داده/محیط) | مورد | چرا اجرا نشد | |------|--------------| | نقش‌های `ROLE_CLINIC` / `ROLE_SECRETARY` (RBAC + داشبورد) | فقط دکتر seed شد؛ کلینیک/منشی ساخته نشد. داشبورد کلینیک/منشی از نظر static فاقد باگ `r.score` است اما زنده تست نشد | | XSS ذخیره‌شده (بلاگ/کامنت/نام) | ذخیرهٔ `