feat: implement server-side validation for mobile numbers and national codes across multiple endpoints

This commit is contained in:
hamed
2026-06-20 12:28:36 +03:30
parent 6fc456522a
commit f9678026a8
12 changed files with 487 additions and 9 deletions
@@ -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` ادمین و نماینده با موبایل معتبر همچنان کار کنند.
- بعد از تست، رکوردهای تستی را پاک کن (هیچ نماینده/کاربر/پروفایلِ آشغال نماند).
+131
View File
@@ -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-<date>.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 <mobile> --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/<path>" -H "Authorization: Bearer <token>"
```
بررسی DB برای تأیید اثر:
```bash
ddev exec bash -c "mysql -uroot -proot db -e \"<SQL>\""
```
سلامت 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-<YYYY-MM-DD>.md` بساز با جدول اولویت‌بندی‌شده:
```markdown
# گزارش باگ — <date>
## خلاصه
- تعداد کل: 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) را کامل کن، سپس بقیه.