From 56195c8a597dca0bfcc7530eba0516d7e33c08e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 9 May 2026 18:22:30 +0300 Subject: [PATCH] =?UTF-8?q?fix(backend):=20tenant=20middleware=20=D0=BD?= =?UTF-8?q?=D0=B0=20auth-routes=20+=20HasPasswordRules=20trait=20+=20test?= =?UTF-8?q?=20password=20(audit=20P0-01=20+=20O-refactor-03=20+=20P2-01)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Закрытие аудита 2026-05-09 (b6ae8dd): - P0-01: применён 'tenant' middleware (alias уже в bootstrap/app.php:17) к 3 auth:sanctum-группам: /api/notifications, /api/reminders, /api/reports/jobs (web.php:44/52/63). /api/deals и /api/admin/* остаются без auth (P1-10/Б-1) — в реестр Спринта 1 Phase F. - O-refactor-03: HasPasswordRules trait извлекает rules + messages, подключён в Login/Register. - P2-01: bcrypt('test') → bcrypt('test1234') в AdminIncidentsIndexTest (≥8 chars). - bonus-fix: SetTenantContext::resolveTenantId — property_exists() заменён на isset() для Eloquent magic-attributes (auth-путь резолюции tenant_id никогда не работал из-за этого бага; тесты-смоки middleware покрывали только X-Tenant-Id header / subdomain). Без фикса P0-01 ломает 58 тестов в /api/notifications + /api/reminders + /api/reports/jobs. Pest: 416/416 PASS. Larastan: 0 errors. Pint: clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/app/Http/Middleware/SetTenantContext.php | 4 ++- .../Auth/Concerns/HasPasswordRules.php | 32 +++++++++++++++++++ app/app/Http/Requests/Auth/LoginRequest.php | 11 ++++--- .../Http/Requests/Auth/RegisterRequest.php | 10 +++--- app/routes/web.php | 6 ++-- app/tests/Feature/AdminIncidentsIndexTest.php | 2 +- 6 files changed, 51 insertions(+), 14 deletions(-) create mode 100644 app/app/Http/Requests/Auth/Concerns/HasPasswordRules.php diff --git a/app/app/Http/Middleware/SetTenantContext.php b/app/app/Http/Middleware/SetTenantContext.php index 62c0a286..466b6e00 100644 --- a/app/app/Http/Middleware/SetTenantContext.php +++ b/app/app/Http/Middleware/SetTenantContext.php @@ -51,7 +51,9 @@ class SetTenantContext private function resolveTenantId(Request $request): ?int { $user = $request->user(); - if ($user !== null && property_exists($user, 'tenant_id') && $user->tenant_id !== null) { + // Eloquent-атрибуты через __get не видны property_exists() — нужен isset() + // (вызывает __isset, который проверяет наличие атрибута через getAttribute). + if ($user !== null && isset($user->tenant_id) && $user->tenant_id !== null) { return (int) $user->tenant_id; } diff --git a/app/app/Http/Requests/Auth/Concerns/HasPasswordRules.php b/app/app/Http/Requests/Auth/Concerns/HasPasswordRules.php new file mode 100644 index 00000000..b335a251 --- /dev/null +++ b/app/app/Http/Requests/Auth/Concerns/HasPasswordRules.php @@ -0,0 +1,32 @@ + + */ + protected function passwordRules(): array + { + return ['required', 'string', 'min:8', 'max:255']; + } + + /** + * Сообщения об ошибках для password. + * + * @return array + */ + protected function passwordMessages(): array + { + return [ + 'password.required' => 'Укажите пароль.', + 'password.min' => 'Пароль должен быть не короче 8 символов.', + ]; + } +} diff --git a/app/app/Http/Requests/Auth/LoginRequest.php b/app/app/Http/Requests/Auth/LoginRequest.php index f3301035..127e7b9d 100644 --- a/app/app/Http/Requests/Auth/LoginRequest.php +++ b/app/app/Http/Requests/Auth/LoginRequest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Requests\Auth; +use App\Http\Requests\Auth\Concerns\HasPasswordRules; use Illuminate\Foundation\Http\FormRequest; /** @@ -14,12 +15,14 @@ use Illuminate\Foundation\Http\FormRequest; */ class LoginRequest extends FormRequest { + use HasPasswordRules; + /** @return array */ public function rules(): array { return [ 'email' => ['required', 'string', 'email', 'max:255'], - 'password' => ['required', 'string', 'min:8', 'max:255'], + 'password' => $this->passwordRules(), 'remember' => ['sometimes', 'boolean'], ]; } @@ -27,11 +30,9 @@ class LoginRequest extends FormRequest /** @return array */ public function messages(): array { - return [ + return array_merge($this->passwordMessages(), [ 'email.required' => 'Укажите email.', 'email.email' => 'Email указан некорректно.', - 'password.required' => 'Укажите пароль.', - 'password.min' => 'Пароль должен быть не короче 8 символов.', - ]; + ]); } } diff --git a/app/app/Http/Requests/Auth/RegisterRequest.php b/app/app/Http/Requests/Auth/RegisterRequest.php index 0951244f..75f85b04 100644 --- a/app/app/Http/Requests/Auth/RegisterRequest.php +++ b/app/app/Http/Requests/Auth/RegisterRequest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Requests\Auth; +use App\Http\Requests\Auth\Concerns\HasPasswordRules; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -15,12 +16,14 @@ use Illuminate\Validation\Rule; */ class RegisterRequest extends FormRequest { + use HasPasswordRules; + /** @return array */ public function rules(): array { return [ 'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')], - 'password' => ['required', 'string', 'min:8', 'max:255'], + 'password' => $this->passwordRules(), 'accept_offer' => ['required', 'accepted'], 'accept_pdn' => ['required', 'accepted'], ]; @@ -29,13 +32,12 @@ class RegisterRequest extends FormRequest /** @return array */ public function messages(): array { - return [ + return array_merge($this->passwordMessages(), [ 'email.required' => 'Укажите email.', 'email.email' => 'Email указан некорректно.', 'email.unique' => 'Аккаунт с таким email уже существует.', - 'password.min' => 'Пароль должен быть не короче 8 символов.', 'accept_offer.accepted' => 'Необходимо принять оферту.', 'accept_pdn.accepted' => 'Необходимо согласие на обработку персональных данных.', - ]; + ]); } } diff --git a/app/routes/web.php b/app/routes/web.php index 7d434413..ec97a9aa 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -41,7 +41,7 @@ Route::prefix('/api/auth')->group(function () { // In-app уведомления (P0 этап 2b). Все endpoint'ы под Sanctum SPA auth — // уведомления USER-personal, читать/писать может только сам user. -Route::middleware('auth:sanctum')->prefix('/api/notifications')->group(function () { +Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/notifications')->group(function () { Route::get('/', [InAppNotificationController::class, 'index']); Route::patch('/{id}/read', [InAppNotificationController::class, 'markRead'])->where('id', '[0-9]+'); Route::post('/mark-all-read', [InAppNotificationController::class, 'markAllRead']); @@ -49,7 +49,7 @@ Route::middleware('auth:sanctum')->prefix('/api/notifications')->group(function }); // Reminders (P0 этап 4) — schema v8.10 §17.5. Auth обязательный. -Route::middleware('auth:sanctum')->prefix('/api/reminders')->group(function () { +Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/reminders')->group(function () { Route::get('/', [ReminderController::class, 'index']); Route::post('/', [ReminderController::class, 'store']); Route::patch('/{id}', [ReminderController::class, 'update'])->where('id', '[0-9]+'); @@ -60,7 +60,7 @@ Route::middleware('auth:sanctum')->prefix('/api/reminders')->group(function () { // Reports backend. Schema §13.5 report_jobs. Auth обязательный. // Этапы 1+2 (CRUD + provider/formatter) + этап 3 (retry/cancel/delete + // retention cron `reports:cleanup-expired`). -Route::middleware('auth:sanctum')->prefix('/api/reports/jobs')->group(function () { +Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/reports/jobs')->group(function () { Route::get('/', [ReportJobController::class, 'index']); Route::post('/', [ReportJobController::class, 'store']); Route::get('/{id}', [ReportJobController::class, 'show'])->where('id', '[0-9]+'); diff --git a/app/tests/Feature/AdminIncidentsIndexTest.php b/app/tests/Feature/AdminIncidentsIndexTest.php index 2aff6816..5b3a813f 100644 --- a/app/tests/Feature/AdminIncidentsIndexTest.php +++ b/app/tests/Feature/AdminIncidentsIndexTest.php @@ -13,7 +13,7 @@ beforeEach(function () { $this->adminId = (int) DB::table('saas_admin_users')->insertGetId([ 'email' => 'admin-'.bin2hex(random_bytes(3)).'@test', 'full_name' => 'Admin Test', - 'password_hash' => bcrypt('test'), + 'password_hash' => bcrypt('test1234'), 'is_active' => true, 'role' => 'support', 'created_at' => now(),