18 KiB
گزارش ممیزی کامل 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 تزریق شود (SymfonyMockHttpClient)، یا provider پشت interface در محیط test با fake جایگزین شود.
[F3] دیتابیس تست seed نشده — فقط کاربر ادمین وجود دارد
- شدت: High · ناحیه: Test data / Integration
- مراحل بازتولید:
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):
فقط دادههای مرجع (cities/specialties از
users=2 (هر دو ADMIN: 09100000001 + 09120671756) · doctors=0 · clinics=0 appointments=0 · blogs=0 · representations=0 · cities=33 · specialties=93app: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. احتمالاً firewallapi(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
- مراحل بازتولید:
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) وجود دارد اما فقط روی OTPsend-codeاعمال شده، نه login با رمز. - اثر: حملهٔ brute-force روی رمز عبور کاربران عملی است.
- پیشنهاد رفع: اعمال rate-limiter (همان الگوی موجود OTP) روی
user/loginper-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_001security.yaml→public_endpoints،api/v1/specialtiesهست ولیcities/provincesنیست. - نتیجهٔ مورد انتظار: یا هر سه عمومی باشند (دادهٔ مرجع)، یا رفتار مستند و عمدی باشد. عدمتقارن فعلی احتمالاً سهوی است.
- نکته cross-repo:
nobat724_frontشهر/استان را ازdata/city.jsonمحلی میخواند نه این API، پس شکست مستقیم سایت بعید است؛ اما هر کلاینتی که این endpoint را عمومی فرض کند401میگیرد. تأیید نیت لازم است. - پیشنهاد رفع: افزودن
api/v1/cities/api/v1/provincesبه regexpublic_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 scoreSELECT AVG(r.score) ... FROM Rate rبه فیلدscoreاشاره میکرد که در entityRateوجود ندارد؛ این entity ۵ زیرمعیار دارد (waitingTimeAtClinic,accuracyOfDiagnosis,doctorBehavior,clinicCleanliness,doctorExpertise) و هیچ فیلد تجمیعیscoreندارد. یعنی این endpoint برای هر دکتر (فارغ از داشتن داده) همیشه میشکست. - اثر: داشبورد اصلی پنل دکتر کاملاً از کار افتاده بود. تنها مصرف
r.scoreدر کلsrc/همینجا بود (بقیهٔ کد از per-dimension AVG درRateRepositoryاستفاده میکند). - رفع انجامشده: جایگزینی با میانگین ۵ بُعد:
بازآزمون زنده:
AVG((r.waitingTimeAtClinic + r.accuracyOfDiagnosis + r.doctorBehavior + r.clinicCleanliness + r.doctorExpertise) / 5.0) AS avg_scoreGET /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 ذخیرهشده (بلاگ/کامنت/نام) | ذخیرهٔ <script> در نام دکتر ممکن است اما بازتاب خام API بررسی نشد؛ escape سمت React فرض شده |
Rate-limit روی send-code |
با فیلد درست mobile باید مجدد با ۱۲+ تلاش سنجیده شود (در دور اول بهخاطر F7 اشتباه اندازهگیری شد) |
| فاز ۶ E2E کامل (نوبت/دعوت/پرداخت/double-booking) | نیازمند دکتر با weekly-schedule + بیمار + کلینیک؛ seed کامل انجام نشد |
| رفتار runtime پنل (RBAC UI، offline، refresh JWT) | نیازمند مرورگر + session نقشهای غیرادمین |
دادههای تستِ ساختهشده در این ممیزی: دو کاربر دکتر 09120000001 / 09120000002 (رمز Doc@1234, uuid fbc11068… / 56987348…) via admin API ساخته شدند و برای تست باقی میمانند. password_hash آنها دستی ست شد.
گام بعدی پیشنهادی: ساخت seeder رسمی (F4) برای کلینیک/منشی/بیمار/نوبت → اجرای فاز E2E کامل + rate-limit روی login (F8، بحرانیترین باز).
دستورات بازتولید (خلاصه)
# baseline
ddev exec php bin/phpunit
ddev exec yarn test
ddev exec php vendor/bin/phpstan analyse
ddev exec npx tsc --noEmit --project tsconfig.json
# توکن ادمین
T=$(curl -sk -X POST https://clinic-pro.ddev.site/api/v1/user/login \
-H 'Content-Type: application/json' \
-d '{"mobile_number":"09100000001","password":"Test@1234"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')