356 lines
30 KiB
Markdown
356 lines
30 KiB
Markdown
# Sprint 3F — API middleware (J1/J2) Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Закрыть аудит-находки J1 (auth+tenant middleware на `/api/deals*`) и J2 (стаб-гейт SaaS-admin зоны `/api/admin/*`) — убрать незащищённые API-эндпоинты, где tenant подставляется параметром запроса.
|
||
|
||
**Architecture:** J1 — на 8 роутов `/api/deals*` навешивается `['auth:sanctum','tenant']`; три контроллера (`DealController`, `DealBulkActionController`, `DealExportController`) перестают читать `tenant_id` из запроса и берут его из `auth()->user()->tenant_id`; 8 Pest-файлов мигрируют с `?tenant_id=` на `actingAs($user)`. J2 — новый middleware `EnsureSaasAdmin` (стаб: dev/testing пропускает, production fail-closed 503) вешается на весь блок `/api/admin/*`; реальная Yandex 360 SSO-авторизация — TODO под Б-1+DO-4.
|
||
|
||
**Tech Stack:** PHP 8.3 + Laravel 13, Sanctum SPA session auth, PostgreSQL 16 (RLS), Pest 4.
|
||
|
||
---
|
||
|
||
## Контекст (audit J1/J2)
|
||
|
||
Аудит портала ([docs/superpowers/specs/2026-05-15-portal-audit-design.md](../specs/2026-05-15-portal-audit-design.md)), раздел J:
|
||
|
||
- **J1** — «CTO-18 — auth+tenant middleware на `/api/deals` (требует Б-1 для prod)». Сейчас 8 роутов `/api/deals*` идут **без middleware**: `tenant_id` берётся параметром. Любой клиент читает/пишет сделки чужого тенанта, подставив `tenant_id`. `auth:sanctum`+`tenant` уже используются на `/api/reminders`, `/api/reports/*`, `/api/billing/*`, `/api/projects` — на dev работают, Б-1 их **не блокирует** (Б-1 блокирует только production-deploy). J1 = применить тот же middleware к `/api/deals*`.
|
||
- **J2** — «`/api/admin/*` — auth:saas-admin middleware (требует Б-1 + DO-4)». Гварда `saas-admin` в `config/auth.php` **нет** (только `web`); реальный гвард = Yandex 360 SSO, аудит явно пишет «**после Б-1+DO-4**» — оба registry-блокера открыты. Полноценный J2 невозможен. Решение заказчика (2026-05-16): **заготовка-стаб** — middleware-гейт, на dev пропускает, на production fail-closed; production SSO — TODO.
|
||
|
||
**Scope J1 — backend + Pest.** Фронтенд (`app/resources/js/api/deals.ts` и 3 view) НЕ трогаем: после рефактора backend игнорирует клиентский `tenant_id` (Laravel `validate()` молча отбрасывает лишние ключи; лишний query-параметр игнорируется), фронт продолжает слать сессионную cookie и работает без изменений. Клиентский `tenant_id` становится вестигиальным безвредным параметром — его удаление косметическое, в аудите J1 не значится, вне scope Sprint 3F. Это устраняет дублирующий риск (8 frontend-файлов + 11 Vitest-спеков) при нулевом выигрыше для безопасности: backend, игнорируя клиентский `tenant_id`, уже закрывает кросс-tenant утечку.
|
||
|
||
**Регистровые items.** J1 связан с CTO-18, J2 — с Б-1+DO-4 (все открыты). Sprint 3F реализует **код** находок J1/J2 (что заказчик авторизовал командой «делай 3f»), но **не закрывает** CTO-18/Б-1/DO-4 в реестре `Открытые_вопросы` — закрытие требует явного «закрываем» от заказчика. Реестр в этом спринте не трогаем.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**Task 1 (J2):**
|
||
|
||
- Create: `app/app/Http/Middleware/EnsureSaasAdmin.php` — стаб-гейт SaaS-admin зоны.
|
||
- Modify: `app/bootstrap/app.php` — alias `'saas-admin'` в `$middleware->alias([...])`.
|
||
- Modify: `app/routes/web.php` — обернуть блок `/api/admin/*` (impersonation/tenants/billing/incidents/system-settings/pricing-tiers/suppliers) в `Route::middleware('saas-admin')->group(...)`.
|
||
- Test: `app/tests/Feature/SaasAdminMiddlewareTest.php` — passthrough на testing + fail-closed 503 на production.
|
||
|
||
**Task 2 (J1):**
|
||
|
||
- Modify: `app/routes/web.php` — 8 роутов `/api/deals*` в `Route::middleware(['auth:sanctum','tenant'])->group(...)`.
|
||
- Modify: `app/app/Http/Controllers/Api/DealController.php` — `index/show/store/update`: tenant из `auth()->user()`.
|
||
- Modify: `app/app/Http/Controllers/Api/DealBulkActionController.php` — `transition/destroy/restore`: то же.
|
||
- Modify: `app/app/Http/Controllers/Api/DealExportController.php` — `export`: то же.
|
||
- Test (migrate): `app/tests/Feature/DealIndexTest.php`, `DealShowTest.php`, `DealCreateTest.php`, `DealUpdateTest.php`, `DealTransitionTest.php`, `DealDestroyTest.php`, `DealRestoreTest.php`, `LookupsTest.php` (только 3 `/api/deals`-теста).
|
||
|
||
**НЕ трогать:** `app/dev-indices.json` (авто-генерируемый, pre-existing `M` — не стейджить); фронтенд `deals.ts` и deal-views (см. Scope выше); `DealModelTest.php` (модельный unit-тест, HTTP не вызывает); lookup-эндпоинты `/api/managers` и `/api/lead-statuses` (в аудит-находке J1 не значатся — остаются без middleware; `/api/managers`-тесты в `LookupsTest` не трогать).
|
||
|
||
---
|
||
|
||
## Task 1: J2 — стаб-гейт `EnsureSaasAdmin` на `/api/admin/*`
|
||
|
||
**Files:**
|
||
|
||
- Create: `app/app/Http/Middleware/EnsureSaasAdmin.php`
|
||
- Modify: `app/bootstrap/app.php`
|
||
- Modify: `app/routes/web.php`
|
||
- Test: `app/tests/Feature/SaasAdminMiddlewareTest.php`
|
||
|
||
- [ ] **Step 1: Написать failing-тест `SaasAdminMiddlewareTest.php`**
|
||
|
||
Создать `app/tests/Feature/SaasAdminMiddlewareTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
|
||
/**
|
||
* J2 (Sprint 3F) — стаб-гейт SaaS-admin зоны.
|
||
*
|
||
* EnsureSaasAdmin на /api/admin/*: dev/testing пропускает (admin-панель
|
||
* работает на dev), прочие окружения — fail-closed 503 до подключения
|
||
* реального Yandex 360 SSO (TODO под Б-1+DO-4).
|
||
*/
|
||
uses(DatabaseTransactions::class);
|
||
|
||
test('/api/admin/* пропускается на testing-окружении (стаб permissive)', function () {
|
||
// Дефолтное тестовое окружение = testing → middleware пропускает.
|
||
$this->getJson('/api/admin/tenants')->assertStatus(200);
|
||
});
|
||
|
||
test('/api/admin/* возвращает 503 вне dev/testing (стаб fail-closed)', function () {
|
||
$this->app->detectEnvironment(fn () => 'production');
|
||
|
||
$this->getJson('/api/admin/tenants')->assertStatus(503);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Прогнать тест — убедиться, что падает**
|
||
|
||
Run: `cd app && composer test -- --filter=SaasAdminMiddlewareTest`
|
||
Expected: FAIL — middleware `EnsureSaasAdmin` ещё не существует, alias `saas-admin` не зарегистрирован, на роуты не навешан; тест «503 вне dev/testing» получит 200.
|
||
|
||
- [ ] **Step 3: Создать middleware `EnsureSaasAdmin.php`**
|
||
|
||
Создать `app/app/Http/Middleware/EnsureSaasAdmin.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Http\Middleware;
|
||
|
||
use Closure;
|
||
use Illuminate\Http\Request;
|
||
use Symfony\Component\HttpFoundation\Response;
|
||
|
||
/**
|
||
* Гейт SaaS-admin зоны (/api/admin/*) — audit-находка J2.
|
||
*
|
||
* СТАБ (Sprint 3F): полноценная авторизация saas-admin требует Yandex 360
|
||
* SSO-входа, который гейтится Б-1 (регистрация ООО) + DO-4. До их закрытия
|
||
* реального механизма аутентификации нет.
|
||
*
|
||
* Поведение стаба:
|
||
* - dev / testing (local, testing) → пропускаем. Admin-панель работает на
|
||
* dev; admin_user_id передаётся параметром (трейт ResolvesAdminUserId).
|
||
* - прочие окружения (production / staging) → fail-closed 503: зона
|
||
* закрыта до подключения реального SSO. Явный 503 лучше, чем тихо
|
||
* открытый /api/admin/* в проде.
|
||
*
|
||
* TODO (после Б-1 + DO-4): заменить на проверку Yandex 360 SSO-сессии
|
||
* saas-admin (отдельный guard) + роль (compliance и т.п. где требуется).
|
||
*/
|
||
class EnsureSaasAdmin
|
||
{
|
||
public function handle(Request $request, Closure $next): Response
|
||
{
|
||
if (! app()->environment('local', 'testing')) {
|
||
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
|
||
}
|
||
|
||
return $next($request);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Зарегистрировать alias `saas-admin` в `bootstrap/app.php`**
|
||
|
||
В `app/bootstrap/app.php` добавить импорт и расширить `$middleware->alias([...])`:
|
||
|
||
Импорт (после `use App\Http\Middleware\SetTenantContext;`):
|
||
|
||
```php
|
||
use App\Http\Middleware\EnsureSaasAdmin;
|
||
```
|
||
|
||
Блок alias заменить на:
|
||
|
||
```php
|
||
$middleware->alias([
|
||
'tenant' => SetTenantContext::class,
|
||
'saas-admin' => EnsureSaasAdmin::class,
|
||
]);
|
||
```
|
||
|
||
- [ ] **Step 5: Навесить `saas-admin` на блок `/api/admin/*` в `routes/web.php`**
|
||
|
||
В `app/routes/web.php` весь блок admin-роутов (от комментария `// SaaS-admin impersonation flow (Ю-1)...` до строки с `AdminSuppliersController@update` включительно — это группы impersonation/tenants/billing/incidents/system-settings/pricing-tiers/suppliers) обернуть в `Route::middleware('saas-admin')->group(...)`. Структура:
|
||
|
||
```php
|
||
// J2 (Sprint 3F): стаб-гейт SaaS-admin зоны. EnsureSaasAdmin — dev/testing
|
||
// пропускает, production fail-closed 503. Реальный Yandex 360 SSO — TODO под
|
||
// Б-1+DO-4. admin_user_id внутри контроллеров (трейт ResolvesAdminUserId)
|
||
// стаб не меняет — это отдельная зона ответственности.
|
||
Route::middleware('saas-admin')->group(function () {
|
||
// SaaS-admin impersonation flow (Ю-1). ...
|
||
Route::prefix('/api/admin/impersonation')->group(function () {
|
||
// ... без изменений ...
|
||
});
|
||
|
||
// ... все остальные admin-роуты без изменений, только с отступом +4 ...
|
||
|
||
Route::patch('/api/admin/suppliers/{id}', 'App\Http\Controllers\Api\AdminSuppliersController@update')
|
||
->where('id', '[0-9]+');
|
||
});
|
||
```
|
||
|
||
Содержимое роутов внутри — без изменений (только индентация +4; `composer pint` в Step 7 выровняет, но писать сразу корректно). Роуты `/api/billing/charges`, `/api/billing/*`, `/api/api-keys`, `/api/tenants/me/webhook-settings`, `/api/dashboard/summary` и далее — **вне** этой группы (это tenant-зона, не admin).
|
||
|
||
- [ ] **Step 6: Прогнать тест — убедиться, что зелёный**
|
||
|
||
Run: `cd app && composer test -- --filter=SaasAdminMiddlewareTest`
|
||
Expected: PASS — 2/2.
|
||
|
||
- [ ] **Step 7: Pint + Larastan + регрессия admin-тестов**
|
||
|
||
Run: `cd app && composer pint` → 0 правок или авто-формат применён.
|
||
Run: `cd app && composer stan` → 0 ошибок (новый файл middleware типизирован; тест использует только реальные методы `TestCase`, динамических свойств нет — baseline regen не требуется).
|
||
Run: `cd app && composer test -- --filter="Admin"` → 0 failed. Все существующие admin-тесты (AdminBilling/AdminIncidents/AdminTenants/AdminSystemSettings/AdminPricingTiers/AdminSuppliers/Impersonation) проходят: на `testing`-окружении `EnsureSaasAdmin` прозрачен.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add app/app/Http/Middleware/EnsureSaasAdmin.php app/bootstrap/app.php app/routes/web.php app/tests/Feature/SaasAdminMiddlewareTest.php
|
||
git commit -m "feat(api): J2 — стаб-гейт EnsureSaasAdmin на /api/admin/*"
|
||
```
|
||
|
||
**НЕ стейджить** `app/dev-indices.json`.
|
||
|
||
---
|
||
|
||
## Task 2: J1 — `auth:sanctum`+`tenant` middleware на `/api/deals*`
|
||
|
||
**Files:**
|
||
|
||
- Modify: `app/routes/web.php`
|
||
- Modify: `app/app/Http/Controllers/Api/DealController.php`
|
||
- Modify: `app/app/Http/Controllers/Api/DealBulkActionController.php`
|
||
- Modify: `app/app/Http/Controllers/Api/DealExportController.php`
|
||
- Test (migrate): `DealIndexTest.php`, `DealShowTest.php`, `DealCreateTest.php`, `DealUpdateTest.php`, `DealTransitionTest.php`, `DealDestroyTest.php`, `DealRestoreTest.php`, `LookupsTest.php`
|
||
|
||
> **NB про порядок шагов:** миграция атомарна — добавление middleware немедленно «краснит» все 8 deal-тест-файлов (они не аутентифицируются). Поэтому routes+контроллеры+тесты мигрируют в одной задаче/одном коммите; промежуточный red — внутри задачи (Step 3 это фиксирует как TDD-red), green — в Step 6.
|
||
|
||
- [ ] **Step 1: Навесить middleware на 8 роутов `/api/deals*` в `routes/web.php`**
|
||
|
||
В `app/routes/web.php` блок из 8 deal-роутов (`GET /api/deals`, `GET /api/deals/{id}`, `POST /api/deals`, `POST /api/deals/export`, `POST /api/deals/transition`, `PATCH /api/deals/{id}`, `DELETE /api/deals`, `POST /api/deals/restore`) обернуть в группу. Заменить docblock-комментарий и роуты на:
|
||
|
||
```php
|
||
// Сделки — single-resource CRUD + bulk + export. J1 (Sprint 3F, audit):
|
||
// auth:sanctum + tenant. tenant_id берётся из auth()->user()->tenant_id
|
||
// (SetTenantContext), НЕ из параметра запроса — закрывает кросс-tenant утечку.
|
||
//
|
||
// Sprint 3 Phase A (audit O-refactor-01): single-resource CRUD в
|
||
// DealController, bulk (transition/destroy/restore) — в
|
||
// DealBulkActionController, export — в DealExportController.
|
||
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
|
||
Route::get('/api/deals', 'App\Http\Controllers\Api\DealController@index');
|
||
Route::get('/api/deals/{id}', 'App\Http\Controllers\Api\DealController@show')->where('id', '[0-9]+');
|
||
Route::post('/api/deals', 'App\Http\Controllers\Api\DealController@store');
|
||
Route::post('/api/deals/export', 'App\Http\Controllers\Api\DealExportController@export');
|
||
Route::post('/api/deals/transition', 'App\Http\Controllers\Api\DealBulkActionController@transition');
|
||
Route::patch('/api/deals/{id}', 'App\Http\Controllers\Api\DealController@update')->where('id', '[0-9]+');
|
||
Route::delete('/api/deals', 'App\Http\Controllers\Api\DealBulkActionController@destroy');
|
||
Route::post('/api/deals/restore', 'App\Http\Controllers\Api\DealBulkActionController@restore');
|
||
});
|
||
```
|
||
|
||
Lookup-роуты `/api/managers` и `/api/lead-statuses` (идут сразу после) — **вне** группы, без изменений.
|
||
|
||
- [ ] **Step 2: Рефактор контроллеров — tenant из `auth()->user()`**
|
||
|
||
Универсальное правило для всех 4 методов `DealController` + 3 методов `DealBulkActionController` + `export` `DealExportController`:
|
||
|
||
1. Убрать чтение `tenant_id` из запроса: для `index`/`show` — строку `$tenantId = (int) $request->query('tenant_id', '0');` и следующий за ней блок `if ($tenantId < 1) { return ... 422; }`. Для `store`/`update`/`transition`/`destroy`/`restore`/`export` — ключ `'tenant_id' => 'required|integer|min:1',` из массива правил `$request->validate([...])`.
|
||
2. Убрать резолюцию `Tenant` + 404: блок `$tenant = Tenant::find(...); if ($tenant === null) { return ... 404; }` (в `export` — `abort(404, ...)`).
|
||
3. Добавить `$tenantId = (int) $request->user()->tenant_id;` (для `index`/`show` — на месте удалённого; для остальных — сразу после `$validated = $request->validate([...]);`).
|
||
4. Заменить все `$tenant->id` на `$tenantId`, в `use (...)` замыканий `$tenant` → `$tenantId`.
|
||
5. Убрать `use App\Models\Tenant;` (станет неиспользуемым; `composer pint` подчистит, но убрать явно).
|
||
6. Внутренние `DB::transaction(...)` + `DB::statement('SET LOCAL app.current_tenant_id = ...')` — **оставить без изменений**. Для write-методов это атомарность; для `DealExportController::export` это **обязательно** — StreamedResponse-замыкание выполняется уже после commit'а транзакции `tenant`-middleware (см. комментарий в `export()` строки про «после Laravel-response pipeline»), tenant-контекст middleware streaming НЕ покрывает.
|
||
7. Обновить docblock-и: убрать абзацы «На MVP без auth-middleware… `tenant_id` параметром… Production: middleware» — заменить на «J1 (Sprint 3F): `auth:sanctum`+`tenant`, `tenant_id` из `auth()->user()`.»
|
||
|
||
Конкретно по `DealController`:
|
||
|
||
- `index(Request $request)`: удалить строки `$tenantId = (int) $request->query('tenant_id', '0');` + `if ($tenantId < 1) {...422}` + `$tenant = Tenant::find($tenantId);` + `if ($tenant === null) {...404}`. На их место: `$tenantId = (int) $request->user()->tenant_id;`. Остальное (уже использует `$tenantId`) — без изменений.
|
||
- `show(Request $request, int $id)`: то же — удалить query/422/Tenant::find/404, поставить `$tenantId = (int) $request->user()->tenant_id;`.
|
||
- `store(Request $request)`: из `validate` убрать `'tenant_id' => 'required|integer|min:1',`; убрать `$tenant = Tenant::find($validated['tenant_id']); if (...404)`; добавить `$tenantId = (int) $request->user()->tenant_id;`; заменить `$tenant->id` → `$tenantId` (manager-guard, `use (...)` замыкания, `SET LOCAL`, `Project::firstOrCreate`, `Deal::create`, `ActivityLog::create`).
|
||
- `update(Request $request, int $id)`: из `validate` убрать `'tenant_id' => 'required|integer|min:1',`; убрать `$tenant = Tenant::find($validated['tenant_id']); if (...404)`; добавить `$tenantId = (int) $request->user()->tenant_id;`; заменить `$tenant->id` → `$tenantId` (manager-guard, `use (...)`, `SET LOCAL`, оба `where('tenant_id', ...)`, три `ActivityLog::create(['tenant_id' => ...])`).
|
||
|
||
`DealBulkActionController` — `transition`/`destroy`/`restore` идентично: убрать `tenant_id` из `validate`, убрать `Tenant::find`+404, `$tenantId = (int) $request->user()->tenant_id;`, `$tenant->id` → `$tenantId`, `use ($validated, $tenant)` → `use ($validated, $tenantId)`.
|
||
|
||
`DealExportController::export` — убрать `tenant_id` из `validate`, убрать `Tenant::find`+`abort(404)`, `$tenantId = (int) $request->user()->tenant_id;`, в `use (...)` StreamedResponse-замыкания `$tenant` → `$tenantId`, `$tenant->id` → `$tenantId`.
|
||
|
||
- [ ] **Step 3: Прогнать deal-тесты — убедиться в массовом red**
|
||
|
||
Run: `cd app && composer test -- --filter="Deal"`
|
||
Expected: FAIL — `DealIndexTest`/`DealShowTest`/`DealCreateTest`/`DealUpdateTest`/`DealTransitionTest`/`DealDestroyTest`/`DealRestoreTest` массово красные: запросы без `actingAs` теперь получают `401`. Это подтверждает, что auth-гейт активен (TDD-red).
|
||
|
||
- [ ] **Step 4: Мигрировать 8 тест-файлов на `actingAs`**
|
||
|
||
Универсальный рецепт для каждого HTTP-теста на `/api/deals*`:
|
||
|
||
**(R1) `beforeEach`** — после создания `$this->tenant` добавить:
|
||
|
||
```php
|
||
$this->user = User::factory()->for($this->tenant)->create();
|
||
$this->actingAs($this->user);
|
||
```
|
||
|
||
(`use App\Models\User;` — добавить в импорты файла, если ещё нет.)
|
||
|
||
**(R2) URL** — убрать `?tenant_id=...` / `&tenant_id=...`: `'/api/deals?tenant_id='.$this->tenant->id` → `'/api/deals'`; `'/api/deals?tenant_id='.$id.'&status_in[]=new'` → `'/api/deals?status_in[]=new'` (если параметр был первым — следующий `&` становится `?`).
|
||
|
||
**(R3) Body** — убрать ключ `'tenant_id' => ...,` из массивов `postJson`/`patchJson`/`deleteJson`.
|
||
|
||
**(R4) Тесты «404 unknown tenant_id»** — **удалить целиком**. После J1 tenant берётся из `auth()->user()->tenant_id` (FK-гарантированно валиден), пути «unknown tenant» больше нет. Удаляются: `DealIndexTest` «404 для unknown tenant_id», `DealShowTest` «404 для unknown tenant», `DealUpdateTest` «404 unknown tenant», `DealTransitionTest` «404 на unknown tenant», `DealDestroyTest` «404 на unknown tenant», `DealRestoreTest` «404 на unknown tenant», `DealCreateTest` «404 при unknown tenant_id» и «POST /api/deals/export 404 unknown tenant».
|
||
|
||
**(R5) Тесты «422 без tenant_id»** — конвертировать в «401 без auth»: тело — запрос **без** `actingAs` → `401`. Пример (`DealIndexTest`):
|
||
|
||
```php
|
||
test('GET /api/deals возвращает 401 без auth', function () {
|
||
auth()->logout();
|
||
$this->getJson('/api/deals')->assertStatus(401);
|
||
});
|
||
```
|
||
|
||
Конвертируются: `DealIndexTest` «422 без tenant_id», `DealShowTest` «422 без tenant_id», `DealUpdateTest` «422 без tenant_id».
|
||
|
||
**(R6) Endpoints без теста «422 без tenant_id»** (transition/destroy/restore/store/export) — добавить по одному новому тесту «401 без auth» (запрос без `actingAs`), чтобы каждый из 8 endpoint'ов имел 401-покрытие. Пример (`DealTransitionTest`):
|
||
|
||
```php
|
||
test('POST /api/deals/transition возвращает 401 без auth', function () {
|
||
auth()->logout();
|
||
$this->postJson('/api/deals/transition', ['ids' => [1], 'status' => 'new'])->assertStatus(401);
|
||
});
|
||
```
|
||
|
||
**(R7) Тесты пустого body «422»** (`DealTransitionTest`/`DealDestroyTest`/`DealRestoreTest`: `postJson('/api/deals/transition', [])->assertStatus(422)` и аналоги) — остаются `422` (поля `ids`/`status` по-прежнему `required`); `actingAs` обеспечивается через `beforeEach` (R1), иначе был бы `401`. Если имя теста содержит «без tenant_id» — переименовать (например «422 на пустой body»).
|
||
|
||
**(R8) `DealCreateTest` «422 без обязательных полей»** — остаётся `422` (`project_name`/`phone` по-прежнему `required`), но `assertJson`-проверка ключей: `toHaveKeys(['tenant_id', 'project_name', 'phone'])` → `toHaveKeys(['project_name', 'phone'])` (`tenant_id` больше не валидируемое поле).
|
||
|
||
**(R9) Кросс-tenant RLS-тесты** (`DealIndexTest` «не возвращает сделки чужого tenant'а», «изолирует чужие удалённые»; `DealShowTest` «404 чужая сделка»; `DealUpdateTest` «404 чужая сделка»; и т.п.) — **оставить логику**: чужие данные сеются через `DB::statement('SET app.current_tenant_id = ...')`, `actingAs` — пользователь `$this->tenant`. Применить только R2/R3 (убрать `tenant_id` из запроса). Изоляция продолжает проверяться: backend берёт tenant из auth-пользователя.
|
||
|
||
**(R10) `LookupsTest.php`** — содержит тесты `/api/managers` (НЕ трогать — endpoint без middleware) и 3 теста `POST /api/deals` (manager-guard). `beforeEach` НЕ менять (тесты `/api/managers` чувствительны к числу users тенанта — лишний `$this->user` сломал бы `toHaveCount(2)`). Вместо этого в каждый из 3 `/api/deals`-тестов («422 если manager_id не принадлежит tenant'у», «422 если manager_id не активен», «принимает manager_id из своего tenant'а») первой строкой добавить `$this->actingAs(User::factory()->for($this->tenant)->create());` и применить R3 (убрать `tenant_id` из body). Файл использует `RefreshDatabase` — created user не протекает между тестами.
|
||
|
||
- [ ] **Step 5: Прогнать deal-тесты — убедиться в green**
|
||
|
||
Run: `cd app && composer test -- --filter="Deal"`
|
||
Run: `cd app && composer test -- --filter="LookupsTest"`
|
||
Expected: PASS — 0 failed в обоих. Точное число тестов — из реального вывода (R4 удалил 8 тестов, R5/R6 добавил/конвертировал 401-тесты).
|
||
|
||
- [ ] **Step 6: Pint + Larastan (regen baseline) + полная регрессия Pest**
|
||
|
||
Run: `cd app && composer pint` → авто-формат применён (в т.ч. удаление неиспользуемого `use App\Models\Tenant;`).
|
||
|
||
Run: `cd app && composer stan` → ожидаются НОВЫЕ ошибки от `$this->user` (новое динамическое свойство в тест-файлах) + сдвиг номеров строк → регенерировать baseline (quirk 25, 3 шага):
|
||
|
||
1. В `app/phpstan.neon` временно закомментировать строку `- phpstan-baseline.neon` в `includes:`.
|
||
2. Run: `cd app && vendor/bin/phpstan analyse --generate-baseline`
|
||
3. Раскомментировать `- phpstan-baseline.neon` в `app/phpstan.neon`.
|
||
|
||
После — повторно `cd app && composer stan` → 0 ошибок.
|
||
|
||
Run: `cd app && composer test` → 0 failed (полная регрессия Pest). Базовый объём перед Sprint 3F (origin/main `ca0c4d9`) — 853 tests / 850 passed / 3 skipped / 0 failed; после Sprint 3F число изменится (J2 +2 теста; J1 −8 удалённых «404 unknown» +5..8 «401 без auth») — **точное число из реального вывода, не экстраполировать**.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add app/routes/web.php app/app/Http/Controllers/Api/DealController.php app/app/Http/Controllers/Api/DealBulkActionController.php app/app/Http/Controllers/Api/DealExportController.php app/app/Http/Controllers/Api/Concerns app/tests/Feature/DealIndexTest.php app/tests/Feature/DealShowTest.php app/tests/Feature/DealCreateTest.php app/tests/Feature/DealUpdateTest.php app/tests/Feature/DealTransitionTest.php app/tests/Feature/DealDestroyTest.php app/tests/Feature/DealRestoreTest.php app/tests/Feature/LookupsTest.php app/phpstan-baseline.neon
|
||
git commit -m "feat(api): J1 — auth:sanctum+tenant middleware на /api/deals*"
|
||
```
|
||
|
||
(`app/app/Http/Controllers/Api/Concerns` в `git add` — на случай, если рефактор ничего там не создаст, путь просто проигнорируется; основное — 4 backend-файла + 8 тестов + baseline.)
|
||
|
||
**НЕ стейджить** `app/dev-indices.json`.
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
- **Spec coverage:** J1 (auth+tenant на `/api/deals*` — 8 роутов, 3 контроллера, 8 тест-файлов) ✅; J2 (стаб-гейт `/api/admin/*`) ✅. CTO-18/Б-1/DO-4 в реестре `Открытые_вопросы` не закрываются (нет «закрываем» от заказчика) — реализуется только код находок.
|
||
- **Placeholder scan:** нет TODO/TBD в коде, кроме намеренного docblock-`TODO` в `EnsureSaasAdmin` (фиксирует, что стаб ждёт реального SSO под Б-1+DO-4 — это документация контракта, не пропуск работы). Тест-миграция задана точным рецептом R1–R10 (механическое преобразование, не placeholder).
|
||
- **Type consistency:** `$tenantId` — `int` во всех 8 методах (`(int) $request->user()->tenant_id`); alias `'saas-admin'` и класс `EnsureSaasAdmin` совпадают между `bootstrap/app.php` и `routes/web.php`; middleware-массив `['auth:sanctum','tenant']` — порядок как в существующих группах (`/api/reminders` и др.).
|
||
- **Атомарность J1:** middleware и миграция тестов — один коммит (Step 1–7 одной задачи); промежуточный red зафиксирован Step 3 как TDD-проверка активности auth-гейта.
|
||
- **Регрессия admin/deal:** J2 на `testing` прозрачен → admin-тесты зелёные без изменений; J1 мигрирует все потребители `/api/deals*` (8 Pest-файлов, включая частично `LookupsTest`) — фронтенд не потребитель backend-тестов, его `tenant_id` backend молча игнорирует.
|