56195c8a59
Закрытие аудита 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) <noreply@anthropic.com>
158 lines
9.5 KiB
PHP
158 lines
9.5 KiB
PHP
<?php
|
||
|
||
use App\Http\Controllers\Api\AdminBillingController;
|
||
use App\Http\Controllers\Api\AdminIncidentsController;
|
||
use App\Http\Controllers\Api\AdminSystemSettingsController;
|
||
use App\Http\Controllers\Api\AdminTenantsController;
|
||
use App\Http\Controllers\Api\AuthController;
|
||
use App\Http\Controllers\Api\DealController;
|
||
use App\Http\Controllers\Api\ImpersonationController;
|
||
use App\Http\Controllers\Api\InAppNotificationController;
|
||
use App\Http\Controllers\Api\LeadStatusController;
|
||
use App\Http\Controllers\Api\ManagerController;
|
||
use App\Http\Controllers\Api\ProjectController;
|
||
use App\Http\Controllers\Api\ReminderController;
|
||
use App\Http\Controllers\Api\ReportJobController;
|
||
use App\Http\Controllers\Api\TwoFactorSetupController;
|
||
use App\Http\Controllers\Api\WebhookReceiveController;
|
||
use Illuminate\Support\Facades\Route;
|
||
|
||
// SPA-API маршруты auth-flow. Размещены в web.php (а не api.php), потому
|
||
// что Sanctum SPA mode использует session-cookie auth, а session middleware
|
||
// добавляется только к web-группе. См. laravel.com/docs/sanctum#spa-authentication.
|
||
Route::prefix('/api/auth')->group(function () {
|
||
Route::post('/login', [AuthController::class, 'login']);
|
||
Route::post('/register', [AuthController::class, 'register']);
|
||
// /2fa/verify публичный — у user'а ещё нет полноценной session-auth, только
|
||
// pending_user_id в session. Verify завершает login после проверки TOTP.
|
||
Route::post('/2fa/verify', [AuthController::class, 'verifyTwoFactor']);
|
||
// /2fa/recovery-use — публичный (нет полноценной session-auth до verify).
|
||
Route::post('/2fa/recovery-use', [AuthController::class, 'useRecoveryCode']);
|
||
// /forgot — публичный (anti-enumeration unified-ответ + rate-limit).
|
||
Route::post('/forgot', [AuthController::class, 'forgotPassword']);
|
||
// /reset-password — публичный (deep-link из email с token+email+password).
|
||
Route::post('/reset-password', [AuthController::class, 'resetPassword']);
|
||
Route::middleware('auth:sanctum')->group(function () {
|
||
Route::get('/me', [AuthController::class, 'me']);
|
||
Route::post('/logout', [AuthController::class, 'logout']);
|
||
Route::patch('/me/notification-preferences', [AuthController::class, 'updateNotificationPreferences']);
|
||
});
|
||
});
|
||
|
||
// In-app уведомления (P0 этап 2b). Все endpoint'ы под Sanctum SPA auth —
|
||
// уведомления USER-personal, читать/писать может только сам user.
|
||
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']);
|
||
Route::delete('/{id}', [InAppNotificationController::class, 'destroy'])->where('id', '[0-9]+');
|
||
});
|
||
|
||
// Reminders (P0 этап 4) — schema v8.10 §17.5. Auth обязательный.
|
||
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]+');
|
||
Route::post('/{id}/complete', [ReminderController::class, 'complete'])->where('id', '[0-9]+');
|
||
Route::delete('/{id}', [ReminderController::class, 'destroy'])->where('id', '[0-9]+');
|
||
});
|
||
|
||
// 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', '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]+');
|
||
Route::post('/{id}/retry', [ReportJobController::class, 'retry'])->where('id', '[0-9]+');
|
||
Route::post('/{id}/cancel', [ReportJobController::class, 'cancel'])->where('id', '[0-9]+');
|
||
Route::delete('/{id}', [ReportJobController::class, 'destroy'])->where('id', '[0-9]+');
|
||
});
|
||
|
||
// SaaS-admin impersonation flow (Ю-1). На MVP без middleware (saas-admin auth
|
||
// не реализован), production: middleware('auth:saas-admin') + role('compliance' if needed).
|
||
Route::prefix('/api/admin/impersonation')->group(function () {
|
||
Route::get('/active', [ImpersonationController::class, 'active']);
|
||
Route::get('/recent', [ImpersonationController::class, 'recent']);
|
||
Route::post('/init', [ImpersonationController::class, 'init']);
|
||
Route::post('/verify', [ImpersonationController::class, 'verify']);
|
||
Route::post('/end', [ImpersonationController::class, 'end']);
|
||
});
|
||
|
||
// SaaS-admin → Тенанты: lookup + детали для AdminTenantsView/AdminTenantDetailView.
|
||
// Без auth (saas-admin SSO ⏸ Б-1).
|
||
Route::get('/api/admin/tenants', [AdminTenantsController::class, 'index']);
|
||
Route::get('/api/admin/tenants/{subdomain}', [AdminTenantsController::class, 'show'])
|
||
->where('subdomain', '[a-z0-9_-]+');
|
||
|
||
// SaaS-admin → Биллинг: aggregates пополнений/списаний за текущий месяц.
|
||
Route::get('/api/admin/billing', [AdminBillingController::class, 'index']);
|
||
|
||
// SaaS-admin → Инциденты: чтение incidents_log для AdminIncidentsView.
|
||
Route::get('/api/admin/incidents', [AdminIncidentsController::class, 'index']);
|
||
|
||
// SaaS-admin → Система: edit-flow для system_settings + audit-log (4-eyes-pattern).
|
||
// На MVP без auth-middleware (admin_user_id параметром); production: middleware('auth:saas-admin').
|
||
Route::prefix('/api/admin/system-settings')->group(function () {
|
||
Route::get('/', [AdminSystemSettingsController::class, 'index']);
|
||
Route::put('/{key}', [AdminSystemSettingsController::class, 'update'])->where('key', '[a-z0-9_\.]+');
|
||
});
|
||
|
||
// Сделки — manual create через UI (NewDealDialog). На prod: middleware
|
||
// 'auth:sanctum' + 'tenant', tenant_id берётся из user'а. На MVP — параметром.
|
||
Route::get('/api/deals', [DealController::class, 'index']);
|
||
Route::get('/api/deals/{id}', [DealController::class, 'show'])->where('id', '[0-9]+');
|
||
Route::post('/api/deals', [DealController::class, 'store']);
|
||
Route::post('/api/deals/export', [DealController::class, 'export']);
|
||
Route::post('/api/deals/transition', [DealController::class, 'transition']);
|
||
Route::patch('/api/deals/{id}', [DealController::class, 'update'])->where('id', '[0-9]+');
|
||
Route::delete('/api/deals', [DealController::class, 'destroy']);
|
||
Route::post('/api/deals/restore', [DealController::class, 'restore']);
|
||
|
||
// Lookup endpoints — заполняют v-select'ы (NewDealDialog, smart-filters).
|
||
Route::get('/api/managers', [ManagerController::class, 'index']);
|
||
Route::get('/api/projects', [ProjectController::class, 'index']);
|
||
Route::get('/api/lead-statuses', [LeadStatusController::class, 'index']);
|
||
|
||
// Receive endpoint для входящих webhook'ов (narrative §5.5).
|
||
// Auth — по `tenants.webhook_token` в URL (без middleware, проверка внутри controller).
|
||
// На prod: + HMAC-валидация X-Webhook-Signature + per-token rate-limit.
|
||
Route::post('/api/webhook/{token}', [WebhookReceiveController::class, 'receive'])
|
||
->where('token', '[A-Za-z0-9\-_]+');
|
||
|
||
// 2FA setup wizard — все эндпоинты под auth:sanctum (только для уже залогиненных).
|
||
Route::prefix('/api/2fa')->middleware('auth:sanctum')->group(function () {
|
||
Route::post('/init', [TwoFactorSetupController::class, 'init']);
|
||
Route::post('/confirm', [TwoFactorSetupController::class, 'confirm']);
|
||
Route::post('/disable', [TwoFactorSetupController::class, 'disable']);
|
||
Route::post('/regenerate-recovery-codes', [TwoFactorSetupController::class, 'regenerateRecoveryCodes']);
|
||
});
|
||
|
||
// SPA-страницы: каждый путь отдаёт Vue-shell (один Blade-template `welcome`).
|
||
// Vue Router (createWebHistory) разруливает /login, /register и т.п. на фронте.
|
||
//
|
||
// Регистрируем явно, а не catch-all `/{any?}` — иначе тесты, регистрирующие
|
||
// runtime routes через `Route::get(...)` в beforeEach (например _test/...),
|
||
// будут перехвачены catch-all'ом и вернут welcome view вместо expected response.
|
||
Route::view('/', 'welcome');
|
||
Route::view('/login', 'welcome');
|
||
Route::view('/register', 'welcome');
|
||
Route::view('/forgot', 'welcome');
|
||
Route::view('/reset', 'welcome'); // SPA-router рендерит ResetPasswordView для /reset/{token}
|
||
Route::view('/2fa', 'welcome');
|
||
Route::view('/recovery', 'welcome');
|
||
Route::view('/recovery-use', 'welcome');
|
||
Route::view('/dashboard', 'welcome');
|
||
Route::view('/deals', 'welcome');
|
||
Route::view('/kanban', 'welcome');
|
||
Route::view('/billing', 'welcome');
|
||
Route::view('/settings', 'welcome');
|
||
Route::view('/reports', 'welcome');
|
||
Route::view('/403', 'welcome');
|
||
Route::view('/500', 'welcome');
|
||
|
||
// Fallback для всех неизвестных путей — Vue Router catch-all отрисует 404.
|
||
// Срабатывает ПОСЛЕ всех явных route'ов выше и runtime-route'ов от Pest
|
||
// beforeEach (они регистрируются в момент теста, до запроса).
|
||
Route::fallback(fn () => view('welcome'));
|