Files
clinicpro/docs/qa/full-system-audit-report.md
T

18 KiB
Raw Blame History

گزارش ممیزی کامل 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
  • مراحل بازتولید:
    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.mdERR_AUTH_005 چون اصلاً در جدول users نیستند.
  • اثر: تست‌های RBAC بین‌نقشی، IDOR، و کل فاز E2E مسدود شد.
  • پیشنهاد رفع: بازگرداندن/ساخت seeder (رجوع به F4) و اجرای آن قبل از QA.

[F4] اسکریپت seeder create_test_users.php وجود ندارد

  • شدت: Medium · ناحیه: Docs / Tooling
  • مراحل بازتولید: ddev exec php create_test_users.phpCould 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.yamlaccess_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
  • مراحل بازتولید:
    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.yamlpublic_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/citysuccess: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/doctor500 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 استفاده می‌کند).
  • رفع انجام‌شده: جایگزینی با میانگین ۵ بُعد:
    AVG((r.waitingTimeAtClinic + r.accuracyOfDiagnosis + r.doctorBehavior
         + r.clinicCleanliness + r.doctorExpertise) / 5.0) AS avg_score
    
    بازآزمون زنده: GET /api/v1/dashboard/doctor200 با 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'='1200 امن (بدون 500) — تأیید ایمنی DQL param binding. ✔
  • Payment callback جعلی: POST /api/v1/payment/callback/mellat بدون امضای معتبر → 403 رد شد. ✔
  • SPA static health: tsc/vitest/build همه سبز. ✔
  • RBAC (تأیید زنده با توکن ROLE_DOCTOR): همهٔ endpointهای admin برای دکتر 403admin/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/appointments200. ✔

حل‌شده در دور دوم (قبلاً مسدود بودند)

مورد نتیجه
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"])')