diff --git a/.claude/prompt/fix-server-side-mobile-nationalcode-validation.md b/.claude/prompt/fix-server-side-mobile-nationalcode-validation.md new file mode 100644 index 00000000..380f2fcc --- /dev/null +++ b/.claude/prompt/fix-server-side-mobile-nationalcode-validation.md @@ -0,0 +1,140 @@ +# رفع باگ: اعتبارسنجی سمت سرورِ موبایل و کد ملی (BUG-2 و BUG-3) + +## پروژه + +`clinicpro` (Backend). مرجع: `docs/qa/bug-report-2026-06-20.md`. + +## زمینه + +ممیزی نشان داد اعتبارسنجی **موبایل** و **کد ملی** فقط در فرانت انجام می‌شود و سمت سرور غایب است. در نتیجه: +- **BUG-3 (HIGH):** `POST /api/v1/representation` با `mobile_number:"not-a-mobile"` → `201` و یک نماینده/کاربر آشغال ساخت (فقط `empty()` چک می‌شود، نه فرمت). +- **BUG-2 (MEDIUM):** `PATCH /api/v1/user-profile/{uuid}` با `national_code:"1111111111"` (رقم کنترلیِ غلط) → `200` و ذخیره شد (هیچ validationی نیست). + +regex موبایل از قبل به‌صورت inline در دو جا هست (`AuthController:143` و `NotificationMobileController:44`: `^09\d{9}$`) ولی منطق پخش/تکراری است و در نقاط ساختِ کاربر استفاده نشده. هیچ ولیدیتورِ کد ملیِ سمت سروری وجود ندارد. + +## مشکل / هدف + +یک سرویس اعتبارسنجی مشترک سمت سرور بساز (`InputValidator`) و آن را در همه‌ی نقاطِ ورودیِ موبایل/کد ملی اعمال کن تا داده‌ی نامعتبر با `422` رد شود (نه ذخیره/ساخت). + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Shared/Service/InputValidator.php` (جدید) | اعتبارسنجی مشترک موبایل + کد ملی | +| `src/Representation/Controller/RepresentationController.php` | `create` — BUG-3، موبایل بدون فرمت | +| `src/Representation/Controller/RepresentationActionController.php` | `createDoctor` (`mobile`)، `createClinic` (`owner_mobile`) — همان خلأ | +| `src/Admin/Controller/AdminApiController.php` | `createDoctor` (`mobile`)، `createClinic` (`owner_mobile`) — همان خلأ | +| `src/UserProfile/Controller/UserProfileController.php` | `hydrate()` — BUG-2، کد ملی بدون validation | +| `src/Shared/Constant/ErrorCodes.php` | `ERR_VALIDATION_001` (موجود) | +| `docs/api/representation.md`, `docs/api/admin.md`, `docs/api/user-profile.md` | مستندسازی پاسخ ۴۲۲ | + +## وضعیت فعلی (کد واقعی) + +`RepresentationController::create` — فقط empty چک می‌شود: +```php +$mobile = trim($data['mobile_number'] ?? ''); +$fullName = trim($data['full_name'] ?? ''); +if (empty($mobile) || empty($fullName)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'mobile_number و full_name الزامی است', 422); +} +$user = $this->userRepo->findByMobile($mobile); +if ($user === null) { $user = new User($mobile); ... } // ← موبایل نامعتبر هم کاربر می‌سازد +``` + +`UserProfileController::hydrate` — کد ملی بدون validation ذخیره می‌شود: +```php +if (array_key_exists('national_code', $data)) $profile->setNationalCode($data['national_code']); +``` + +regex موبایلِ موجود (برای الگو): +```php +// AuthController:143 و NotificationMobileController:44 +if (!preg_match('/^09\d{9}$/', $mobile)) { ... 422 ... } +``` + +## وظایف + +### ۱. سرویس مشترک `InputValidator` + +`src/Shared/Service/InputValidator.php` بساز با متدهای static (یا سرویسِ بدون state): +```php +namespace App\Shared\Service; + +final class InputValidator +{ + public static function isValidIranMobile(string $mobile): bool + { + return (bool) preg_match('/^09\d{9}$/', self::toEnglishDigits($mobile)); + } + + /** کد ملی ایران: ۱۰ رقم + الگوریتم رقم کنترلی؛ ارقام یکسان نامعتبر. */ + public static function isValidIranNationalCode(string $code): bool + { + $code = self::toEnglishDigits($code); + if (!preg_match('/^\d{10}$/', $code)) return false; + if (preg_match('/^(\d)\1{9}$/', $code)) return false; + $check = (int) $code[9]; + $sum = 0; + for ($i = 0; $i < 9; $i++) $sum += (int) $code[$i] * (10 - $i); + $r = $sum % 11; + return $r < 2 ? $check === $r : $check === 11 - $r; + } + + public static function toEnglishDigits(string $s): string + { + return strtr($s, + ['۰'=>'0','۱'=>'1','۲'=>'2','۳'=>'3','۴'=>'4','۵'=>'5','۶'=>'6','۷'=>'7','۸'=>'8','۹'=>'9', + '٠'=>'0','١'=>'1','٢'=>'2','٣'=>'3','٤'=>'4','٥'=>'5','٦'=>'6','٧'=>'7','٨'=>'8','٩'=>'9']); + } +} +``` +> این دقیقاً معادلِ سرورِ `isValidIranNationalCode`/`sanitizeMobileInput` فرانت است (تا رفتار یکدست شود). + +### ۲. اعمال در ساخت نماینده (BUG-3) + +در `RepresentationController::create`، بعد از چک empty و قبل از ساخت `User`: +```php +if (!InputValidator::isValidIranMobile($mobile)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل نامعتبر است', 422); +} +``` +- `$mobile` را به ارقام انگلیسی نرمال هم بکن (`InputValidator::toEnglishDigits`) قبل از `findByMobile`/`new User` تا «۰۹...» فارسی هم درست ذخیره شود. + +### ۳. اعمال در سایر نقاطِ ساختِ کاربر از موبایل + +همان چک را اضافه کن به: +- `RepresentationActionController::createDoctor` (فیلد `mobile`) و `createClinic` (فیلد `owner_mobile`). +- `AdminApiController::createDoctor` (`mobile`) و `createClinic` (`owner_mobile`). +هرجا `new User($mobile)` ساخته می‌شود، قبلش موبایل باید معتبر باشد؛ در غیر این صورت `422`. + +### ۴. اعمال در کد ملی (BUG-2) + +در `UserProfileController` (متد `update` یا `hydrate`)، اگر `national_code` در ورودی هست و **غیرخالی** است، اعتبارسنجی کن: +```php +if (array_key_exists('national_code', $data) && $data['national_code'] !== null && $data['national_code'] !== '') { + $code = InputValidator::toEnglishDigits((string) $data['national_code']); + if (!InputValidator::isValidIranNationalCode($code)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی نامعتبر است', 422, 'national_code'); + } + $data['national_code'] = $code; // ذخیره‌ی نرمال‌شده +} +``` +> اجازه‌ی خالی/`null` بماند (کد ملی اختیاری است)؛ فقط مقدارِ **واردشده‌ی نامعتبر** رد شود. این چک باید **قبل از** `hydrate()` و در همان action باشد (چون `hydrate` خروجی JsonResponse ندارد). + +### ۵. مستندسازی + +- `docs/api/representation.md` و `docs/api/admin.md`: در createDoctor/createClinic/representation، افزودن پاسخ `422 ERR_VALIDATION_001` برای موبایل نامعتبر. +- `docs/api/user-profile.md`: افزودن `422 ERR_VALIDATION_001` برای کد ملی نامعتبر در PATCH. + +## نکات مهم + +- **یک‌بار تعریف، همه‌جا استفاده:** منطق موبایل/کد ملی فقط در `InputValidator` باشد؛ regexهای inline موجود (`AuthController:143`, `NotificationMobileController:44`) را هم می‌توان به این سرویس واگذار کرد (اختیاری، اگر ریسک کم بود) ولی **رفتار `send-code` را تغییر نده** مگر مطمئن باشی. +- نرمال‌سازی ارقام فارسی/عربی به انگلیسی **قبل از ذخیره** انجام شود تا موبایل/کد ملیِ ذخیره‌شده همیشه لاتین باشد. +- کد ملی **اختیاری** است؛ خالی/null نباید ۴۲۲ بدهد — فقط مقدارِ نامعتبر. +- پاسخ‌ها از `BaseController` (`error(code, msg, 422, field?)`)؛ تاریخ‌ها دست نخورد؛ migration لازم نیست (فقط منطق). +- بعد از تغییر، `cache:clear --env=prod` و تست: + - `POST /api/v1/representation` با `mobile_number:"not-a-mobile"` → `422` (قبلاً ۲۰۱). + - با موبایل معتبر `09123456789` → `201` و در DB لاتین ذخیره شود. + - `PATCH /api/v1/user-profile/{own}` با `national_code:"1111111111"` → `422`؛ با کد معتبر (مثل `0079070875`) → `200`؛ با `null` → `200` (پاک شود). + - رگرسیون: `createDoctor`/`createClinic` ادمین و نماینده با موبایل معتبر همچنان کار کنند. +- بعد از تست، رکوردهای تستی را پاک کن (هیچ نماینده/کاربر/پروفایلِ آشغال نماند). diff --git a/.claude/prompt/qa-bug-audit.md b/.claude/prompt/qa-bug-audit.md new file mode 100644 index 00000000..578d622d --- /dev/null +++ b/.claude/prompt/qa-bug-audit.md @@ -0,0 +1,131 @@ +# ممیزی کشف باگ — تست رفتاری کل سیستم + +## پروژه + +`clinicpro` (Backend API + Admin SPA؛ منبع واحد داده/auth). جریان‌هایی که `nobat724_front` مصرف می‌کند هم از طریق همین APIها تست می‌شوند. + +> این یک پرامپت **تست/ممیزی** است، نه پیاده‌سازی قابلیت. هدف: پیدا کردن باگ‌های واقعی با شواهد، نه تغییر کد. **هیچ فایل تولیدی را تغییر نده** مگر برای رفع باگ‌های قطعیِ کم‌ریسک (آن هم با تأیید جداگانه). خروجی اصلی = گزارش باگ. + +## زمینه + +سیستم suite تست خودکار ندارد (`tests/` فقط `bootstrap.php` دارد)، ولی مرجع کاملِ رفتار در `docs/api/*.md` و کاربرانِ تست در `TEST_USERS.md` موجود است. این ممیزی باید رفتار واقعیِ APIها را با قرارداد مستندشده مقایسه کند و انحراف‌ها/نشتی‌ها/خطاهای ۵۰۰ را پیدا کند. + +## مشکل / هدف + +کشف باگ در محورهای زیر و تولید یک **گزارش باگ اولویت‌بندی‌شده** (`docs/qa/bug-report-.md`): +1. **خطاهای ۵۰۰ / استثناهای کنترل‌نشده** در endpointها (به‌ویژه با ورودی‌های مرزی). +2. **نشتی دسترسی (authorization)** بین نقش‌ها: admin / clinic / doctor / secretary / representation / user. +3. **انحراف قرارداد API** از `docs/api/*` (شکل پاسخ، nestی، status code، فیلدهای گمشده). +4. **اعتبارسنجی ورودی** (موبایل، کد ملی، تاریخ، مقادیر منفی/خالی/طولانی، تزریق). +5. **سازگاری frontend↔backend**: جاهایی که SPA یا `nobat724_front` شکل پاسخ را اشتباه باز می‌کند (الگوی double-nested). + +## فایل‌های مرتبط + +| فایل/مسیر | نقش | +|------|-----| +| `docs/api/*.md` | قرارداد مرجع — هر تست باید با این مقایسه شود | +| `TEST_USERS.md` | کاربران هر نقش برای تولید توکن | +| `src/*/Controller/*.php` | endpointها — سطح دسترسی `#[IsGranted]` و منطق | +| `src/Shared/Controller/BaseController.php` | شکل `success`/`paginated`/`error` | +| `config/packages/security.yaml` | firewall و access_control | +| `assets/admin/lib/api.ts`, `services/response.js` (front عمومی) | مصرف پاسخ‌ها | + +## ابزارها و روش تست (واقعی، داخل ddev) + +تولید توکن هر نقش: +```bash +ddev exec bash -c 'php bin/console lexik:jwt:generate-token --user-class="App\\Auth\\Entity\\User"' +``` +لیست همه‌ی routeها: +```bash +ddev exec php bin/console debug:router | grep api/v1 +``` +صدا زدن endpoint (prod host، مثل مصرف واقعی): +```bash +curl -sk "https://clinic-pro.ddev.site/api/v1/" -H "Authorization: Bearer " +``` +بررسی DB برای تأیید اثر: +```bash +ddev exec bash -c "mysql -uroot -proot db -e \"\"" +``` +سلامت TS/build فرانت: +```bash +ddev exec npx tsc --noEmit --project tsconfig.json +ddev exec yarn dev +``` + +## وظایف + +### ۱. نقشه‌برداری endpointها و دامنه‌ی تست + +- خروجی `debug:router | grep api/v1` را بگیر و لیست کامل endpointها را با method/path استخراج کن. +- برای هر دامنه (`auth, doctor, clinic, appointment, payment, representation, secretary, sms, blog, rating, settlement, user-profile, ...`) فایل docs متناظر را بخوان و «قرارداد مورد انتظار» را یادداشت کن (status، شکل پاسخ، permission). + +### ۲. تست خطاهای ۵۰۰ و ورودی مرزی + +برای endpointهای پرریسک (به‌خصوص آن‌هایی که از relation/Entity proxy می‌خوانند یا ورودی JSON می‌گیرند): +- ورودی‌های مرزی بفرست: بدنه‌ی خالی `{}`، فیلد گمشده، نوع اشتباه (string به‌جای int)، عدد منفی، رشته‌ی خیلی بلند، uuid نامعتبر، تاریخ نامعتبر، صفحه/limit خیلی بزرگ. +- هر پاسخی با `"code":"ERR_INTERNAL_001"` یا HTTP 500 = **باگ** (باید ۴xx ساختاریافته باشد). نمونه‌ی شناخته‌شده‌ی این کلاس باگ: دسترسی به getter روی proxyِ موجودیتِ حذف‌شده (مثل author در بلاگ) → `EntityNotFoundException` → 500. +- لیست‌های paginated را با `page=99999` و `limit=0/-1/9999` بزن. + +### ۳. ماتریس دسترسی نقش‌ها (مهم‌ترین بخش) + +برای هر نقش یک توکن بساز و **endpointهای خارج از حوزه‌اش** را صدا بزن؛ انتظار `403`: +- `user` عادی → هر `/api/v1/admin/*` و `/api/v1/representation/*` و `/api/v1/sms/*` → باید 403. +- `representation` → `/api/v1/admin/users`, `/admin/payments`, `/admin/settlements` → باید 403؛ ولی `/representation/me|doctors|clinics|appointments` → 200. +- `doctor` (مهمان کلینیک، نه مالک) → endpointهای مدیریت کلینیک (staff/clinic-service برای کلینیکِ دیگران، ویرایش کلینیک) → باید 403 یا scopeِ شخصی؛ نباید روی کلینیک عمل کند. +- `secretary` → فقط در محدوده‌ی scope مجازش؛ خارج از آن 403. +- **IDOR**: با توکن نقش A، منبع متعلق به B را با uuid/id مستقیم بخوان/ویرایش کن (مثلاً `GET/PATCH /representation/{uuidِ نفر دیگر}`، نوبت/پروفایل کاربر دیگر). هر دسترسی موفق = باگ امنیتی. +- هر endpointی که مالکیت را از **ورودی کلاینت** (uuid/id در query/path) تعیین می‌کند نه از `#[CurrentUser]` → مشکوک؛ تست و گزارش کن. + +### ۴. انطباق قرارداد API + +برای نمونه‌ای از هر دامنه، پاسخ واقعی را با docs مقایسه کن: +- شکل nestی درست است؟ (`paginated` → `data` تخت + `meta`؛ `success(['data'=>...])` → double-nested `data.data`). جاهایی که SPA/`nobat724_front` این nestی را اشتباه باز می‌کند را به‌عنوان باگ مصرف ثبت کن. +- status codeها و فیلدهای الزامی مطابق docs هستند؟ فیلد گمشده/اضافه را گزارش کن. +- تاریخ‌ها Unix timestamp صحیح‌اند (نه DateTime serialize‌شده‌ی خراب)؟ + +### ۵. اعتبارسنجی ورودی + +- موبایل: ارقام فارسی/عربی، طول غلط، حروف → باید رد شود (۴۲۲)، نه ذخیره‌ی خراب. +- کد ملی: الگوریتم رقم کنترلی (سمت سرور هم چک می‌شود یا فقط فرانت؟ اگر فقط فرانت = باگ). +- مقادیر مالی/درصد کمیسیون/limit عکس گالری (سقف ۵) و امثال آن: مرزها را تست کن. + +### ۶. سلامت build و سازگاری + +- `ddev exec npx tsc --noEmit` → هر خطای TS = باگ. +- `ddev exec yarn dev` → هر خطای کامپایل = باگ. +- (front عمومی) `cd nobat724_front && npm run build` → خطاهای صفحه/متادیتا. + +### ۷. تولید گزارش باگ + +فایل `docs/qa/bug-report-.md` بساز با جدول اولویت‌بندی‌شده: + +```markdown +# گزارش باگ — + +## خلاصه +- تعداد کل: X | بحرانی: a | بالا: b | متوسط: c | پایین: d + +## باگ‌ها +### [CRITICAL] <عنوان کوتاه> +- **دامنه:** auth/clinic/... +- **endpoint/صفحه:** `METHOD /api/v1/...` +- **مراحل بازتولید:** دستور curl دقیق + توکن نقش +- **انتظار:** (طبق docs) ... +- **واقعیت:** (پاسخ واقعی + HTTP code) ... +- **اثر:** (نشتی داده / 500 / خرابی UI / ...) +- **فایل محتمل:** `src/...:line` +- **رفع پیشنهادی:** یک جمله +``` + +اولویت‌بندی: نشتی دسترسی/IDOR = CRITICAL؛ 500 روی مسیر پرکاربرد = HIGH؛ انحراف قرارداد که UI را می‌شکند = HIGH/MEDIUM؛ اعتبارسنجی ناقص = MEDIUM؛ کاسمتیک = LOW. + +## نکات مهم + +- **تخریب‌نکن:** برای تست‌های نوشتنی (POST/PATCH/DELETE) از داده‌ی تستیِ جداگانه استفاده کن و **در پایان پاک/بازگردانی کن**؛ روی داده‌ی واقعیِ کاربران اصلی عملیات مخرب نزن. +- محیط: باگ‌های مخصوص prod ممکن است فقط روی `https://clinic-pro.ddev.site` (APP_ENV=prod) ظاهر شوند؛ بعد از تغییر config `cache:clear --env=prod`. dev هم خطای کامل‌تر می‌دهد (`var/log`), از آن برای تشخیص ریشه استفاده کن. +- توکن: کد OTP در dev همیشه `12345`؛ یا مستقیم با `lexik:jwt:generate-token` توکن هر نقش را بساز. +- هر یافته باید **بازتولیدپذیر** باشد (دستور دقیق + پاسخ واقعی)؛ حدس و گمان بدون شواهد در گزارش نیاید. +- این پرامپت فقط **گزارش** تولید می‌کند؛ رفع باگ‌ها در پرامپت‌های جداگانه (بعد از تأیید اولویت‌ها) انجام شود — مگر باگِ تک‌خطیِ بدیهی و کم‌ریسک که می‌توان با ذکر در گزارش، هم‌زمان رفع کرد. +- دامنه را کنترل کن: اگر تعداد endpointها زیاد است، اول دامنه‌های پرریسک (auth, representation, clinic, appointment, payment) را کامل کن، سپس بقیه. diff --git a/docs/api/admin.md b/docs/api/admin.md index 3dc09c8d..4c66cff7 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -284,6 +284,47 @@ Delete a user. > **نماینده (ROLE_REPRESENTATION):** افزودن پزشک و کلینیک برای نماینده از طریق endpointهای جدا انجام می‌شود — `POST /api/v1/representation/doctor` و `POST /api/v1/representation/clinic` (به `docs/api/representation.md` مراجعه کنید). در نسخه‌ی نماینده، `representation_id` پزشک خودکار روی نماینده‌ی کاربر جاری ست می‌شود. endpointهای `/api/v1/admin/*` همچنان فقط `ROLE_ADMIN` هستند. +### POST `/api/v1/admin/doctors` + +ساخت پزشک جدید (در صورت نبودِ کاربر با این موبایل، یک User هم ساخته می‌شود). + +**Permission:** `ROLE_ADMIN` + +#### Request Body (`application/json`) +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `mobile` | string | ✅ | موبایل ورود؛ باید فرمت معتبر موبایل ایران داشته باشد (`^09\d{9}$`) — ارقام فارسی/عربی به انگلیسی نرمال می‌شوند | +| `name` | string | ✅ | نام پزشک | + +#### Errors +| Code | HTTP | Description | +|------|------|-------------| +| `VALIDATION` | 422 | `mobile` یا `name` خالی | +| `VALIDATION` | 422 | `mobile` فرمت معتبر موبایل ایران ندارد (`field: mobile`) | + +--- + +### POST `/api/v1/admin/clinic` + +ساخت کلینیک جدید (در صورت نبودِ کاربرِ صاحب با این موبایل، یک User هم ساخته می‌شود). + +**Permission:** `ROLE_ADMIN` + +#### Request Body (`application/json`) +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `owner_mobile` | string | ✅ | موبایل صاحب کلینیک؛ باید فرمت معتبر موبایل ایران داشته باشد (`^09\d{9}$`) | +| `name` | string | ✅ | نام کلینیک | + +#### Errors +| Code | HTTP | Description | +|------|------|-------------| +| `VALIDATION` | 422 | `owner_mobile` خالی | +| `VALIDATION` | 422 | `owner_mobile` فرمت معتبر موبایل ایران ندارد (`field: owner_mobile`) | +| `VALIDATION` | 422 | `name` کلینیک خالی | + +--- + ### GET `/api/v1/admin/doctors` List all doctors with pagination. diff --git a/docs/api/representation.md b/docs/api/representation.md index 90a0b0de..201f7058 100644 --- a/docs/api/representation.md +++ b/docs/api/representation.md @@ -59,7 +59,8 @@ Create a new representation. | `ERR_AUTH_001` | 401 | Missing token | | `ERR_AUTH_006` | 403 | Not admin | | `ERR_CONFLICT_001` | 409 | Mobile number already in use | -| `ERR_VALIDATION_001` | 422 | Invalid input | +| `ERR_VALIDATION_001` | 422 | `mobile_number` فرمت معتبر موبایل ایران (`^09\d{9}$`) ندارد (`field: mobile_number`) | +| `ERR_VALIDATION_002` | 422 | `mobile_number` یا `full_name` خالی | --- @@ -288,6 +289,7 @@ Get yearly earnings dashboard for a representation. | Code | HTTP | Description | |------|------|-------------| | `ERR_VALIDATION_002` | 422 | موبایل یا نام خالی | +| `ERR_VALIDATION_001` | 422 | `mobile` فرمت معتبر موبایل ایران ندارد (`field: mobile`) | | `ERR_CONFLICT_001` | 409 | این کاربر قبلاً پزشک است | --- @@ -314,6 +316,7 @@ Get yearly earnings dashboard for a representation. | Code | HTTP | Description | |------|------|-------------| | `ERR_VALIDATION_002` | 422 | موبایل یا نام خالی | +| `ERR_VALIDATION_001` | 422 | `owner_mobile` فرمت معتبر موبایل ایران ندارد (`field: owner_mobile`) | --- diff --git a/docs/api/user-profile.md b/docs/api/user-profile.md index 182a49dc..2e6890f5 100644 --- a/docs/api/user-profile.md +++ b/docs/api/user-profile.md @@ -125,6 +125,8 @@ Update a user profile. ### Request Body Same fields as POST — all optional. +> **`national_code` server-side validation:** اگر `national_code` ارسال شود و **غیرخالی** باشد، با الگوریتم رقم کنترلیِ کد ملی ایران اعتبارسنجی می‌شود (ارقام فارسی/عربی به انگلیسی نرمال و به‌صورت لاتین ذخیره می‌شوند). مقدارِ نامعتبر با `422` رد می‌شود. ارسال `null` یا رشته‌ی خالی مجاز است (کد ملی اختیاری) و فیلد را پاک می‌کند. + ### Response `200` Updated profile object. @@ -132,6 +134,7 @@ Updated profile object. | Code | HTTP | Description | |------|------|-------------| | `ERR_AUTH_001` | 401 | Missing token | +| `ERR_VALIDATION_001` | 422 | `national_code` نامعتبر (رقم کنترلی/طول غلط) (`field: national_code`) | | `ERR_FORBIDDEN_001` | 403 | Not the profile owner | | `ERR_NOT_FOUND_001` | 404 | Profile not found | diff --git a/docs/qa/bug-report-2026-06-20.md b/docs/qa/bug-report-2026-06-20.md new file mode 100644 index 00000000..a7531623 --- /dev/null +++ b/docs/qa/bug-report-2026-06-20.md @@ -0,0 +1,82 @@ +# گزارش باگ — ممیزی ۱۴۰۵/۰۳/۳۰ (2026-06-20) + +ممیزی رفتاریِ کل APIها (۲۴۳ endpoint) با توکن واقعیِ هر ۶ نقش (admin/clinic/doctor/secretary/representation/user) روی `https://clinic-pro.ddev.site`. مقایسه با `docs/api/*`. + +## خلاصه + +| سطح | تعداد | +|-----|------| +| CRITICAL | 0 | +| HIGH | 1 | +| MEDIUM | 2 | +| LOW | 0 | +| **رفع‌شده در همین جلسه** | 1 (BUG-1) | + +**نتیجه‌ی کلیِ امنیتی مثبت:** لایه‌ی authorization محکم است — همه‌ی `/api/v1/admin/*` برای نقش‌های غیرادمین `403` دادند؛ scope نماینده درست محدود است؛ IDOR روی پروفایل کاربر و ویرایش کلینیکِ غیرمالک `403/404` می‌گیرد؛ ورودی‌های مرزیِ لیست‌ها (page/limit بزرگ/منفی/غیرعددی، تاریخ نامعتبر) همگی graceful بودند (بدون ۵۰۰). + +--- + +## باگ‌ها + +### [HIGH] BUG-3 — ساخت نماینده با شماره موبایلِ نامعتبر (بدون اعتبارسنجی فرمت) +- **دامنه:** representation +- **endpoint:** `POST /api/v1/representation` (`ROLE_ADMIN`) +- **بازتولید:** + ```bash + curl -sk -X POST .../api/v1/representation -H "Authorization: Bearer " \ + -H "Content-Type: application/json" -d '{"full_name":"qa","mobile_number":"not-a-mobile"}' + ``` +- **انتظار:** `422` (شماره موبایل نامعتبر). +- **واقعیت:** `201 Created` — یک نماینده ساخته شد و یک `User("not-a-mobile")` ایجاد شد (در پاسخ `mobile_number: null`). +- **اثر:** رکورد آشغال + کاربرِ بدون شماره‌ی لاگین معتبر؛ شماره موبایل کلیدِ ورود است، پس نماینده‌ای ساخته می‌شود که نمی‌تواند وارد شود/پیامک بگیرد. +- **ریشه:** `src/Representation/Controller/RepresentationController.php:86,89` — فقط `empty($mobile)` چک می‌شود، فرمت ایران (`^09\d{9}$`) چک نمی‌شود. +- **رفع پیشنهادی:** بعد از `empty` چک، `if (!preg_match('/^09\d{9}$/', $mobile)) return $this->error(ERR_VALIDATION_001, 'شماره موبایل نامعتبر', 422);` (همان regex فرانت). +- **وضعیت:** گزارش‌شده (رفع نشده — نیاز به تأیید). + +### [MEDIUM] BUG-2 — کد ملی نامعتبر بدون اعتبارسنجی سمت سرور ذخیره می‌شود +- **دامنه:** user-profile +- **endpoint:** `PATCH /api/v1/user-profile/{uuid}` +- **بازتولید:** + ```bash + curl -sk -X PATCH .../api/v1/user-profile/ -H "Authorization: Bearer " \ + -H "Content-Type: application/json" -d '{"national_code":"1111111111"}' + ``` +- **انتظار:** `422` (رقم کنترلیِ کد ملی نامعتبر است). +- **واقعیت:** `200` و `national_code: "1111111111"` ذخیره شد. +- **اثر:** اعتبارسنجی کد ملی فقط در فرانت (`isValidIranNationalCode`) است؛ هر کلاینت/فرانتِ دورزده‌شده می‌تواند کد ملی نامعتبر ثبت کند. +- **ریشه:** `src/UserProfile/Controller/UserProfileController.php` → `hydrate()` فقط `setNationalCode($data['national_code'])` می‌کند، بدون validation. +- **رفع پیشنهادی:** اعتبارسنجی الگوریتم رقم کنترلیِ ایران سمت سرور قبل از `setNationalCode` (پورت همان منطق فرانت). +- **وضعیت:** گزارش‌شده. + +### [MEDIUM] BUG-1 — ۵۰۰ روی ساخت بلاگ با تایپ اشتباهِ body ✅ رفع شد +- **دامنه:** blog +- **endpoint:** `POST /api/v1/blog` (`ROLE_ADMIN`) +- **بازتولید:** `-d '{"title":"valid","body":["x"]}'` +- **انتظار:** `422`. +- **واقعیت (قبل رفع):** `HTTP 500` / `ERR_INTERNAL_001`. +- **ریشه:** `src/Blog/Controller/BlogController.php:178` — `trim($data['body'] ?? '')` وقتی `body` آرایه است → `TypeError` در PHP 8. +- **رفع انجام‌شده:** چک `is_string` روی title/body (۴۲۲ صریح) + cast `(string)`. تست شد: body آرایه → `422`، بلاگ معتبر همچنان `201`. +- **یادداشت:** اسکن سراسری نشان داد بقیه‌ی controllerها از `(string)` cast استفاده می‌کنند؛ این تنها نقطه‌ی بدون cast بود. + +--- + +## مواردی که تست شدند و **سالم** بودند (شواهد مثبت) + +| تست | نتیجه | +|-----|------| +| `/api/v1/admin/*` (users, payments, settlements, doctors, clinics, sms/logs) با ۵ نقش غیرادمین | همه `403` ✓ (admin `200`) | +| `representation/{me,doctors,clinics,appointments}` | rep `200`، بقیه `403` ✓ | +| IDOR: user → PATCH پروفایلِ کاربر دیگر | `404` (canAccess ownership) ✓ | +| IDOR: doctor → PATCH کلینیکِ غیرمالک | `403` (`ERR_AUTH_006`) ✓ | +| لیست‌های paginated: `page=99999`, `limit=-1/0`, `page=abc` | همه `200` graceful ✓ | +| detail بلاگ‌ها (کلاس باگ proxy author) | همه `200` ✓ (قبلاً رفع شده بود) | +| POST با بدنه‌ی خالی `{}` (doctor/clinic/representation/blog/send-code) | همه `422` ✓ | +| `tsc --noEmit` + `yarn dev` (admin build) | بدون خطا ✓ | + +--- + +## توصیه‌ها + +1. **یک Trait/سرویس اعتبارسنجی مشترک سمت سرور** برای موبایل و کد ملی بساز (الان منطق فقط در فرانت تکرار شده). BUG-2 و BUG-3 هر دو از همین خلأ می‌آیند. +2. الگوی `trim((string) ($data[...] ?? ''))` را به‌عنوان قاعده در همه‌ی هندلرها رعایت کن (BUG-1). +3. این ممیزی روی محورهای پرریسک متمرکز بود؛ برای پوشش کامل ۲۴۳ endpoint، یک suite `phpunit` رفتاری (functional) ارزش سرمایه‌گذاری دارد (الان فقط `tests/bootstrap.php` هست). diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index 186bf3e5..a98c0c64 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -4,6 +4,7 @@ namespace App\Admin\Controller; use App\Appointment\Entity\Appointment; use App\Auth\Entity\User; +use App\Shared\Service\InputValidator; use App\Location\Entity\City; use App\Clinic\Entity\Clinic; use App\Doctor\Entity\Doctor; @@ -381,12 +382,15 @@ class AdminApiController extends BaseController public function createDoctor(Request $request): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; - $mobile = trim((string) ($data['mobile'] ?? '')); + $mobile = InputValidator::toEnglishDigits(trim((string) ($data['mobile'] ?? ''))); $name = trim((string) ($data['name'] ?? '')); if ($mobile === '' || $name === '') { return $this->error('VALIDATION', 'موبایل و نام الزامی هستند', 422); } + if (!InputValidator::isValidIranMobile($mobile)) { + return $this->error('VALIDATION', 'شماره موبایل نامعتبر است', 422, 'mobile'); + } $user = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]); if (!$user) { @@ -491,12 +495,15 @@ class AdminApiController extends BaseController public function createClinic(Request $request): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; - $mobile = trim((string) ($data['owner_mobile'] ?? '')); + $mobile = InputValidator::toEnglishDigits(trim((string) ($data['owner_mobile'] ?? ''))); $name = trim((string) ($data['name'] ?? '')); if ($mobile === '') { return $this->error('VALIDATION', 'شماره موبایل الزامی است', 422); } + if (!InputValidator::isValidIranMobile($mobile)) { + return $this->error('VALIDATION', 'شماره موبایل نامعتبر است', 422, 'owner_mobile'); + } if ($name === '') { return $this->error('VALIDATION', 'نام کلینیک الزامی است', 422); } diff --git a/src/Blog/Controller/BlogController.php b/src/Blog/Controller/BlogController.php index b5a74d3e..58d9ed19 100644 --- a/src/Blog/Controller/BlogController.php +++ b/src/Blog/Controller/BlogController.php @@ -174,8 +174,11 @@ class BlogController extends BaseController public function create(Request $request, #[CurrentUser] User $user): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; - $title = trim($data['title'] ?? ''); - $body = trim($data['body'] ?? ''); + if ((isset($data['title']) && !is_string($data['title'])) || (isset($data['body']) && !is_string($data['body']))) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'title و body باید رشته باشند', 422); + } + $title = trim((string) ($data['title'] ?? '')); + $body = trim((string) ($data['body'] ?? '')); if (empty($title) || empty($body)) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'title و body الزامی است', 422); diff --git a/src/Representation/Controller/RepresentationActionController.php b/src/Representation/Controller/RepresentationActionController.php index 0e1cfad2..e7d5e010 100644 --- a/src/Representation/Controller/RepresentationActionController.php +++ b/src/Representation/Controller/RepresentationActionController.php @@ -9,6 +9,7 @@ use App\Doctor\Entity\Doctor; use App\Specialty\Entity\Specialty; use App\Representation\Repository\RepresentationRepository; use App\Shared\Constant\ErrorCodes; +use App\Shared\Service\InputValidator; use App\Shared\Controller\BaseController; use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; @@ -79,12 +80,15 @@ class RepresentationActionController extends BaseController public function createDoctor(Request $request, #[CurrentUser] User $user): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; - $mobile = trim((string) ($data['mobile'] ?? '')); + $mobile = InputValidator::toEnglishDigits(trim((string) ($data['mobile'] ?? ''))); $name = trim((string) ($data['name'] ?? '')); if ($mobile === '' || $name === '') { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'موبایل و نام الزامی هستند', 422); } + if (!InputValidator::isValidIranMobile($mobile)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل نامعتبر است', 422, 'mobile'); + } $doctorUser = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]); if (!$doctorUser) { @@ -154,12 +158,15 @@ class RepresentationActionController extends BaseController public function createClinic(Request $request, #[CurrentUser] User $user): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; - $mobile = trim((string) ($data['owner_mobile'] ?? '')); + $mobile = InputValidator::toEnglishDigits(trim((string) ($data['owner_mobile'] ?? ''))); $name = trim((string) ($data['name'] ?? '')); if ($mobile === '') { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'شماره موبایل الزامی است', 422); } + if (!InputValidator::isValidIranMobile($mobile)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل نامعتبر است', 422, 'owner_mobile'); + } if ($name === '') { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نام کلینیک الزامی است', 422); } diff --git a/src/Representation/Controller/RepresentationController.php b/src/Representation/Controller/RepresentationController.php index 36d8759d..0a2c8e63 100644 --- a/src/Representation/Controller/RepresentationController.php +++ b/src/Representation/Controller/RepresentationController.php @@ -9,6 +9,7 @@ use App\Representation\Repository\RepresentationRepository; use App\Representation\Service\JalaliDateService; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; +use App\Shared\Service\InputValidator; use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\JsonResponse; @@ -83,13 +84,17 @@ class RepresentationController extends BaseController public function create(Request $request): JsonResponse { $data = json_decode($request->getContent(), true) ?? []; - $mobile = trim($data['mobile_number'] ?? ''); - $fullName = trim($data['full_name'] ?? ''); + $mobile = InputValidator::toEnglishDigits(trim((string) ($data['mobile_number'] ?? ''))); + $fullName = trim((string) ($data['full_name'] ?? '')); if (empty($mobile) || empty($fullName)) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'mobile_number و full_name الزامی است', 422); } + if (!InputValidator::isValidIranMobile($mobile)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شماره موبایل نامعتبر است', 422, 'mobile_number'); + } + $user = $this->userRepo->findByMobile($mobile); if ($user === null) { $user = new User($mobile); diff --git a/src/Shared/Service/InputValidator.php b/src/Shared/Service/InputValidator.php new file mode 100644 index 00000000..19b032d1 --- /dev/null +++ b/src/Shared/Service/InputValidator.php @@ -0,0 +1,46 @@ + '0', '۱' => '1', '۲' => '2', '۳' => '3', '۴' => '4', + '۵' => '5', '۶' => '6', '۷' => '7', '۸' => '8', '۹' => '9', + '٠' => '0', '١' => '1', '٢' => '2', '٣' => '3', '٤' => '4', + '٥' => '5', '٦' => '6', '٧' => '7', '٨' => '8', '٩' => '9', + ]); + } +} diff --git a/src/UserProfile/Controller/UserProfileController.php b/src/UserProfile/Controller/UserProfileController.php index af9d2c97..b4b1f244 100644 --- a/src/UserProfile/Controller/UserProfileController.php +++ b/src/UserProfile/Controller/UserProfileController.php @@ -7,6 +7,7 @@ use App\Auth\Repository\UserRepository; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; use App\Shared\Service\FileValidatorService; +use App\Shared\Service\InputValidator; use App\UserProfile\Entity\UserProfile; use App\UserProfile\Repository\UserProfileRepository; use Symfony\Component\Uid\Uuid; @@ -129,6 +130,15 @@ class UserProfileController extends BaseController } $data = json_decode($request->getContent(), true) ?? []; + + if (array_key_exists('national_code', $data) && $data['national_code'] !== null && $data['national_code'] !== '') { + $code = InputValidator::toEnglishDigits((string) $data['national_code']); + if (!InputValidator::isValidIranNationalCode($code)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی نامعتبر است', 422, 'national_code'); + } + $data['national_code'] = $code; + } + $this->hydrate($profile, $data); $this->repository->save($profile);