feat: implement server-side validation for mobile numbers and national codes across multiple endpoints
This commit is contained in:
@@ -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` ادمین و نماینده با موبایل معتبر همچنان کار کنند.
|
||||
- بعد از تست، رکوردهای تستی را پاک کن (هیچ نماینده/کاربر/پروفایلِ آشغال نماند).
|
||||
@@ -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) را کامل کن، سپس بقیه.
|
||||
@@ -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.
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 <ADMIN>" \
|
||||
-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/<own-uuid> -H "Authorization: Bearer <USER>" \
|
||||
-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` هست).
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user