Compare commits

...

12 Commits

Author SHA1 Message Date
Дмитрий 447ef593fa feat(api): J1 — auth:sanctum+tenant middleware на /api/deals*
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 15:18:13 +03:00
Дмитрий 9f70d89046 feat(api): J2 — стаб-гейт EnsureSaasAdmin на /api/admin/* 2026-05-16 15:01:07 +03:00
Дмитрий 42a246d633 docs(plan): Sprint 3F — API middleware (J1/J2) 2026-05-16 14:56:11 +03:00
Дмитрий ca0c4d9318 feat(admin): G5/G6 frontend — incident detail view + РКН-notify 2026-05-16 14:09:53 +03:00
Дмитрий 3269434746 feat(admin): G6 backend — incident РКН-notify endpoint 2026-05-16 14:09:53 +03:00
Дмитрий 5e12126d71 feat(admin): G5 backend — incident detail endpoint 2026-05-16 14:09:53 +03:00
Дмитрий 8e3e06f3a4 fix(admin): G4 review — real AxiosError in error test + balance/NaN guards + a11y 2026-05-16 14:09:53 +03:00
Дмитрий c85424968e feat(admin): G4 frontend — billing row-actions menu + dialogs 2026-05-16 14:09:53 +03:00
Дмитрий 00f6611bc1 fix(admin): G4 review — lockForUpdate on refund balance + self-contained tariff tests 2026-05-16 14:09:53 +03:00
Дмитрий adabcf15a4 feat(admin): G4 backend — billing tenant actions (status/refund/tariff)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 14:09:53 +03:00
Дмитрий 3ea86d62ff docs(plan): Sprint 3D — Admin actions (G4/G5/G6) implementation plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:09:53 +03:00
Дмитрий 9a25e658b3 docs(map): automation-graph — нормативный sync под реколлаж ruflo 16.05
Карта приведена к реколлажу ruflo (Pravila v1.16 / CLAUDE.md v2.2 /
PSR_v1 v3.2 / Tooling v2.2): убраны «уровень −1», «§12 sub-policy»,
«R0 sub-policy delegation pattern».

- 4 узла-правила: лейблы v1.16/v2.2/v3.2/v2.2 + NODE_META.changed 16.05
- nd()-блоки правил: §12 — hard-rule уровня 0, R0 — головной фильтр,
  цепочка 7-уровневая (0–6), §3.5/§4.10 — advisory-подсистема
- ruflo_queen: advisory/automation-подсистема, не entry-point;
  reportsTo → Pravila §14 + CLAUDE.md §3.5/Tooling §4.10
- 4 ребра ruflo_queen→{правило} «перенял sub-policy» → flipped
  {правило}→ruflo_queen (§14 queen-триггер / §3.5 / §4.10 описывают)
- конфликт ruflo_queen↔pravila 🔴🟢 (реколлаж = правило-фикс):
  классификация 🔴2/4/🟢5 → 🔴1/4/🟢6
- §12 sub-policy → hard-rule level 0 в superpowers/hk_economy/mem_sp
  + CONFLICT hk_economy↔superpowers + EDGE_DETAILS

Топология 83/90/11 без изменений (downstream-sync, не iter).
Visual smoke 8/8 PASS (Playwright): 83 узла / 90 рёбер рендерятся,
0 JS-ошибок, легенды отредактированных узлов рендерятся корректно.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:44:14 +03:00
35 changed files with 3930 additions and 355 deletions
@@ -4,7 +4,10 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\ResolvesAdminUserId;
use App\Http\Controllers\Controller;
use App\Models\BalanceTransaction;
use App\Models\SaasAdminAuditLog;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -19,6 +22,183 @@ use Illuminate\Support\Facades\DB;
*/
class AdminBillingController extends Controller
{
use ResolvesAdminUserId;
/** GET /api/admin/billing/tariff-plans — список планов для диалога смены тарифа. */
public function tariffPlans(): JsonResponse
{
$plans = DB::table('tariff_plans')
->select(['id', 'name', 'price_monthly'])
->orderBy('price_monthly')
->get()
->map(fn ($p) => [
'id' => (int) $p->id,
'name' => $p->name,
'price_monthly' => (string) $p->price_monthly,
]);
return response()->json(['plans' => $plans]);
}
/** PATCH /api/admin/billing/tenants/{id}/status — приостановить/разблокировать тенанта. */
public function updateStatus(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'status' => ['required', 'in:active,suspended'],
'reason' => ['required', 'string', 'min:10', 'max:1000'],
]);
$tenant = $this->findActiveTenant($id);
$adminUserId = $this->resolveAdminUserId($request, 'system-billing@liderra.local', 'System Billing Bot');
DB::transaction(function () use ($tenant, $validated, $adminUserId, $request): void {
// tenants / saas_admin_audit_log не под RLS — SET LOCAL не нужен (ср. refund()).
DB::table('tenants')->where('id', $tenant->id)->update([
'status' => $validated['status'],
'updated_at' => now(),
]);
SaasAdminAuditLog::create([
'admin_user_id' => $adminUserId,
'action' => $validated['status'] === 'suspended' ? 'tenant.suspend' : 'tenant.activate',
'target_type' => 'tenant',
'target_id' => $tenant->id,
'target_tenant_id' => $tenant->id,
'payload_before' => ['status' => $tenant->status],
'payload_after' => ['status' => $validated['status']],
'reason' => $validated['reason'],
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
]);
});
return response()->json(['id' => $tenant->id, 'status' => $validated['status']]);
}
/** POST /api/admin/billing/tenants/{id}/refund — возврат средств: списание с баланса + ledger-запись. */
public function refund(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'amount_rub' => ['required', 'numeric', 'gt:0'],
'reason' => ['required', 'string', 'min:10', 'max:1000'],
]);
$this->findActiveTenant($id); // ранний 404; авторитетный баланс перечитывается под локом ниже
$amount = number_format((float) $validated['amount_rub'], 2, '.', '');
$adminUserId = $this->resolveAdminUserId($request, 'system-billing@liderra.local', 'System Billing Bot');
/** @var array{transaction_id:int, balance_rub:string} $result */
$result = DB::transaction(function () use ($id, $amount, $validated, $adminUserId, $request): array {
DB::statement('SET LOCAL app.current_tenant_id = '.$id);
// Баланс — money-колонка: перечитываем под row-lock внутри транзакции,
// защита от lost-update (конвенция LedgerService — lockForUpdate на tenants).
$tenant = DB::table('tenants')->where('id', $id)->whereNull('deleted_at')
->lockForUpdate()->first();
if ($tenant === null) {
abort(404, 'tenant not found');
}
$balance = (string) $tenant->balance_rub;
if (bccomp($amount, $balance, 2) === 1) {
abort(422, 'refund amount exceeds tenant balance');
}
$newBalance = bcsub($balance, $amount, 2);
DB::table('tenants')->where('id', $id)->update([
'balance_rub' => $newBalance,
'updated_at' => now(),
]);
$tx = BalanceTransaction::create([
'tenant_id' => $id,
'type' => BalanceTransaction::TYPE_REFUND,
'amount_rub' => '-'.$amount,
'amount_leads' => 0,
'balance_rub_after' => $newBalance,
'description' => $validated['reason'],
'admin_user_id' => $adminUserId,
'created_at' => now(),
]);
SaasAdminAuditLog::create([
'admin_user_id' => $adminUserId,
'action' => 'tenant.refund',
'target_type' => 'tenant',
'target_id' => $id,
'target_tenant_id' => $id,
'payload_before' => ['balance_rub' => $balance],
'payload_after' => ['balance_rub' => $newBalance, 'amount_rub' => $amount, 'transaction_id' => $tx->id],
'reason' => $validated['reason'],
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
]);
return ['transaction_id' => (int) $tx->id, 'balance_rub' => $newBalance];
});
return response()->json([
'id' => $id,
'balance_rub' => $result['balance_rub'],
'transaction_id' => $result['transaction_id'],
]);
}
/** PATCH /api/admin/billing/tenants/{id}/tariff — сменить тарифный план тенанта. */
public function changeTariff(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'tariff_id' => ['required', 'integer', 'exists:tariff_plans,id'],
'reason' => ['required', 'string', 'min:10', 'max:1000'],
]);
$tenant = $this->findActiveTenant($id);
$tariff = DB::table('tariff_plans')->where('id', $validated['tariff_id'])->first();
$adminUserId = $this->resolveAdminUserId($request, 'system-billing@liderra.local', 'System Billing Bot');
DB::transaction(function () use ($tenant, $tariff, $validated, $adminUserId, $request): void {
// tenants / saas_admin_audit_log не под RLS — SET LOCAL не нужен (ср. refund()).
DB::table('tenants')->where('id', $tenant->id)->update([
'current_tariff_id' => $tariff->id,
'updated_at' => now(),
]);
SaasAdminAuditLog::create([
'admin_user_id' => $adminUserId,
'action' => 'tenant.change_tariff',
'target_type' => 'tenant',
'target_id' => $tenant->id,
'target_tenant_id' => $tenant->id,
'payload_before' => ['current_tariff_id' => $tenant->current_tariff_id],
'payload_after' => ['current_tariff_id' => (int) $tariff->id],
'reason' => $validated['reason'],
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
]);
});
return response()->json([
'id' => $tenant->id,
'tariff_id' => (int) $tariff->id,
'tariff_name' => $tariff->name,
]);
}
/**
* Возвращает не-удалённого тенанта либо abort(404).
*
* @return object{id:int,status:string,balance_rub:string,current_tariff_id:int|null}
*/
private function findActiveTenant(int $id): object
{
$tenant = DB::table('tenants')->where('id', $id)->whereNull('deleted_at')->first();
if ($tenant === null) {
abort(404, 'tenant not found');
}
return $tenant;
}
/** GET /api/admin/billing?search= */
public function index(Request $request): JsonResponse
{
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\ResolvesAdminUserId;
use App\Http\Controllers\Controller;
use App\Models\SaasAdminAuditLog;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -21,6 +23,8 @@ use Illuminate\Support\Facades\DB;
*/
class AdminIncidentsController extends Controller
{
use ResolvesAdminUserId;
/** GET /api/admin/incidents?type=&severity=&unresolved_only=&limit=&offset= */
public function index(Request $request): JsonResponse
{
@@ -83,6 +87,116 @@ class AdminIncidentsController extends Controller
]);
}
/** POST /api/admin/incidents/{id}/rkn-notify — зафиксировать уведомление РКН (G6, 152-ФЗ). */
public function notifyRkn(Request $request, int $id): JsonResponse
{
$row = DB::table('incidents_log')->where('id', $id)->first();
if ($row === null) {
abort(404, 'incident not found');
}
if ($row->type !== 'data_breach') {
abort(422, 'РКН-уведомление применимо только к инцидентам типа data_breach');
}
if ($row->rkn_notified_at !== null) {
abort(409, 'РКН уже уведомлён по этому инциденту');
}
$adminUserId = $this->resolveAdminUserId($request, 'system-incidents@liderra.local', 'System Incidents Bot');
DB::transaction(function () use ($row, $adminUserId, $request): void {
DB::table('incidents_log')->where('id', $row->id)->update([
'rkn_notified_at' => now(),
'updated_at' => now(),
]);
SaasAdminAuditLog::create([
'admin_user_id' => $adminUserId,
'action' => 'incident.rkn_notify',
'target_type' => 'incident',
'target_id' => $row->id,
'payload_before' => ['rkn_notified_at' => null],
'payload_after' => ['rkn_notified_at' => now()->toIso8601String()],
'reason' => 'Роскомнадзор уведомлён об утечке ПДн через админ-интерфейс (152-ФЗ).',
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
]);
});
return $this->show($id);
}
/** GET /api/admin/incidents/{id} — полная карточка инцидента (drill-down G5). */
public function show(int $id): JsonResponse
{
$row = DB::table('incidents_log')->where('id', $id)->first();
if ($row === null) {
abort(404, 'incident not found');
}
$tenantIds = is_array($row->affected_tenant_ids)
? $row->affected_tenant_ids
: ($row->affected_tenant_ids !== null ? $this->parsePgArrayValues((string) $row->affected_tenant_ids) : []);
$tenants = $tenantIds === []
? collect()
: DB::table('tenants')->whereIn('id', $tenantIds)
->select(['id', 'organization_name'])->get();
$admins = DB::table('saas_admin_users')
->whereIn('id', array_filter([$row->created_by_admin_id, $row->closed_by_admin_id]))
->pluck('full_name', 'id');
return response()->json([
'incident' => [
'id' => (int) $row->id,
'incident_id' => $this->formatIncidentId($row),
'type' => $row->type,
'severity' => $row->severity,
'summary' => $row->summary,
'root_cause' => $row->root_cause,
'postmortem_url' => $row->postmortem_url,
'started_at' => CarbonImmutable::parse($row->started_at)->toIso8601String(),
'detected_at' => CarbonImmutable::parse($row->detected_at)->toIso8601String(),
'resolved_at' => $row->resolved_at !== null
? CarbonImmutable::parse($row->resolved_at)->toIso8601String() : null,
'status' => $this->deriveStatus($row),
'affected_tenants' => $tenants->map(fn ($t) => [
'id' => (int) $t->id,
'organization_name' => $t->organization_name,
])->values(),
'affected_users_count' => $row->affected_users_count !== null ? (int) $row->affected_users_count : null,
'notification_sent_at' => $row->notification_sent_at !== null
? CarbonImmutable::parse($row->notification_sent_at)->toIso8601String() : null,
'rkn_notified' => $row->rkn_notified_at !== null,
'rkn_notified_at' => $row->rkn_notified_at !== null
? CarbonImmutable::parse($row->rkn_notified_at)->toIso8601String() : null,
'rkn_deadline_at' => $row->type === 'data_breach' && $row->rkn_notified_at === null
? CarbonImmutable::parse($row->detected_at)->addHours(24)->toIso8601String() : null,
'created_by_admin' => $admins->get($row->created_by_admin_id),
'closed_by_admin' => $row->closed_by_admin_id !== null ? $admins->get($row->closed_by_admin_id) : null,
'created_at' => $row->created_at !== null
? CarbonImmutable::parse($row->created_at)->toIso8601String() : null,
'updated_at' => $row->updated_at !== null
? CarbonImmutable::parse($row->updated_at)->toIso8601String() : null,
],
]);
}
/**
* PG-array literal '{1,2,3}' массив int.
*
* @return list<int>
*/
private function parsePgArrayValues(string $literal): array
{
$trimmed = trim($literal, '{}');
if ($trimmed === '') {
return [];
}
return array_map('intval', explode(',', $trimmed));
}
/** Уникальный человеко-читаемый ID: INC-YYYY-MMDD-NNNN, NNNN = id padded. */
private function formatIncidentId(object $row): string
{
@@ -7,7 +7,6 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\Deal;
use App\Models\Tenant;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -20,6 +19,8 @@ use Illuminate\Support\Facades\DB;
* bulk + export + helpers). Этот класс отвечает только за многоразовые
* массовые операции; single-resource действия остаются в DealController.
*
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
*
* O-perf-01: N+1 устранён.
*
* transition: сначала SELECT всех сделок tenant'а из ids, чтобы отфильтровать
@@ -41,23 +42,19 @@ class DealBulkActionController extends Controller
/**
* POST /api/deals/transition bulk status-update.
*
* Body: {tenant_id, ids: [int...], status: slug}.
* Body: {ids: [int...], status: slug}.
* Response: {updated, requested, status} (updated = реально изменённых,
* без NO-OP).
*/
public function transition(Request $request): JsonResponse
{
$validated = $request->validate([
'tenant_id' => 'required|integer|min:1',
'ids' => 'required|array|min:1|max:1000',
'ids.*' => 'integer|min:1',
'status' => 'required|string|max:50',
]);
$tenant = Tenant::find($validated['tenant_id']);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
$tenantId = (int) $request->user()->tenant_id;
$statusExists = DB::table('lead_statuses')->where('slug', $validated['status'])->exists();
if (! $statusExists) {
@@ -67,14 +64,14 @@ class DealBulkActionController extends Controller
], 422);
}
$updated = DB::transaction(function () use ($validated, $tenant) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$updated = DB::transaction(function () use ($validated, $tenantId) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// Фаза 1: SELECT — нужны id и предыдущий status для каждой строки,
// чтобы (а) отфильтровать NO-OP и (б) сохранить prev в context.from.
// Defense-in-depth where(tenant_id) — защита от кросс-tenant id.
$rows = Deal::query()
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->whereIn('id', $validated['ids'])
->get(['id', 'status']);
@@ -88,7 +85,7 @@ class DealBulkActionController extends Controller
// Фаза 2: bulk-UPDATE 1 запросом вместо N.
Deal::query()
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->whereIn('id', $changedIds)
->update([
'status' => $validated['status'],
@@ -100,7 +97,7 @@ class DealBulkActionController extends Controller
// массив сериализуем в JSON руками, остальные scalar-поля передаём
// напрямую. Триггер audit_chain_hash() заполнит log_hash на уровне БД.
$logRows = $changed->map(fn (Deal $d) => [
'tenant_id' => $tenant->id,
'tenant_id' => $tenantId,
'user_id' => null,
'deal_id' => $d->id,
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
@@ -127,7 +124,7 @@ class DealBulkActionController extends Controller
/**
* DELETE /api/deals bulk soft-delete.
*
* Body: {tenant_id, ids: [int...]}.
* Body: {ids: [int...]}.
* Response: {deleted, requested}.
*
* Soft-delete сохраняется (см. документацию в DealController.destroy на
@@ -137,23 +134,19 @@ class DealBulkActionController extends Controller
public function destroy(Request $request): JsonResponse
{
$validated = $request->validate([
'tenant_id' => 'required|integer|min:1',
'ids' => 'required|array|min:1|max:1000',
'ids.*' => 'integer|min:1',
]);
$tenant = Tenant::find($validated['tenant_id']);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
$tenantId = (int) $request->user()->tenant_id;
$deleted = DB::transaction(function () use ($validated, $tenant) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$deleted = DB::transaction(function () use ($validated, $tenantId) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// SELECT id'шников живых сделок tenant'а из ids — для bulk-INSERT
// в activity_log по списку реально удаляемых (NO-OP idempotency).
$targetIds = Deal::query()
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->whereIn('id', $validated['ids'])
->whereNull('deleted_at')
->pluck('id')
@@ -166,7 +159,7 @@ class DealBulkActionController extends Controller
$now = now();
Deal::query()
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->whereIn('id', $targetIds)
->whereNull('deleted_at')
->update([
@@ -175,7 +168,7 @@ class DealBulkActionController extends Controller
]);
$logRows = array_map(fn (int $id) => [
'tenant_id' => $tenant->id,
'tenant_id' => $tenantId,
'user_id' => null,
'deal_id' => $id,
'event' => ActivityLog::EVENT_DEAL_DELETED,
@@ -197,30 +190,26 @@ class DealBulkActionController extends Controller
/**
* POST /api/deals/restore bulk restore soft-deleted.
*
* Body: {tenant_id, ids: [int...]}.
* Body: {ids: [int...]}.
* Response: {restored, requested}.
*/
public function restore(Request $request): JsonResponse
{
$validated = $request->validate([
'tenant_id' => 'required|integer|min:1',
'ids' => 'required|array|min:1|max:1000',
'ids.*' => 'integer|min:1',
]);
$tenant = Tenant::find($validated['tenant_id']);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
$tenantId = (int) $request->user()->tenant_id;
$restored = DB::transaction(function () use ($validated, $tenant) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$restored = DB::transaction(function () use ($validated, $tenantId) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// withTrashed обходит SoftDeletes global scope; whereNotNull —
// NO-OP idempotency для уже живых.
$targetIds = Deal::query()
->withTrashed()
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->whereIn('id', $validated['ids'])
->whereNotNull('deleted_at')
->pluck('id')
@@ -234,7 +223,7 @@ class DealBulkActionController extends Controller
Deal::query()
->withTrashed()
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->whereIn('id', $targetIds)
->whereNotNull('deleted_at')
->update([
@@ -243,7 +232,7 @@ class DealBulkActionController extends Controller
]);
$logRows = array_map(fn (int $id) => [
'tenant_id' => $tenant->id,
'tenant_id' => $tenantId,
'user_id' => null,
'deal_id' => $id,
'event' => ActivityLog::EVENT_DEAL_RESTORED,
+21 -50
View File
@@ -9,7 +9,6 @@ use App\Models\ActivityLog;
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierLeadCost;
use App\Models\Tenant;
use App\Models\User;
use App\Services\SupplierResolver;
use Illuminate\Http\JsonResponse;
@@ -27,9 +26,7 @@ use Illuminate\Support\Facades\DB;
* `WebhookReceiveController` + `ProcessWebhookJob` (асинхронно через очередь
* с advisory lock + dedup). Этот controller для ручных action'ов из UI.
*
* На MVP без auth-middleware (multi-tenant контекст резолвится по
* `tenant_id` параметру). Production: middleware('auth:sanctum')+'tenant'
* tenant_id из request()->user()->tenant_id; user ID для manager/audit.
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
*
* Manual-create отличается от webhook'а:
* - source_crm_id = NULL (не из webhook).
@@ -42,7 +39,7 @@ use Illuminate\Support\Facades\DB;
class DealController extends Controller
{
/**
* GET /api/deals?tenant_id={id}&status_in[]=...&project_id=...&manager_id=...&search=...&limit=...&offset=...
* GET /api/deals?status_in[]=...&project_id=...&manager_id=...&search=...&limit=...&offset=...
*
* Список сделок tenant'а с relations (project.name, manager.first/last/email).
* Используется в `DealsView`/`KanbanView` вместо MOCK_DEALS.
@@ -53,20 +50,10 @@ class DealController extends Controller
* (received_at, id)).
*
* RLS: SET LOCAL app.current_tenant_id внутри транзакции (PgBouncer-safe).
* Чужие сделки отфильтрует политика, даже если клиент подсунет чужой
* tenant_id (без auth на MVP, на prod middleware).
*/
public function index(Request $request): JsonResponse
{
$tenantId = (int) $request->query('tenant_id', '0');
if ($tenantId < 1) {
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
}
$tenant = Tenant::find($tenantId);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
$tenantId = (int) $request->user()->tenant_id;
$statuses = (array) $request->query('status_in', []);
$projectId = $request->query('project_id') !== null ? (int) $request->query('project_id') : null;
@@ -203,7 +190,7 @@ class DealController extends Controller
}
/**
* GET /api/deals/{id}?tenant_id={id} детали сделки + recent activity events.
* GET /api/deals/{id} детали сделки + recent activity events.
*
* Используется в DealDetailDrawer (правая панель). Возвращает deal с
* relations + до 50 последних activity_log событий по этой сделке.
@@ -213,15 +200,7 @@ class DealController extends Controller
*/
public function show(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->query('tenant_id', '0');
if ($tenantId < 1) {
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
}
$tenant = Tenant::find($tenantId);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
$tenantId = (int) $request->user()->tenant_id;
[$deal, $events] = DB::transaction(function () use ($tenantId, $id) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
@@ -291,7 +270,7 @@ class DealController extends Controller
/**
* PATCH /api/deals/{id} частичное редактирование сделки из DealDetailDrawer.
*
* Body (все поля optional, должно быть хотя бы одно): {tenant_id, comment?,
* Body (все поля optional, должно быть хотя бы одно): {comment?,
* manager_id?, status?}.
*
* Каждое изменение пишется в ActivityLog с правильным event-type:
@@ -309,16 +288,12 @@ class DealController extends Controller
public function update(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'tenant_id' => 'required|integer|min:1',
'comment' => 'nullable|string|max:5000',
'manager_id' => 'nullable|integer|min:1',
'status' => 'nullable|string|max:50',
]);
$tenant = Tenant::find($validated['tenant_id']);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
$tenantId = (int) $request->user()->tenant_id;
// Validate status slug если передан.
if (array_key_exists('status', $validated) && $validated['status'] !== null) {
@@ -335,7 +310,7 @@ class DealController extends Controller
if (array_key_exists('manager_id', $validated) && $validated['manager_id'] !== null) {
$managerExists = User::query()
->where('id', $validated['manager_id'])
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->whereNull('deleted_at')
->where('is_active', true)
->exists();
@@ -347,11 +322,11 @@ class DealController extends Controller
}
}
$deal = DB::transaction(function () use ($validated, $tenant, $id) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$deal = DB::transaction(function () use ($validated, $tenantId, $id) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$deal = Deal::query()
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->where('id', $id)
->first();
@@ -363,7 +338,7 @@ class DealController extends Controller
if (array_key_exists('comment', $validated) && $deal->comment !== $validated['comment']) {
$deal->comment = $validated['comment'];
ActivityLog::create([
'tenant_id' => $tenant->id,
'tenant_id' => $tenantId,
'user_id' => null,
'deal_id' => $deal->id,
'event' => 'deal.commented',
@@ -376,7 +351,7 @@ class DealController extends Controller
$deal->manager_id = $validated['manager_id'];
$deal->assigned_at = $validated['manager_id'] !== null ? now() : null;
ActivityLog::create([
'tenant_id' => $tenant->id,
'tenant_id' => $tenantId,
'user_id' => null,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_ASSIGNED,
@@ -388,7 +363,7 @@ class DealController extends Controller
$previousStatus = $deal->status;
$deal->status = $validated['status'];
ActivityLog::create([
'tenant_id' => $tenant->id,
'tenant_id' => $tenantId,
'user_id' => null,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
@@ -425,7 +400,6 @@ class DealController extends Controller
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'tenant_id' => 'required|integer|min:1',
'project_name' => 'required|string|max:255',
'phone' => 'required|string|max:20',
'contact_name' => 'nullable|string|max:255',
@@ -434,17 +408,14 @@ class DealController extends Controller
'comment' => 'nullable|string|max:5000',
]);
$tenant = Tenant::find($validated['tenant_id']);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
$tenantId = (int) $request->user()->tenant_id;
// Manager FK guard: если manager_id передан, он должен принадлежать
// этому tenant'у. Иначе можно назначить чужого менеджера на свою сделку.
if (isset($validated['manager_id'])) {
$managerExists = User::query()
->where('id', $validated['manager_id'])
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->whereNull('deleted_at')
->where('is_active', true)
->exists();
@@ -459,16 +430,16 @@ class DealController extends Controller
$statusSlug = $validated['status'] ?? 'new';
// Транзакция + RLS: SET LOCAL внутри (PgBouncer-safe).
$deal = DB::transaction(function () use ($validated, $tenant, $statusSlug) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$deal = DB::transaction(function () use ($validated, $tenantId, $statusSlug) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$project = Project::firstOrCreate(
['tenant_id' => $tenant->id, 'name' => $validated['project_name']],
['tenant_id' => $tenantId, 'name' => $validated['project_name']],
['type' => 'manual'],
);
$deal = Deal::create([
'tenant_id' => $tenant->id,
'tenant_id' => $tenantId,
'source_crm_id' => null, // manual
'project_id' => $project->id,
'phone' => $validated['phone'],
@@ -499,7 +470,7 @@ class DealController extends Controller
}
ActivityLog::create([
'tenant_id' => $tenant->id,
'tenant_id' => $tenantId,
'user_id' => null, // на prod — request()->user()->id
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_CREATED,
@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Deal;
use App\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use OpenSpout\Common\Entity\Row;
@@ -21,13 +20,15 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
*
* Извлечено из DealController (Sprint 3 Phase A, audit O-refactor-01).
*
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
*
* O-perf-05: streaming устраняет memory pressure. PhpSpreadsheet строил
* полный объект .xlsx в памяти (для 10K сделок 100+ MB). OpenSpout пишет
* в php://output постранично через Writer + Row::fromValues и chunkById(500)
* по сделкам пик памяти O(1) от размера экспорта.
*
* API контракт сохранён:
* POST /api/deals/export {tenant_id, ids[], format?: csv|xlsx}
* POST /api/deals/export {ids[], format?: csv|xlsx}
* Headers Content-Type / Content-Disposition без изменений.
* CSV: UTF-8 + BOM + ;-разделитель (Excel-friendly RU-локаль).
* XLSX: bold-header + auto-size columns.
@@ -43,16 +44,12 @@ class DealExportController extends Controller
public function export(Request $request): StreamedResponse
{
$validated = $request->validate([
'tenant_id' => 'required|integer|min:1',
'ids' => 'required|array|min:1|max:10000',
'ids.*' => 'integer|min:1',
'format' => 'nullable|string|in:csv,xlsx',
]);
$tenant = Tenant::find($validated['tenant_id']);
if ($tenant === null) {
abort(404, 'Тенант не найден.');
}
$tenantId = (int) $request->user()->tenant_id;
$format = $validated['format'] ?? 'csv';
$filename = 'deals_export_'.now()->format('Y-m-d').'.'.$format;
@@ -67,13 +64,13 @@ class DealExportController extends Controller
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
];
return new StreamedResponse(function () use ($validated, $tenant, $format) {
return new StreamedResponse(function () use ($validated, $tenantId, $format) {
// RLS-контекст должен быть установлен внутри транзакции на момент
// фактического SELECT. StreamedResponse callback вызывается уже
// после Laravel-response pipeline'а, поэтому открываем транзакцию
// прямо здесь.
DB::transaction(function () use ($validated, $tenant, $format) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
DB::transaction(function () use ($validated, $tenantId, $format) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$writer = $this->openWriter($format);
$writer->openToFile('php://output');
@@ -93,7 +90,7 @@ class DealExportController extends Controller
// chunkById(500) — keyset-friendly; в нашем DealsView это
// редкий тяжёлый action, экспортировать могут до 10K id.
Deal::query()
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->whereIn('id', $validated['ids'])
->orderBy('id')
->chunkById(500, function ($deals) use ($writer) {
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Concerns;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Резолв saas_admin_users.id для audit-trail на MVP (saas-admin SSO Б-1).
*
* Берёт admin_user_id из request-параметра; при отсутствии валидного
* создаёт/переиспользует системный стаб-аккаунт (не loginable, is_active=false),
* чтобы соблюсти NOT NULL + FK на saas_admin_users в saas_admin_audit_log.
*
* Паттерн ранее дублировался в AdminPricingTiersController /
* AdminSystemSettingsController; новый код использует этот трейт.
*/
trait ResolvesAdminUserId
{
protected function resolveAdminUserId(Request $request, string $stubEmail, string $stubName): int
{
$requested = $request->input('admin_user_id');
if (is_int($requested) || (is_string($requested) && ctype_digit($requested))) {
$existing = DB::table('saas_admin_users')->where('id', (int) $requested)->value('id');
if ($existing !== null) {
return (int) $existing;
}
}
$existingId = DB::table('saas_admin_users')->where('email', $stubEmail)->value('id');
if ($existingId !== null) {
return (int) $existingId;
}
return (int) DB::table('saas_admin_users')->insertGetId([
'email' => $stubEmail,
'full_name' => $stubName,
'password_hash' => '$2y$04$system-stub-not-loginable',
'role' => 'super_admin',
'is_active' => false,
'sso_provider' => 'local',
'is_break_glass' => false,
]);
}
}
@@ -0,0 +1,38 @@
<?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);
}
}
+2
View File
@@ -1,5 +1,6 @@
<?php
use App\Http\Middleware\EnsureSaasAdmin;
use App\Http\Middleware\SetTenantContext;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
@@ -18,6 +19,7 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->alias([
'tenant' => SetTenantContext::class,
'saas-admin' => EnsureSaasAdmin::class,
]);
// Webhook receive endpoint (POST /api/webhook/{token}) не должен требовать
+156 -12
View File
@@ -180,12 +180,54 @@ parameters:
count: 3
path: tests/Feature/Admin/AdminSuppliersControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/AdminBillingActionsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:patchJson\(\)\.$#'
identifier: method.notFound
count: 7
path: tests/Feature/AdminBillingActionsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/AdminBillingActionsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 10
path: tests/Feature/AdminBillingIndexTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#'
identifier: property.notFound
count: 4
path: tests/Feature/AdminIncidentRknNotifyTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 4
path: tests/Feature/AdminIncidentRknNotifyTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#'
identifier: property.notFound
count: 5
path: tests/Feature/AdminIncidentShowTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/Feature/AdminIncidentShowTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#'
identifier: property.notFound
@@ -651,7 +693,19 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 37
count: 15
path: tests/Feature/DealCreateTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/DealCreateTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/DealCreateTest.php
-
@@ -675,7 +729,19 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 20
count: 11
path: tests/Feature/DealDestroyTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/DealDestroyTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/DealDestroyTest.php
-
@@ -717,13 +783,25 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 50
count: 30
path: tests/Feature/DealIndexTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/DealIndexTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/DealIndexTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 22
count: 21
path: tests/Feature/DealIndexTest.php
-
@@ -741,7 +819,19 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 18
count: 9
path: tests/Feature/DealRestoreTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/DealRestoreTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/DealRestoreTest.php
-
@@ -777,19 +867,31 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
identifier: property.notFound
count: 7
count: 6
path: tests/Feature/DealShowTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 20
count: 13
path: tests/Feature/DealShowTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/DealShowTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/DealShowTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 8
count: 7
path: tests/Feature/DealShowTest.php
-
@@ -807,7 +909,19 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 12
count: 7
path: tests/Feature/DealTransitionTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/DealTransitionTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/DealTransitionTest.php
-
@@ -831,19 +945,31 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
identifier: property.notFound
count: 10
count: 9
path: tests/Feature/DealUpdateTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 24
count: 15
path: tests/Feature/DealUpdateTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/DealUpdateTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/DealUpdateTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:patchJson\(\)\.$#'
identifier: method.notFound
count: 10
count: 9
path: tests/Feature/DealUpdateTest.php
-
@@ -912,6 +1038,12 @@ parameters:
count: 16
path: tests/Feature/LookupsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/LookupsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
@@ -1200,6 +1332,18 @@ parameters:
count: 5
path: tests/Feature/RlsSmokeTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$app\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/SaasAdminMiddlewareTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/SaasAdminMiddlewareTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
identifier: property.notFound
+97
View File
@@ -331,3 +331,100 @@ export async function updateSystemSetting(
);
return data;
}
// === SaaS-admin → Биллинг: row-actions (Sprint 3D G4) ===
export interface AdminTariffPlan {
id: number;
name: string;
price_monthly: string;
}
export async function listAdminTariffPlans(): Promise<AdminTariffPlan[]> {
const { data } = await apiClient.get<{ plans: AdminTariffPlan[] }>('/api/admin/billing/tariff-plans');
return data.plans;
}
export async function updateTenantStatus(
id: number,
status: 'active' | 'suspended',
reason: string,
): Promise<{ id: number; status: string }> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<{ id: number; status: string }>(
`/api/admin/billing/tenants/${id}/status`,
{ status, reason },
);
return data;
}
export async function refundTenant(
id: number,
amountRub: number,
reason: string,
): Promise<{ id: number; balance_rub: string; transaction_id: number }> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ id: number; balance_rub: string; transaction_id: number }>(
`/api/admin/billing/tenants/${id}/refund`,
{ amount_rub: amountRub, reason },
);
return data;
}
export async function changeTenantTariff(
id: number,
tariffId: number,
reason: string,
): Promise<{ id: number; tariff_id: number; tariff_name: string }> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<{ id: number; tariff_id: number; tariff_name: string }>(
`/api/admin/billing/tenants/${id}/tariff`,
{ tariff_id: tariffId, reason },
);
return data;
}
// === SaaS-admin → Инциденты: detail-view + РКН-notify (Sprint 3D G5/G6) ===
export interface ApiIncidentAffectedTenant {
id: number;
organization_name: string;
}
export interface ApiAdminIncidentDetail {
id: number;
incident_id: string;
type: string;
severity: 'low' | 'medium' | 'high' | 'critical';
summary: string;
root_cause: string | null;
postmortem_url: string | null;
started_at: string;
detected_at: string;
resolved_at: string | null;
status: 'open' | 'investigating' | 'resolved';
affected_tenants: ApiIncidentAffectedTenant[];
affected_users_count: number | null;
notification_sent_at: string | null;
rkn_notified: boolean;
rkn_notified_at: string | null;
rkn_deadline_at: string | null;
created_by_admin: string | null;
closed_by_admin: string | null;
created_at: string | null;
updated_at: string | null;
}
export async function getAdminIncidentDetail(id: number): Promise<ApiAdminIncidentDetail> {
const { data } = await apiClient.get<{ incident: ApiAdminIncidentDetail }>(`/api/admin/incidents/${id}`);
return data.incident;
}
export async function notifyIncidentRkn(id: number): Promise<ApiAdminIncidentDetail> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ incident: ApiAdminIncidentDetail }>(
`/api/admin/incidents/${id}/rkn-notify`,
{},
);
return data.incident;
}
+6
View File
@@ -210,6 +210,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/admin/AdminIncidentsView.vue'),
meta: { layout: 'admin', title: 'Инциденты', requiresAuth: true, devIndex: 24, devLabel: 'Admin Incidents' },
},
{
path: '/admin/incidents/:id',
name: 'admin-incident-detail',
component: () => import('../views/admin/AdminIncidentDetailView.vue'),
meta: { layout: 'admin', title: 'Инцидент', requiresAuth: true },
},
{
path: '/admin/system',
name: 'admin-system',
@@ -12,6 +12,8 @@ import { ADMIN_BILLING_SUMMARY as MOCK_SUMMARY, ADMIN_BILLING_TENANTS } from '..
import { computed, onMounted, reactive, ref } from 'vue';
import { usePolling } from '../../composables/usePolling';
import * as adminApi from '../../api/admin';
import type { AdminTariffPlan } from '../../api/admin';
import { extractErrorMessage } from '../../api/client';
const search = ref('');
@@ -95,7 +97,92 @@ async function loadBilling() {
onMounted(loadBilling);
usePolling(loadBilling);
defineExpose({ rowsState, summary, loading, fetchError, loadBilling });
// === Row-actions state (Sprint 3D G4) ===
const actionDialog = ref<null | 'status' | 'refund' | 'tariff'>(null);
const actionRow = ref<BillingRow | null>(null);
const actionReason = ref('');
const actionAmount = ref<number | null>(null);
const actionTariffId = ref<number | null>(null);
const actionLoading = ref(false);
const actionError = ref('');
const tariffPlans = ref<AdminTariffPlan[]>([]);
async function openAction(type: 'status' | 'refund' | 'tariff', row: BillingRow) {
actionDialog.value = type;
actionRow.value = row;
actionReason.value = '';
actionAmount.value = null;
actionTariffId.value = null;
actionError.value = '';
if (type === 'tariff') {
try {
tariffPlans.value = await adminApi.listAdminTariffPlans();
} catch (e) {
actionError.value = extractErrorMessage(e);
}
}
}
async function confirmAction() {
// Validate
if (actionReason.value.trim().length < 10) {
actionError.value = 'Укажите основание (минимум 10 символов).';
return;
}
if (actionDialog.value === 'refund' && (actionAmount.value === null || !Number.isFinite(actionAmount.value) || actionAmount.value <= 0)) {
actionError.value = 'Укажите сумму возврата больше нуля.';
return;
}
if (actionDialog.value === 'refund' && actionAmount.value! > actionRow.value!.balance_rub) {
actionError.value = 'Сумма возврата превышает баланс тенанта.';
return;
}
if (actionDialog.value === 'tariff' && actionTariffId.value === null) {
actionError.value = 'Выберите тарифный план.';
return;
}
const row = actionRow.value!;
actionLoading.value = true;
actionError.value = '';
try {
if (actionDialog.value === 'status') {
const newStatus = row.status === 'suspended' ? 'active' : 'suspended';
await adminApi.updateTenantStatus(row.id, newStatus, actionReason.value.trim());
} else if (actionDialog.value === 'refund') {
await adminApi.refundTenant(row.id, actionAmount.value!, actionReason.value.trim());
} else if (actionDialog.value === 'tariff') {
await adminApi.changeTenantTariff(row.id, actionTariffId.value!, actionReason.value.trim());
}
await loadBilling();
actionDialog.value = null;
} catch (e) {
actionError.value = extractErrorMessage(e);
} finally {
actionLoading.value = false;
}
}
defineExpose({
rowsState,
summary,
loading,
fetchError,
loadBilling,
actionDialog,
actionRow,
actionReason,
actionAmount,
actionTariffId,
actionError,
actionLoading,
tariffPlans,
openAction,
confirmAction,
});
const headers = [
{ title: 'Тенант', key: 'name', sortable: true },
@@ -105,6 +192,7 @@ const headers = [
{ title: 'Списания за мес', key: 'monthly_charges_rub', sortable: true, align: 'end' as const },
{ title: 'MRR', key: 'mrr_rub', sortable: true, align: 'end' as const },
{ title: 'Статус', key: 'status', sortable: true },
{ title: '', key: 'actions', sortable: false, align: 'end' as const },
];
const filteredRows = computed(() => {
@@ -257,8 +345,189 @@ function tariffLabel(t: string): string {
{{ statusInfo(item.status).label }}
</v-chip>
</template>
<template #[`item.actions`]="{ item }">
<v-menu>
<template #activator="{ props }">
<v-btn
v-bind="props"
icon="mdi-dots-vertical"
:data-testid="`row-actions-${item.id}`"
variant="text"
size="small"
aria-label="Действия с тенантом"
/>
</template>
<v-list density="compact">
<v-list-item
:title="item.status === 'suspended' ? 'Разблокировать' : 'Приостановить'"
prepend-icon="mdi-account-cancel"
@click="openAction('status', item)"
/>
<v-list-item
title="Возврат средств"
prepend-icon="mdi-cash-refund"
@click="openAction('refund', item)"
/>
<v-list-item
title="Сменить тариф"
prepend-icon="mdi-swap-horizontal"
@click="openAction('tariff', item)"
/>
</v-list>
</v-menu>
</template>
</v-data-table>
</v-card>
<!-- Dialog: suspend / activate -->
<v-dialog :model-value="actionDialog === 'status'" max-width="480" @update:model-value="actionDialog = null">
<v-card>
<v-card-title>
{{ actionRow?.status === 'suspended' ? 'Разблокировать тенанта' : 'Приостановить тенанта' }}
</v-card-title>
<v-card-text>
<p class="mb-3 text-body-2">
Тенант: <strong>{{ actionRow?.name }}</strong>
</p>
<v-alert
v-if="actionError"
type="error"
variant="tonal"
density="compact"
class="mb-3"
data-testid="action-error"
>
{{ actionError }}
</v-alert>
<v-textarea
v-model="actionReason"
label="Основание"
placeholder="Минимум 10 символов"
rows="3"
variant="outlined"
density="compact"
data-testid="action-reason"
/>
</v-card-text>
<v-card-actions class="justify-end">
<v-btn variant="text" @click="actionDialog = null">Отмена</v-btn>
<v-btn
:loading="actionLoading"
color="primary"
variant="flat"
@click="confirmAction"
>
Подтвердить
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Dialog: refund -->
<v-dialog :model-value="actionDialog === 'refund'" max-width="480" @update:model-value="actionDialog = null">
<v-card>
<v-card-title>Возврат средств</v-card-title>
<v-card-text>
<p class="mb-3 text-body-2">
Тенант: <strong>{{ actionRow?.name }}</strong>
</p>
<v-alert
v-if="actionError"
type="error"
variant="tonal"
density="compact"
class="mb-3"
data-testid="action-error"
>
{{ actionError }}
</v-alert>
<v-text-field
v-model.number="actionAmount"
type="number"
label="Сумма возврата, ₽"
:hint="actionRow ? `доступно к возврату: ${formatRub(actionRow.balance_rub)}` : ''"
persistent-hint
variant="outlined"
density="compact"
class="mb-3"
data-testid="refund-amount"
/>
<v-textarea
v-model="actionReason"
label="Основание"
placeholder="Минимум 10 символов"
rows="3"
variant="outlined"
density="compact"
data-testid="action-reason"
/>
</v-card-text>
<v-card-actions class="justify-end">
<v-btn variant="text" @click="actionDialog = null">Отмена</v-btn>
<v-btn
:loading="actionLoading"
color="primary"
variant="flat"
@click="confirmAction"
>
Выполнить возврат
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Dialog: change tariff -->
<v-dialog :model-value="actionDialog === 'tariff'" max-width="480" @update:model-value="actionDialog = null">
<v-card>
<v-card-title>Сменить тариф</v-card-title>
<v-card-text>
<p class="mb-3 text-body-2">
Тенант: <strong>{{ actionRow?.name }}</strong>
</p>
<v-alert
v-if="actionError"
type="error"
variant="tonal"
density="compact"
class="mb-3"
data-testid="action-error"
>
{{ actionError }}
</v-alert>
<v-select
v-model="actionTariffId"
:items="tariffPlans"
item-title="name"
item-value="id"
label="Тарифный план"
variant="outlined"
density="compact"
class="mb-3"
data-testid="tariff-select"
/>
<v-textarea
v-model="actionReason"
label="Основание"
placeholder="Минимум 10 символов"
rows="3"
variant="outlined"
density="compact"
data-testid="action-reason"
/>
</v-card-text>
<v-card-actions class="justify-end">
<v-btn variant="text" @click="actionDialog = null">Отмена</v-btn>
<v-btn
:loading="actionLoading"
color="primary"
variant="flat"
@click="confirmAction"
>
Сменить тариф
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
@@ -0,0 +1,299 @@
<script setup lang="ts">
/**
* Карточка инцидента (drill-down из AdminIncidentsView).
*
* Sprint 3D G5/G6: детальный просмотр инцидента + кнопка «Уведомить РКН»
* (152-ФЗ — обязательное уведомление РКН для data_breach за 24ч).
*
* Маршрут: /admin/incidents/:id
*/
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { getAdminIncidentDetail, notifyIncidentRkn } from '../../api/admin';
import type { ApiAdminIncidentDetail } from '../../api/admin';
import { extractErrorMessage } from '../../api/client';
const route = useRoute();
const router = useRouter();
const id = computed(() => Number(route.params.id));
const incident = ref<ApiAdminIncidentDetail | null>(null);
const loading = ref(false);
const notFound = ref(false);
const fetchError = ref<string | null>(null);
const rknError = ref('');
const rknLoading = ref(false);
const rknDialog = ref(false);
async function loadIncident(): Promise<void> {
loading.value = true;
fetchError.value = null;
notFound.value = false;
try {
incident.value = await getAdminIncidentDetail(id.value);
} catch (e: unknown) {
const status = (e as { response?: { status?: number } })?.response?.status;
if (status === 404) {
notFound.value = true;
incident.value = null;
} else {
fetchError.value = extractErrorMessage(e);
}
} finally {
loading.value = false;
}
}
onMounted(() => void loadIncident());
watch(id, () => void loadIncident());
async function confirmRkn(): Promise<void> {
rknLoading.value = true;
rknError.value = '';
try {
incident.value = await notifyIncidentRkn(id.value);
rknDialog.value = false;
} catch (e: unknown) {
rknError.value = extractErrorMessage(e);
// dialog stays open so error is visible
} finally {
rknLoading.value = false;
}
}
function goBack(): void {
void router.push({ name: 'admin-incidents' });
}
// Helpers (copied from AdminIncidentsView for self-containment)
const statusMap: Record<string, { label: string; color: string }> = {
open: { label: 'Открыт', color: 'error' },
investigating: { label: 'Расследуется', color: 'warning' },
resolved: { label: 'Решён', color: 'info' },
closed: { label: 'Закрыт', color: 'success' },
};
function statusInfo(s: string) {
return statusMap[s] ?? { label: s, color: 'default' };
}
const severityMap: Record<string, { label: string; color: string }> = {
critical: { label: 'Critical', color: 'error' },
high: { label: 'High', color: 'warning' },
medium: { label: 'Medium', color: 'info' },
low: { label: 'Low', color: 'success' },
};
function severityInfo(s: string) {
return severityMap[s] ?? { label: s, color: 'default' };
}
function formatDate(iso: string | null): string {
if (!iso) return '—';
return new Date(iso).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
defineExpose({
incident,
loading,
notFound,
fetchError,
rknError,
rknLoading,
rknDialog,
loadIncident,
confirmRkn,
});
</script>
<template>
<!-- Loading -->
<v-container v-if="loading" fluid class="pa-6" data-testid="incident-loading">
<v-progress-circular indeterminate color="primary" />
<span class="ml-3 text-medium-emphasis">Загрузка</span>
</v-container>
<!-- Not found -->
<v-container v-else-if="notFound" fluid class="pa-6" data-testid="incident-not-found">
<v-alert type="error" variant="tonal" class="mb-4">
Инцидент <strong>#{{ id }}</strong> не найден.
</v-alert>
<v-btn variant="outlined" prepend-icon="mdi-arrow-left" @click="goBack">К списку инцидентов</v-btn>
</v-container>
<!-- Fetch error -->
<v-container v-else-if="fetchError" fluid class="pa-6" data-testid="incident-fetch-error">
<v-alert type="warning" variant="tonal" class="mb-4">
Не удалось загрузить инцидент: {{ fetchError }}
</v-alert>
<div class="d-flex ga-2">
<v-btn variant="outlined" prepend-icon="mdi-refresh" @click="loadIncident">Повторить</v-btn>
<v-btn variant="text" prepend-icon="mdi-arrow-left" @click="goBack">К списку</v-btn>
</div>
</v-container>
<!-- Content -->
<v-container v-else-if="incident" fluid class="incident-detail pa-6">
<!-- Header -->
<header class="d-flex justify-space-between align-start mb-4 flex-wrap ga-2">
<div>
<div class="d-flex align-center ga-2 mb-1">
<span class="font-mono text-caption text-medium-emphasis">{{ incident.incident_id }}</span>
<v-chip :color="severityInfo(incident.severity).color" size="x-small" variant="tonal">
{{ severityInfo(incident.severity).label }}
</v-chip>
<v-chip :color="statusInfo(incident.status).color" size="x-small" variant="tonal">
{{ statusInfo(incident.status).label }}
</v-chip>
</div>
<h1 class="text-h5 font-weight-medium">{{ incident.summary }}</h1>
</div>
<v-btn variant="outlined" prepend-icon="mdi-arrow-left" @click="goBack">Назад</v-btn>
</header>
<v-row>
<!-- Main details -->
<v-col cols="12" md="8">
<v-card variant="outlined" class="pa-4 mb-4">
<h2 class="text-h6 mb-3">Детали инцидента</h2>
<div v-if="incident.root_cause" class="mb-3">
<div class="text-caption text-medium-emphasis">Корневая причина</div>
<div>{{ incident.root_cause }}</div>
</div>
<div v-if="incident.postmortem_url" class="mb-3">
<div class="text-caption text-medium-emphasis">Postmortem</div>
<a :href="incident.postmortem_url" target="_blank" rel="noopener">
{{ incident.postmortem_url }}
</a>
</div>
<v-divider class="my-3" />
<v-row dense>
<v-col cols="6">
<div class="text-caption text-medium-emphasis">Начался</div>
<div>{{ formatDate(incident.started_at) }}</div>
</v-col>
<v-col cols="6">
<div class="text-caption text-medium-emphasis">Обнаружен</div>
<div>{{ formatDate(incident.detected_at) }}</div>
</v-col>
<v-col cols="6" class="mt-2">
<div class="text-caption text-medium-emphasis">Решён</div>
<div>{{ formatDate(incident.resolved_at) }}</div>
</v-col>
<v-col v-if="incident.affected_users_count !== null" cols="6" class="mt-2">
<div class="text-caption text-medium-emphasis">Затронуто пользователей</div>
<div>{{ incident.affected_users_count }}</div>
</v-col>
</v-row>
</v-card>
<!-- Affected tenants -->
<v-card variant="outlined" class="pa-4 mb-4">
<h2 class="text-h6 mb-3">Затронутые тенанты ({{ incident.affected_tenants.length }})</h2>
<div v-if="incident.affected_tenants.length === 0" class="text-medium-emphasis text-body-2">
Нет данных
</div>
<v-list v-else density="compact">
<v-list-item
v-for="t in incident.affected_tenants"
:key="t.id"
:title="t.organization_name"
:subtitle="`ID: ${t.id}`"
/>
</v-list>
</v-card>
</v-col>
<!-- РКН section -->
<v-col cols="12" md="4">
<v-card v-if="incident.type === 'data_breach'" variant="outlined" class="pa-4 mb-4">
<h2 class="text-h6 mb-3">Уведомление РКН (152-ФЗ)</h2>
<div v-if="incident.rkn_notified">
<v-icon color="success" class="mr-1">mdi-check-circle</v-icon>
РКН уведомлён {{ formatDate(incident.rkn_notified_at) }}
</div>
<template v-else>
<div v-if="incident.rkn_deadline_at" class="mb-3">
<div class="text-caption text-medium-emphasis">Дедлайн</div>
<div class="text-error font-weight-medium">{{ formatDate(incident.rkn_deadline_at) }}</div>
</div>
<v-btn
data-testid="rkn-notify-btn"
color="error"
:loading="rknLoading"
block
@click="rknDialog = true"
>
Уведомить РКН
</v-btn>
</template>
<v-alert
v-if="rknError"
type="error"
variant="tonal"
density="compact"
class="mt-3"
data-testid="rkn-error"
>
{{ rknError }}
</v-alert>
</v-card>
<!-- Admin meta -->
<v-card variant="outlined" class="pa-4">
<h2 class="text-h6 mb-3">Служебная информация</h2>
<div v-if="incident.created_by_admin" class="mb-2">
<div class="text-caption text-medium-emphasis">Создал</div>
<div>{{ incident.created_by_admin }}</div>
</div>
<div v-if="incident.closed_by_admin" class="mb-2">
<div class="text-caption text-medium-emphasis">Закрыл</div>
<div>{{ incident.closed_by_admin }}</div>
</div>
<div v-if="incident.created_at">
<div class="text-caption text-medium-emphasis">Создан</div>
<div>{{ formatDate(incident.created_at) }}</div>
</div>
</v-card>
</v-col>
</v-row>
<!-- РКН confirm dialog -->
<v-dialog v-model="rknDialog" max-width="480">
<v-card>
<v-card-title class="text-h6">Подтверждение уведомления РКН</v-card-title>
<v-card-text>
Это юридически значимое действие. После подтверждения будет зафиксировано время уведомления
регулятора (152-ФЗ). Продолжить?
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="rknDialog = false">Отмена</v-btn>
<v-btn color="error" :loading="rknLoading" @click="confirmRkn">Подтвердить</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<style scoped>
.incident-detail {
max-width: 1200px;
}
.font-mono {
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
</style>
@@ -11,6 +11,7 @@
*/
import { ADMIN_INCIDENTS } from '../../composables/mockAdmin';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { usePolling } from '../../composables/usePolling';
import * as adminApi from '../../api/admin';
@@ -29,6 +30,8 @@ interface IncidentRow {
rkn_deadline_at: string | null;
}
const router = useRouter();
const filterStatus = ref<string>('all');
const statusMap: Record<string, { label: string; color: string }> = {
@@ -210,7 +213,14 @@ function formatDate(iso: string): string {
</div>
<v-list lines="three" class="incidents-list">
<v-list-item v-for="row in filteredRows" :key="row.id" class="incident-row">
<v-list-item
v-for="row in filteredRows"
:key="row.id"
class="incident-row"
:data-testid="`incident-row-${row.id}`"
style="cursor: pointer"
@click="router.push({ name: 'admin-incident-detail', params: { id: row.id } })"
>
<div class="incident-header">
<span class="font-mono text-caption text-medium-emphasis">{{ row.incident_id }}</span>
<v-chip :color="severityInfo(row.severity).color" size="x-small" variant="tonal" class="ml-2">
+82 -58
View File
@@ -79,52 +79,74 @@ Route::get('/api/reports/jobs/{id}/file', 'App\Http\Controllers\Api\ReportJobCon
->name('reports.download')
->middleware('signed');
// 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', 'App\Http\Controllers\Api\ImpersonationController@active');
Route::get('/recent', 'App\Http\Controllers\Api\ImpersonationController@recent');
Route::post('/init', 'App\Http\Controllers\Api\ImpersonationController@init');
Route::post('/verify', 'App\Http\Controllers\Api\ImpersonationController@verify');
Route::post('/end', 'App\Http\Controllers\Api\ImpersonationController@end');
// 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). На MVP без middleware (saas-admin auth
// не реализован), production: middleware('auth:saas-admin') + role('compliance' if needed).
Route::prefix('/api/admin/impersonation')->group(function () {
Route::get('/active', 'App\Http\Controllers\Api\ImpersonationController@active');
Route::get('/recent', 'App\Http\Controllers\Api\ImpersonationController@recent');
Route::post('/init', 'App\Http\Controllers\Api\ImpersonationController@init');
Route::post('/verify', 'App\Http\Controllers\Api\ImpersonationController@verify');
Route::post('/end', 'App\Http\Controllers\Api\ImpersonationController@end');
});
// SaaS-admin → Тенанты: lookup + детали для AdminTenantsView/AdminTenantDetailView.
Route::get('/api/admin/tenants', 'App\Http\Controllers\Api\AdminTenantsController@index');
Route::get('/api/admin/tenants/{subdomain}', 'App\Http\Controllers\Api\AdminTenantsController@show')
->where('subdomain', '[a-z0-9_-]+');
// SaaS-admin → Биллинг: aggregates пополнений/списаний за текущий месяц.
Route::get('/api/admin/billing', 'App\Http\Controllers\Api\AdminBillingController@index');
// Sprint 3D (G4): SaaS-admin billing row-actions — приостановка/возврат/смена тарифа.
Route::get('/api/admin/billing/tariff-plans', 'App\Http\Controllers\Api\AdminBillingController@tariffPlans');
Route::patch('/api/admin/billing/tenants/{id}/status', 'App\Http\Controllers\Api\AdminBillingController@updateStatus')
->where('id', '[0-9]+');
Route::post('/api/admin/billing/tenants/{id}/refund', 'App\Http\Controllers\Api\AdminBillingController@refund')
->where('id', '[0-9]+');
Route::patch('/api/admin/billing/tenants/{id}/tariff', 'App\Http\Controllers\Api\AdminBillingController@changeTariff')
->where('id', '[0-9]+');
// SaaS-admin → Инциденты: чтение incidents_log для AdminIncidentsView.
Route::get('/api/admin/incidents', 'App\Http\Controllers\Api\AdminIncidentsController@index');
// Sprint 3D (G5): SaaS-admin incident detail-view drill-down.
Route::get('/api/admin/incidents/{id}', 'App\Http\Controllers\Api\AdminIncidentsController@show')
->where('id', '[0-9]+');
// Sprint 3D (G6): РКН-notify endpoint (152-ФЗ).
Route::post('/api/admin/incidents/{id}/rkn-notify', 'App\Http\Controllers\Api\AdminIncidentsController@notifyRkn')
->where('id', '[0-9]+');
// 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('/', 'App\Http\Controllers\Api\AdminSystemSettingsController@index');
Route::put('/{key}', 'App\Http\Controllers\Api\AdminSystemSettingsController@update')->where('key', '[a-z0-9_\.]+');
});
// Plan 4: SaaS-admin pricing-tiers editor.
// CRUD для 7-ступенчатого тарифа. effective_from auto-computed = 1-е число
// следующего месяца (МСК). Audit-trail в saas_admin_audit_log.
Route::prefix('/api/admin/pricing-tiers')->group(function () {
Route::get('/', 'App\Http\Controllers\Api\AdminPricingTiersController@index');
Route::post('/', 'App\Http\Controllers\Api\AdminPricingTiersController@store');
Route::delete('/scheduled/{effective_from}',
'App\Http\Controllers\Api\AdminPricingTiersController@deleteScheduled')
->where('effective_from', '\d{4}-\d{2}-\d{2}');
});
// Plan 4 Task 10: SaaS-admin supplier prices editor.
// CRUD для B1/B2/B3 закупочных цен. Audit-trail в saas_admin_audit_log.
Route::get('/api/admin/suppliers', 'App\Http\Controllers\Api\AdminSuppliersController@index');
Route::patch('/api/admin/suppliers/{id}', 'App\Http\Controllers\Api\AdminSuppliersController@update')
->where('id', '[0-9]+');
});
// SaaS-admin → Тенанты: lookup + детали для AdminTenantsView/AdminTenantDetailView.
// Без auth (saas-admin SSO ⏸ Б-1).
Route::get('/api/admin/tenants', 'App\Http\Controllers\Api\AdminTenantsController@index');
Route::get('/api/admin/tenants/{subdomain}', 'App\Http\Controllers\Api\AdminTenantsController@show')
->where('subdomain', '[a-z0-9_-]+');
// SaaS-admin → Биллинг: aggregates пополнений/списаний за текущий месяц.
Route::get('/api/admin/billing', 'App\Http\Controllers\Api\AdminBillingController@index');
// SaaS-admin → Инциденты: чтение incidents_log для AdminIncidentsView.
Route::get('/api/admin/incidents', 'App\Http\Controllers\Api\AdminIncidentsController@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('/', 'App\Http\Controllers\Api\AdminSystemSettingsController@index');
Route::put('/{key}', 'App\Http\Controllers\Api\AdminSystemSettingsController@update')->where('key', '[a-z0-9_\.]+');
});
// Plan 4: SaaS-admin pricing-tiers editor.
// CRUD для 7-ступенчатого тарифа. effective_from auto-computed = 1-е число
// следующего месяца (МСК). Audit-trail в saas_admin_audit_log.
Route::prefix('/api/admin/pricing-tiers')->group(function () {
Route::get('/', 'App\Http\Controllers\Api\AdminPricingTiersController@index');
Route::post('/', 'App\Http\Controllers\Api\AdminPricingTiersController@store');
Route::delete('/scheduled/{effective_from}',
'App\Http\Controllers\Api\AdminPricingTiersController@deleteScheduled')
->where('effective_from', '\d{4}-\d{2}-\d{2}');
});
// Plan 4 Task 10: SaaS-admin supplier prices editor.
// CRUD для B1/B2/B3 закупочных цен. Audit-trail в saas_admin_audit_log.
Route::get('/api/admin/suppliers', 'App\Http\Controllers\Api\AdminSuppliersController@index');
Route::patch('/api/admin/suppliers/{id}', 'App\Http\Controllers\Api\AdminSuppliersController@update')
->where('id', '[0-9]+');
// Plan 4 Task 11: tenant charges ledger (read-only + CSV export).
// RLS изоляция через SetTenantContext (auth:sanctum + tenant) — текущий tenant
// видит только свои lead_charges. Pagination 20/page, фильтры period/source.
@@ -159,21 +181,23 @@ Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
// auth-middleware (tenant_id параметром); production: middleware('auth:sanctum','tenant').
Route::get('/api/dashboard/summary', 'App\Http\Controllers\Api\DashboardController@summary');
// Сделки — manual create через UI (NewDealDialog). На prod: middleware
// 'auth:sanctum' + 'tenant', tenant_id берётся из user'а. На MVP — параметром.
// Сделки — 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. URL и shape
// payload'ов сохранены, только controller@method обновлён.
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');
// 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 endpoints — заполняют v-select'ы (NewDealDialog, smart-filters).
Route::get('/api/managers', 'App\Http\Controllers\Api\ManagerController@index');
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
use App\Models\BalanceTransaction;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
function makeBillingTenant(array $overrides = []): int
{
return (int) DB::table('tenants')->insertGetId(array_merge([
'subdomain' => 'bt-'.bin2hex(random_bytes(4)),
'organization_name' => 'Billing Test Co',
'contact_email' => 'bt-'.bin2hex(random_bytes(3)).'@test.local',
'webhook_token' => bin2hex(random_bytes(16)),
'status' => 'active',
'balance_rub' => '5000.00',
'is_trial' => false,
'created_at' => now(),
], $overrides));
}
function makeTariffPlan(array $overrides = []): int
{
return (int) DB::table('tariff_plans')->insertGetId(array_merge([
'code' => 'test-'.bin2hex(random_bytes(4)),
'name' => 'Test Plan',
'billing_model' => 'monthly',
'price_monthly' => '999.00',
'created_at' => now(),
], $overrides));
}
test('GET tariff-plans возвращает список планов', function () {
$planId = makeTariffPlan(['name' => 'Visible Plan', 'price_monthly' => '1500.00']);
$r = $this->getJson('/api/admin/billing/tariff-plans');
$r->assertOk();
$plans = $r->json('plans');
expect($plans)->toBeArray();
$found = collect($plans)->first(fn ($p) => $p['id'] === $planId);
expect($found)->not->toBeNull();
expect($found['id'])->toBeInt();
expect($found['name'])->toBeString();
expect($found['price_monthly'])->toBeString();
});
test('PATCH status suspended меняет статус + пишет audit-log', function () {
$id = makeBillingTenant(['status' => 'active']);
$r = $this->patchJson("/api/admin/billing/tenants/{$id}/status", [
'status' => 'suspended',
'reason' => 'Просрочка оплаты более 30 дней.',
]);
$r->assertOk();
expect($r->json('status'))->toBe('suspended');
expect(DB::table('tenants')->where('id', $id)->value('status'))->toBe('suspended');
expect(DB::table('saas_admin_audit_log')->where('action', 'tenant.suspend')->where('target_id', $id)->exists())->toBeTrue();
});
test('PATCH status active разблокирует', function () {
$id = makeBillingTenant(['status' => 'suspended']);
$this->patchJson("/api/admin/billing/tenants/{$id}/status", [
'status' => 'active', 'reason' => 'Оплата получена, блокировка снята.',
])->assertOk();
expect(DB::table('tenants')->where('id', $id)->value('status'))->toBe('active');
});
test('PATCH status reason короче 10 символов → 422', function () {
$id = makeBillingTenant();
$this->patchJson("/api/admin/billing/tenants/{$id}/status", ['status' => 'suspended', 'reason' => 'мало'])
->assertStatus(422);
});
test('PATCH status несуществующий тенант → 404', function () {
$this->patchJson('/api/admin/billing/tenants/99999999/status', [
'status' => 'suspended', 'reason' => 'Любое основание длиной более десяти.',
])->assertStatus(404);
});
test('PATCH status soft-deleted тенант → 404', function () {
$id = makeBillingTenant(['deleted_at' => now()]);
$this->patchJson("/api/admin/billing/tenants/{$id}/status", [
'status' => 'suspended', 'reason' => 'Любое основание длиной более десяти.',
])->assertStatus(404);
});
test('POST refund списывает с баланса + создаёт balance_transactions refund', function () {
$id = makeBillingTenant(['balance_rub' => '5000.00']);
$r = $this->postJson("/api/admin/billing/tenants/{$id}/refund", [
'amount_rub' => 1500, 'reason' => 'Возврат по обращению клиента №42.',
]);
$r->assertOk();
expect($r->json('balance_rub'))->toBe('3500.00');
expect(DB::table('tenants')->where('id', $id)->value('balance_rub'))->toBe('3500.00');
$tx = BalanceTransaction::where('tenant_id', $id)->where('type', 'refund')->first();
expect($tx)->not->toBeNull();
expect((string) $tx->amount_rub)->toBe('-1500.00');
expect((string) $tx->balance_rub_after)->toBe('3500.00');
expect(DB::table('saas_admin_audit_log')->where('action', 'tenant.refund')->where('target_id', $id)->exists())->toBeTrue();
});
test('POST refund больше баланса → 422, баланс не меняется', function () {
$id = makeBillingTenant(['balance_rub' => '1000.00']);
$this->postJson("/api/admin/billing/tenants/{$id}/refund", [
'amount_rub' => 5000, 'reason' => 'Возврат по обращению клиента №7.',
])->assertStatus(422);
expect(DB::table('tenants')->where('id', $id)->value('balance_rub'))->toBe('1000.00');
expect(BalanceTransaction::where('tenant_id', $id)->count())->toBe(0);
});
test('POST refund неположительная сумма → 422', function () {
$id = makeBillingTenant();
$this->postJson("/api/admin/billing/tenants/{$id}/refund", ['amount_rub' => 0, 'reason' => 'Основание длиннее десяти символов.'])
->assertStatus(422);
});
test('PATCH tariff меняет current_tariff_id + audit-log', function () {
$id = makeBillingTenant();
$tariffId = makeTariffPlan(['name' => 'Corp Plan', 'price_monthly' => '2500.00']);
$r = $this->patchJson("/api/admin/billing/tenants/{$id}/tariff", [
'tariff_id' => $tariffId, 'reason' => 'Переход на тариф по договорённости с клиентом.',
]);
$r->assertOk();
expect((int) DB::table('tenants')->where('id', $id)->value('current_tariff_id'))->toBe($tariffId);
expect(DB::table('saas_admin_audit_log')->where('action', 'tenant.change_tariff')->where('target_id', $id)->exists())->toBeTrue();
});
test('PATCH tariff несуществующий tariff_id → 422', function () {
$id = makeBillingTenant();
$this->patchJson("/api/admin/billing/tenants/{$id}/tariff", [
'tariff_id' => 88888888, 'reason' => 'Основание длиннее десяти символов.',
])->assertStatus(422);
});
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function () {
DB::table('incidents_log')->delete();
$this->adminId = (int) DB::table('saas_admin_users')->insertGetId([
'email' => 'rkn-'.bin2hex(random_bytes(3)).'@test',
'full_name' => 'RKN Admin',
'password_hash' => bcrypt('test1234'),
'is_active' => true,
'role' => 'support',
'created_at' => now(),
]);
});
function makeRknIncident(int $adminId, array $overrides = []): int
{
$started = $overrides['started_at'] ?? now()->subHours(2);
return (int) DB::table('incidents_log')->insertGetId(array_merge([
'type' => 'data_breach',
'severity' => 'critical',
'started_at' => $started,
'detected_at' => $started,
'summary' => 'PDN leak test',
'created_by_admin_id' => $adminId,
'created_at' => now(),
], $overrides));
}
test('POST rkn-notify проставляет rkn_notified_at + audit-log', function () {
$id = makeRknIncident($this->adminId);
$r = $this->postJson("/api/admin/incidents/{$id}/rkn-notify");
$r->assertOk();
expect($r->json('incident.rkn_notified'))->toBeTrue();
expect($r->json('incident.rkn_notified_at'))->toBeString();
expect(DB::table('incidents_log')->where('id', $id)->value('rkn_notified_at'))->not->toBeNull();
expect(DB::table('saas_admin_audit_log')->where('action', 'incident.rkn_notify')->where('target_id', $id)->exists())
->toBeTrue();
});
test('POST rkn-notify несуществующий инцидент → 404', function () {
$this->postJson('/api/admin/incidents/99999999/rkn-notify')->assertStatus(404);
});
test('POST rkn-notify не data_breach → 422', function () {
$id = makeRknIncident($this->adminId, ['type' => 'service_outage']);
$this->postJson("/api/admin/incidents/{$id}/rkn-notify")->assertStatus(422);
expect(DB::table('incidents_log')->where('id', $id)->value('rkn_notified_at'))->toBeNull();
});
test('POST rkn-notify повторно → 409', function () {
$id = makeRknIncident($this->adminId, ['rkn_notified_at' => now()->subHour()]);
$this->postJson("/api/admin/incidents/{$id}/rkn-notify")->assertStatus(409);
});
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function () {
DB::table('incidents_log')->delete();
$this->adminId = (int) DB::table('saas_admin_users')->insertGetId([
'email' => 'inc-'.bin2hex(random_bytes(3)).'@test',
'full_name' => 'Incident Admin',
'password_hash' => bcrypt('test1234'),
'is_active' => true,
'role' => 'support',
'created_at' => now(),
]);
});
function makeShowIncident(int $adminId, array $overrides = []): int
{
$started = $overrides['started_at'] ?? now()->subHours(3);
$detected = $overrides['detected_at'] ?? $started;
return (int) DB::table('incidents_log')->insertGetId(array_merge([
'type' => 'service_outage',
'severity' => 'high',
'started_at' => $started,
'detected_at' => $detected,
'resolved_at' => null,
'summary' => 'Show test incident',
'created_by_admin_id' => $adminId,
'created_at' => now(),
], $overrides));
}
test('GET /api/admin/incidents/{id} 200 + полная карточка', function () {
$id = makeShowIncident($this->adminId, ['summary' => 'API 502 burst', 'severity' => 'critical']);
$r = $this->getJson("/api/admin/incidents/{$id}");
$r->assertOk();
expect($r->json('incident.id'))->toBe($id);
expect($r->json('incident.summary'))->toBe('API 502 burst');
expect($r->json('incident.severity'))->toBe('critical');
expect($r->json('incident.incident_id'))->toMatch('/^INC-\d{4}-\d{4}-\d{4}$/');
expect($r->json('incident.status'))->toBe('investigating');
expect($r->json('incident.created_by_admin'))->toBe('Incident Admin');
});
test('GET /api/admin/incidents/{id} несуществующий → 404', function () {
$this->getJson('/api/admin/incidents/99999999')->assertStatus(404);
});
test('GET /api/admin/incidents/{id} data_breach без rkn_notified_at → rkn_deadline_at +24ч', function () {
$id = makeShowIncident($this->adminId, ['type' => 'data_breach', 'detected_at' => now()->subHour()]);
$r = $this->getJson("/api/admin/incidents/{$id}");
expect($r->json('incident.rkn_notified'))->toBeFalse();
expect($r->json('incident.rkn_deadline_at'))->toBeString();
});
test('GET /api/admin/incidents/{id} разрешает имена affected_tenants', function () {
$tenantId = (int) DB::table('tenants')->insertGetId([
'subdomain' => 'inc-'.bin2hex(random_bytes(4)),
'organization_name' => 'Affected Org',
'contact_email' => 'a@test.local',
'webhook_token' => bin2hex(random_bytes(16)),
'created_at' => now(),
]);
$id = makeShowIncident($this->adminId, ['affected_tenant_ids' => '{'.$tenantId.'}']);
$r = $this->getJson("/api/admin/incidents/{$id}");
expect($r->json('incident.affected_tenants'))->toHaveCount(1);
expect($r->json('incident.affected_tenants.0.organization_name'))->toBe('Affected Org');
});
test('GET /api/admin/incidents/{id} resolved инцидент → status resolved', function () {
$id = makeShowIncident($this->adminId, ['resolved_at' => now()]);
expect($this->getJson("/api/admin/incidents/{$id}")->json('incident.status'))->toBe('resolved');
});
+10 -31
View File
@@ -18,11 +18,12 @@ beforeEach(function () {
$this->tenant = Tenant::factory()->create([
'balance_leads' => 100,
]);
$this->user = User::factory()->for($this->tenant)->create();
$this->actingAs($this->user);
});
test('POST /api/deals создаёт сделку с manual source + project firstOrCreate', function () {
$r = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'Окна Москва',
'phone' => '+7 (999) 123-45-67',
'contact_name' => 'Тест Тестов',
@@ -57,7 +58,6 @@ test('POST /api/deals использует существующий project (н
]);
$r = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'Натяжные потолки',
'phone' => '+7 (999) 000-00-00',
]);
@@ -74,7 +74,6 @@ test('POST /api/deals использует существующий project (н
test('POST /api/deals пишет ActivityLog с context.source=manual', function () {
$r = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 000-00-00',
]);
@@ -90,21 +89,20 @@ test('POST /api/deals пишет ActivityLog с context.source=manual', function
test('POST /api/deals 422 без обязательных полей', function () {
$r = $this->postJson('/api/deals', []);
$r->assertStatus(422);
expect($r->json('errors'))->toHaveKeys(['tenant_id', 'project_name', 'phone']);
expect($r->json('errors'))->toHaveKeys(['project_name', 'phone']);
});
test('POST /api/deals 404 при unknown tenant_id', function () {
test('POST /api/deals 401 без auth', function () {
auth()->logout();
$r = $this->postJson('/api/deals', [
'tenant_id' => 999999,
'project_name' => 'X',
'phone' => '+7 (999) 000-00-00',
]);
$r->assertStatus(404);
$r->assertStatus(401);
});
test('POST /api/deals дефолтный status = new если не передан', function () {
$r = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 000-00-00',
]);
@@ -117,7 +115,6 @@ test('POST /api/deals с manager_id → assigned_at = NOW()', function () {
$manager = User::factory()->for($this->tenant)->create(['is_active' => true]);
$r = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 000-00-00',
'manager_id' => $manager->id,
@@ -133,7 +130,6 @@ test('POST /api/deals manual НЕ списывает баланс tenant\'а', f
$balanceBefore = $this->tenant->balance_leads;
$this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 000-00-00',
])->assertStatus(201);
@@ -170,7 +166,6 @@ test('POST /api/deals manual создаёт SupplierLeadCost если у про
]);
$r = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'WithSupplier',
'phone' => '+7 (999) 000-00-00',
]);
@@ -188,7 +183,6 @@ test('POST /api/deals manual создаёт SupplierLeadCost если у про
test('POST /api/deals manual БЕЗ supplier'."'а у проекта — без SupplierLeadCost (graceful skip)", function () {
$r = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'NoSupplier',
'phone' => '+7 (999) 000-00-00',
]);
@@ -204,20 +198,17 @@ test('POST /api/deals manual БЕЗ supplier'."'а у проекта — без
test('POST /api/deals/export возвращает CSV с правильными headers + BOM', function () {
// Создаём 2 сделки через store endpoint (получаем реальные id).
$r1 = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 111-11-11',
'contact_name' => 'Алиса',
])->json('deal');
$r2 = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 222-22-22',
'contact_name' => 'Боб',
])->json('deal');
$r = $this->postJson('/api/deals/export', [
'tenant_id' => $this->tenant->id,
'ids' => [$r1['id'], $r2['id']],
]);
@@ -239,38 +230,33 @@ test('POST /api/deals/export возвращает CSV с правильными
});
test('POST /api/deals/export 422 без ids', function () {
$r = $this->postJson('/api/deals/export', [
'tenant_id' => $this->tenant->id,
]);
$r = $this->postJson('/api/deals/export', []);
$r->assertStatus(422);
expect($r->json('errors'))->toHaveKey('ids');
});
test('POST /api/deals/export 404 unknown tenant', function () {
test('POST /api/deals/export 401 без auth', function () {
auth()->logout();
$r = $this->postJson('/api/deals/export', [
'tenant_id' => 999999,
'ids' => [1, 2, 3],
]);
$r->assertStatus(404);
$r->assertStatus(401);
});
test('POST /api/deals/export фильтрует только запрошенные ids (своего tenant\'а)', function () {
// Создаём 3 сделки одного tenant'а, экспортируем 1 → CSV только её.
$a = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 111-11-11',
'contact_name' => 'Алиса',
])->json('deal');
$this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 222-22-22',
'contact_name' => 'Боб',
])->json('deal');
$r = $this->postJson('/api/deals/export', [
'tenant_id' => $this->tenant->id,
'ids' => [$a['id']],
]);
$r->assertStatus(200);
@@ -286,14 +272,12 @@ test('POST /api/deals/export фильтрует только запрошенн
test('POST /api/deals/export?format=xlsx возвращает binary с корректным content-type', function () {
$a = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 111-11-11',
'contact_name' => 'Алиса',
])->json('deal');
$r = $this->postJson('/api/deals/export', [
'tenant_id' => $this->tenant->id,
'ids' => [$a['id']],
'format' => 'xlsx',
]);
@@ -310,14 +294,12 @@ test('POST /api/deals/export?format=xlsx возвращает binary с корр
test('POST /api/deals/export?format=xlsx содержит данные сделки (после распаковки sheet1)', function () {
$a = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 333-33-33',
'contact_name' => 'Кириллов',
])->json('deal');
$r = $this->postJson('/api/deals/export', [
'tenant_id' => $this->tenant->id,
'ids' => [$a['id']],
'format' => 'xlsx',
]);
@@ -348,7 +330,6 @@ test('POST /api/deals/export?format=xlsx содержит данные сдел
test('POST /api/deals/export 422 на неизвестный format', function () {
$r = $this->postJson('/api/deals/export', [
'tenant_id' => $this->tenant->id,
'ids' => [1],
'format' => 'pdf',
]);
@@ -358,14 +339,12 @@ test('POST /api/deals/export 422 на неизвестный format', function (
test('POST /api/deals/export по умолчанию (без format) возвращает CSV — backward-compat', function () {
$a = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 444-44-44',
'contact_name' => 'Test',
])->json('deal');
$r = $this->postJson('/api/deals/export', [
'tenant_id' => $this->tenant->id,
'ids' => [$a['id']],
]);
$r->assertStatus(200);
+9 -15
View File
@@ -6,6 +6,7 @@ use App\Models\ActivityLog;
use App\Models\Deal;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
@@ -15,6 +16,9 @@ beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->otherTenant = Tenant::factory()->create();
$this->user = User::factory()->for($this->tenant)->create();
$this->actingAs($this->user);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$this->project = Project::factory()->for($this->tenant)->create();
});
@@ -23,19 +27,15 @@ test('DELETE /api/deals 422 без обязательных полей', functio
$this->deleteJson('/api/deals', [])->assertStatus(422);
});
test('DELETE /api/deals 404 на unknown tenant', function () {
$r = $this->deleteJson('/api/deals', [
'tenant_id' => 999999,
'ids' => [1],
]);
$r->assertStatus(404);
test('DELETE /api/deals 401 без auth', function () {
auth()->logout();
$this->deleteJson('/api/deals', ['ids' => [1]])->assertStatus(401);
});
test('DELETE /api/deals soft-удаляет сделки + пишет deal.deleted ActivityLog', function () {
$deals = Deal::factory()->count(3)->for($this->tenant)->for($this->project)->create();
$r = $this->deleteJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'ids' => $deals->pluck('id')->all(),
]);
@@ -65,7 +65,6 @@ test('DELETE /api/deals defense-in-depth не удаляет чужие сдел
$foreign = Deal::factory()->for($this->otherTenant)->for($foreignProject)->create();
$r = $this->deleteJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'ids' => [$own->id, $foreign->id],
]);
@@ -88,13 +87,11 @@ test('DELETE /api/deals NO-OP на уже удалённых', function () {
// Первое удаление
$this->deleteJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'ids' => [$deal->id],
])->assertStatus(200)->assertJson(['deleted' => 1]);
// Повтор — уже удалена, NO-OP.
$r = $this->deleteJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'ids' => [$deal->id],
]);
$r->assertStatus(200)->assertJson(['deleted' => 0, 'requested' => 1]);
@@ -110,11 +107,10 @@ test('GET /api/deals НЕ возвращает soft-deleted сделки', funct
// Удаляем одну
$this->deleteJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'ids' => [$deleted->id],
])->assertStatus(200);
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
$r = $this->getJson('/api/deals');
$ids = collect($r->json('deals'))->pluck('id')->all();
expect($ids)->toContain($alive->id);
expect($ids)->not->toContain($deleted->id);
@@ -125,17 +121,15 @@ test('GET /api/deals/{id} 404 для soft-deleted сделки', function () {
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
$this->deleteJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'ids' => [$deal->id],
])->assertStatus(200);
$this->getJson('/api/deals/'.$deal->id.'?tenant_id='.$this->tenant->id)
$this->getJson('/api/deals/'.$deal->id)
->assertStatus(404);
});
test('DELETE /api/deals 422 пустой массив ids', function () {
$this->deleteJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'ids' => [],
])->assertStatus(422);
});
+27 -27
View File
@@ -14,7 +14,7 @@ use Illuminate\Support\Facades\DB;
*
* Покрывает: фильтры (status_in, project_id, manager_id, search), сортировку
* по received_at DESC, RLS-изоляцию между tenant'ами, относительные поля
* (project_name, manager_name/initials), 422/404, пагинацию (limit/offset).
* (project_name, manager_name/initials), 401/404, пагинацию (limit/offset).
*/
uses(DatabaseTransactions::class);
@@ -22,6 +22,9 @@ beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->otherTenant = Tenant::factory()->create();
$this->user = User::factory()->for($this->tenant)->create();
$this->actingAs($this->user);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$this->project = Project::factory()->for($this->tenant)->create(['name' => 'Окна Москва']);
$this->project2 = Project::factory()->for($this->tenant)->create(['name' => 'Натяжные потолки']);
@@ -33,16 +36,13 @@ beforeEach(function () {
]);
});
test('GET /api/deals возвращает 422 без tenant_id', function () {
$this->getJson('/api/deals')->assertStatus(422);
});
test('GET /api/deals возвращает 404 для unknown tenant_id', function () {
$this->getJson('/api/deals?tenant_id=999999')->assertStatus(404);
test('GET /api/deals возвращает 401 без auth', function () {
auth()->logout();
$this->getJson('/api/deals')->assertStatus(401);
});
test('GET /api/deals возвращает пустой список для tenant без сделок', function () {
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
$r = $this->getJson('/api/deals');
$r->assertStatus(200)
->assertJson(['deals' => [], 'total' => 0, 'limit' => 100, 'offset' => 0]);
@@ -59,7 +59,7 @@ test('GET /api/deals возвращает сделки tenant\'а с проек
'manager_id' => $this->manager->id,
]);
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
$r = $this->getJson('/api/deals');
$r->assertStatus(200);
expect($r->json('total'))->toBe(1);
@@ -79,7 +79,7 @@ test('GET /api/deals не возвращает сделки чужого tenant\
$foreignProject = Project::factory()->for($this->otherTenant)->create();
Deal::factory()->for($this->otherTenant)->for($foreignProject)->create(['status' => 'new']);
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
$r = $this->getJson('/api/deals');
expect($r->json('total'))->toBe(1);
expect($r->json('deals.0.tenant_id'))->toBe($this->tenant->id);
@@ -96,7 +96,7 @@ test('GET /api/deals сортирует по received_at DESC', function () {
'received_at' => now()->subHours(1),
]);
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
$r = $this->getJson('/api/deals');
expect($r->json('deals.0.id'))->toBe($newest->id);
expect($r->json('deals.1.id'))->toBe($middle->id);
@@ -108,7 +108,7 @@ test('GET /api/deals фильтрует по status_in[]', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'closed']);
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&status_in[]=new&status_in[]=paid');
$r = $this->getJson('/api/deals?status_in[]=new&status_in[]=paid');
expect($r->json('total'))->toBe(2);
$statuses = collect($r->json('deals'))->pluck('status')->sort()->values()->all();
@@ -120,7 +120,7 @@ test('GET /api/deals фильтрует по project_id', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create();
Deal::factory()->for($this->tenant)->for($this->project2)->create();
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&project_id='.$this->project2->id);
$r = $this->getJson('/api/deals?project_id='.$this->project2->id);
expect($r->json('total'))->toBe(1);
expect($r->json('deals.0.project_name'))->toBe('Натяжные потолки');
@@ -133,7 +133,7 @@ test('GET /api/deals фильтрует по manager_id', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['manager_id' => $other->id]);
Deal::factory()->for($this->tenant)->for($this->project)->create(['manager_id' => null]);
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&manager_id='.$this->manager->id);
$r = $this->getJson('/api/deals?manager_id='.$this->manager->id);
expect($r->json('total'))->toBe(1);
expect($r->json('deals.0.manager_id'))->toBe($this->manager->id);
@@ -149,13 +149,13 @@ test('GET /api/deals фильтрует по search (phone + contact_name, ILIKE
'contact_name' => 'Дмитрий Петров',
]);
expect($this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&search=Соколова')
expect($this->getJson('/api/deals?search=Соколова')
->json('total'))->toBe(1);
expect($this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&search=903')
expect($this->getJson('/api/deals?search=903')
->json('total'))->toBe(1);
expect($this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&search=сокол') // case-insensitive ILIKE
expect($this->getJson('/api/deals?search=сокол') // case-insensitive ILIKE
->json('total'))->toBe(1);
});
@@ -166,7 +166,7 @@ test('GET /api/deals поддерживает limit + offset', function () {
]);
}
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2&offset=1');
$r = $this->getJson('/api/deals?limit=2&offset=1');
expect($r->json('total'))->toBe(5);
expect($r->json('limit'))->toBe(2);
@@ -181,7 +181,7 @@ test('GET /api/deals?only_deleted=true возвращает только soft-de
$deleted1->delete();
$deleted2->delete();
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&only_deleted=true');
$r = $this->getJson('/api/deals?only_deleted=true');
expect($r->json('total'))->toBe(2);
$ids = collect($r->json('deals'))->pluck('id')->all();
@@ -195,7 +195,7 @@ test('GET /api/deals (без only_deleted) НЕ возвращает soft-delete
$deleted = Deal::factory()->for($this->tenant)->for($this->project)->create();
$deleted->delete();
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
$r = $this->getJson('/api/deals');
expect($r->json('total'))->toBe(1);
expect($r->json('deals.0.id'))->toBe($alive->id);
@@ -211,7 +211,7 @@ test('GET /api/deals?only_deleted=true изолирует чужие удалё
$own = Deal::factory()->for($this->tenant)->for($this->project)->create();
$own->delete();
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&only_deleted=true');
$r = $this->getJson('/api/deals?only_deleted=true');
expect($r->json('total'))->toBe(1);
expect($r->json('deals.0.id'))->toBe($own->id);
@@ -220,7 +220,7 @@ test('GET /api/deals?only_deleted=true изолирует чужие удалё
test('GET /api/deals возвращает manager_name/initials = null если manager_id null', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['manager_id' => null]);
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
$r = $this->getJson('/api/deals');
expect($r->json('deals.0.manager_id'))->toBeNull();
expect($r->json('deals.0.manager_name'))->toBeNull();
@@ -244,7 +244,7 @@ test('GET /api/deals с cursor возвращает следующую стра
}
// Первая страница без cursor: limit=2 → последние 2 (по received_at DESC).
$r1 = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2');
$r1 = $this->getJson('/api/deals?limit=2');
$r1->assertStatus(200);
expect($r1->json('deals'))->toHaveLength(2);
expect($r1->json('deals.0.id'))->toBe($ids[4]);
@@ -256,7 +256,7 @@ test('GET /api/deals с cursor возвращает следующую стра
'i' => $r1->json('deals.1.id'),
]));
$r2 = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2&cursor='.$cursor);
$r2 = $this->getJson('/api/deals?limit=2&cursor='.$cursor);
$r2->assertStatus(200);
expect($r2->json('deals'))->toHaveLength(2);
expect($r2->json('deals.0.id'))->toBe($ids[2]);
@@ -264,7 +264,7 @@ test('GET /api/deals с cursor возвращает следующую стра
});
test('GET /api/deals с невалидным cursor возвращает 422', function () {
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&cursor=not-base64-json');
$r = $this->getJson('/api/deals?cursor=not-base64-json');
$r->assertStatus(422);
expect($r->json('message'))->toBeString();
});
@@ -278,14 +278,14 @@ test('GET /api/deals возвращает next_cursor когда есть ещё
]);
}
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2');
$r = $this->getJson('/api/deals?limit=2');
$r->assertStatus(200);
expect($r->json('next_cursor'))->toBeString();
expect($r->json('next_cursor'))->not->toBeEmpty();
// Последняя страница: next_cursor = null.
$cursor = $r->json('next_cursor');
$r2 = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2&cursor='.$cursor);
$r2 = $this->getJson('/api/deals?limit=2&cursor='.$cursor);
$r2->assertStatus(200);
expect($r2->json('next_cursor'))->toBeNull();
});
+9 -15
View File
@@ -6,6 +6,7 @@ use App\Models\ActivityLog;
use App\Models\Deal;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
@@ -15,6 +16,9 @@ beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->otherTenant = Tenant::factory()->create();
$this->user = User::factory()->for($this->tenant)->create();
$this->actingAs($this->user);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$this->project = Project::factory()->for($this->tenant)->create();
});
@@ -23,12 +27,9 @@ test('POST /api/deals/restore 422 без обязательных полей', f
$this->postJson('/api/deals/restore', [])->assertStatus(422);
});
test('POST /api/deals/restore 404 на unknown tenant', function () {
$r = $this->postJson('/api/deals/restore', [
'tenant_id' => 999999,
'ids' => [1],
]);
$r->assertStatus(404);
test('POST /api/deals/restore 401 без auth', function () {
auth()->logout();
$this->postJson('/api/deals/restore', ['ids' => [1]])->assertStatus(401);
});
test('POST /api/deals/restore восстанавливает soft-deleted + пишет deal.restored', function () {
@@ -36,13 +37,11 @@ test('POST /api/deals/restore восстанавливает soft-deleted + пи
// Удалим сначала
$this->deleteJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'ids' => [$deal->id],
])->assertStatus(200);
// Восстановим
$r = $this->postJson('/api/deals/restore', [
'tenant_id' => $this->tenant->id,
'ids' => [$deal->id],
]);
$r->assertStatus(200)->assertJson([
@@ -64,7 +63,6 @@ test('POST /api/deals/restore NO-OP для не-удалённых (живых)
$alive = Deal::factory()->for($this->tenant)->for($this->project)->create();
$r = $this->postJson('/api/deals/restore', [
'tenant_id' => $this->tenant->id,
'ids' => [$alive->id],
]);
$r->assertStatus(200)->assertJson([
@@ -88,7 +86,6 @@ test('POST /api/deals/restore defense-in-depth не восстанавливае
$own->delete();
$r = $this->postJson('/api/deals/restore', [
'tenant_id' => $this->tenant->id,
'ids' => [$own->id, $foreign->id],
]);
$r->assertStatus(200)->assertJson([
@@ -110,26 +107,23 @@ test('POST /api/deals/restore — после restore сделка снова в
// Удалили
$this->deleteJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'ids' => [$deal->id],
])->assertStatus(200);
// GET не возвращает
expect($this->getJson('/api/deals?tenant_id='.$this->tenant->id)->json('total'))->toBe(0);
expect($this->getJson('/api/deals')->json('total'))->toBe(0);
// Restore
$this->postJson('/api/deals/restore', [
'tenant_id' => $this->tenant->id,
'ids' => [$deal->id],
])->assertStatus(200);
// GET снова возвращает
expect($this->getJson('/api/deals?tenant_id='.$this->tenant->id)->json('total'))->toBe(1);
expect($this->getJson('/api/deals')->json('total'))->toBe(1);
});
test('POST /api/deals/restore 422 пустой массив ids', function () {
$this->postJson('/api/deals/restore', [
'tenant_id' => $this->tenant->id,
'ids' => [],
])->assertStatus(422);
});
+13 -14
View File
@@ -20,6 +20,9 @@ beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->otherTenant = Tenant::factory()->create();
$this->user = User::factory()->for($this->tenant)->create();
$this->actingAs($this->user);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$this->project = Project::factory()->for($this->tenant)->create(['name' => 'Окна Москва']);
$this->manager = User::factory()->for($this->tenant)->create([
@@ -29,18 +32,14 @@ beforeEach(function () {
]);
});
test('GET /api/deals/{id} 422 без tenant_id', function () {
test('GET /api/deals/{id} 401 без auth', function () {
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
$this->getJson('/api/deals/'.$deal->id)->assertStatus(422);
});
test('GET /api/deals/{id} 404 для unknown tenant', function () {
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
$this->getJson('/api/deals/'.$deal->id.'?tenant_id=999999')->assertStatus(404);
auth()->logout();
$this->getJson('/api/deals/'.$deal->id)->assertStatus(401);
});
test('GET /api/deals/{id} 404 если сделка не существует', function () {
$this->getJson('/api/deals/999999?tenant_id='.$this->tenant->id)->assertStatus(404);
$this->getJson('/api/deals/999999')->assertStatus(404);
});
test('GET /api/deals/{id} 404 если сделка чужого tenant\'а (defense-in-depth)', function () {
@@ -48,8 +47,8 @@ test('GET /api/deals/{id} 404 если сделка чужого tenant\'а (def
$foreignProject = Project::factory()->for($this->otherTenant)->create();
$foreign = Deal::factory()->for($this->otherTenant)->for($foreignProject)->create();
// Запрашиваем чужую сделку с нашим tenant_id — RLS+app-фильтр скрывают.
$this->getJson('/api/deals/'.$foreign->id.'?tenant_id='.$this->tenant->id)
// Запрашиваем чужую сделку — RLS+app-фильтр скрывают.
$this->getJson('/api/deals/'.$foreign->id)
->assertStatus(404);
});
@@ -65,7 +64,7 @@ test('GET /api/deals/{id} возвращает сделку с relations', funct
'comment' => 'Заметка менеджера',
]);
$r = $this->getJson('/api/deals/'.$deal->id.'?tenant_id='.$this->tenant->id);
$r = $this->getJson('/api/deals/'.$deal->id);
$r->assertStatus(200);
expect($r->json('deal.id'))->toBe($deal->id);
@@ -100,7 +99,7 @@ test('GET /api/deals/{id} возвращает activity events отсортир
'created_at' => now()->subMinutes(5),
]);
$r = $this->getJson('/api/deals/'.$deal->id.'?tenant_id='.$this->tenant->id);
$r = $this->getJson('/api/deals/'.$deal->id);
$r->assertStatus(200);
$events = $r->json('events');
@@ -137,7 +136,7 @@ test('GET /api/deals/{id} НЕ возвращает чужие activity events (
'context' => ['source' => 'webhook'],
]);
$r = $this->getJson('/api/deals/'.$deal->id.'?tenant_id='.$this->tenant->id);
$r = $this->getJson('/api/deals/'.$deal->id);
$events = $r->json('events');
expect($events)->toHaveCount(1);
@@ -159,7 +158,7 @@ test('GET /api/deals/{id} лимит 50 событий', function () {
]);
}
$r = $this->getJson('/api/deals/'.$deal->id.'?tenant_id='.$this->tenant->id);
$r = $this->getJson('/api/deals/'.$deal->id);
expect($r->json('events'))->toHaveCount(50);
});
+9 -14
View File
@@ -6,6 +6,7 @@ use App\Models\ActivityLog;
use App\Models\Deal;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
@@ -14,7 +15,7 @@ use Illuminate\Support\Facades\DB;
*
* Покрывает: validation (422 на missing/неизвестный slug), RLS+app-фильтр
* (чужие сделки НЕ обновляются), ActivityLog event=deal.status_changed,
* 404 unknown tenant, NO-OP не пишет audit entry, partial update (несколько id
* 401 без auth, NO-OP не пишет audit entry, partial update (несколько id
* принадлежат tenant'у, один нет updated < requested).
*/
uses(DatabaseTransactions::class);
@@ -23,6 +24,9 @@ beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->otherTenant = Tenant::factory()->create();
$this->user = User::factory()->for($this->tenant)->create();
$this->actingAs($this->user);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$this->project = Project::factory()->for($this->tenant)->create();
});
@@ -31,20 +35,15 @@ test('POST /api/deals/transition — 422 без обязательных пол
$this->postJson('/api/deals/transition', [])->assertStatus(422);
});
test('POST /api/deals/transition — 404 на unknown tenant', function () {
$r = $this->postJson('/api/deals/transition', [
'tenant_id' => 999999,
'ids' => [1],
'status' => 'paid',
]);
$r->assertStatus(404);
test('POST /api/deals/transition — 401 без auth', function () {
auth()->logout();
$this->postJson('/api/deals/transition', ['ids' => [1], 'status' => 'new'])->assertStatus(401);
});
test('POST /api/deals/transition — 422 на неизвестный status slug', function () {
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
$r = $this->postJson('/api/deals/transition', [
'tenant_id' => $this->tenant->id,
'ids' => [$deal->id],
'status' => 'not_a_real_slug',
]);
@@ -61,7 +60,6 @@ test('POST /api/deals/transition — обновляет статус и пише
$deals = Deal::factory()->count(3)->for($this->tenant)->for($this->project)->create(['status' => 'new']);
$r = $this->postJson('/api/deals/transition', [
'tenant_id' => $this->tenant->id,
'ids' => $deals->pluck('id')->all(),
'status' => 'paid',
]);
@@ -92,7 +90,6 @@ test('POST /api/deals/transition — NO-OP не пишет ActivityLog', functio
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
$r = $this->postJson('/api/deals/transition', [
'tenant_id' => $this->tenant->id,
'ids' => [$deal->id],
'status' => 'paid',
]);
@@ -111,9 +108,8 @@ test('POST /api/deals/transition — defense-in-depth не апдейтит чу
$foreignProject = Project::factory()->for($this->otherTenant)->create();
$foreign = Deal::factory()->for($this->otherTenant)->for($foreignProject)->create(['status' => 'new']);
// Передаём оба id, но tenant_id указываем наш — чужой не должен обновиться.
// Передаём оба id — чужой не должен обновиться.
$r = $this->postJson('/api/deals/transition', [
'tenant_id' => $this->tenant->id,
'ids' => [$own->id, $foreign->id],
'status' => 'paid',
]);
@@ -134,7 +130,6 @@ test('POST /api/deals/transition — defense-in-depth не апдейтит чу
test('POST /api/deals/transition — 422 если ids пустой массив', function () {
$this->postJson('/api/deals/transition', [
'tenant_id' => $this->tenant->id,
'ids' => [],
'status' => 'paid',
])->assertStatus(422);
+6 -18
View File
@@ -16,22 +16,18 @@ beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->otherTenant = Tenant::factory()->create();
$this->user = User::factory()->for($this->tenant)->create();
$this->actingAs($this->user);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$this->project = Project::factory()->for($this->tenant)->create();
$this->manager = User::factory()->for($this->tenant)->create(['is_active' => true]);
});
test('PATCH /api/deals/{id} 422 без tenant_id', function () {
test('PATCH /api/deals/{id} 401 без auth', function () {
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
$this->patchJson('/api/deals/'.$deal->id, [])->assertStatus(422);
});
test('PATCH /api/deals/{id} 404 unknown tenant', function () {
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
$this->patchJson('/api/deals/'.$deal->id, [
'tenant_id' => 999999,
'comment' => 'X',
])->assertStatus(404);
auth()->logout();
$this->patchJson('/api/deals/'.$deal->id, [])->assertStatus(401);
});
test('PATCH /api/deals/{id} 404 чужая сделка', function () {
@@ -40,7 +36,6 @@ test('PATCH /api/deals/{id} 404 чужая сделка', function () {
$foreign = Deal::factory()->for($this->otherTenant)->for($foreignProject)->create();
$this->patchJson('/api/deals/'.$foreign->id, [
'tenant_id' => $this->tenant->id,
'comment' => 'leak',
])->assertStatus(404);
});
@@ -49,7 +44,6 @@ test('PATCH /api/deals/{id} обновляет comment + пишет deal.comment
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['comment' => 'old']);
$r = $this->patchJson('/api/deals/'.$deal->id, [
'tenant_id' => $this->tenant->id,
'comment' => 'Дозвонился, перезвоню после 14:00',
]);
$r->assertStatus(200);
@@ -71,7 +65,6 @@ test('PATCH /api/deals/{id} обновляет manager_id + пишет deal.assi
]);
$r = $this->patchJson('/api/deals/'.$deal->id, [
'tenant_id' => $this->tenant->id,
'manager_id' => $this->manager->id,
]);
$r->assertStatus(200);
@@ -90,7 +83,6 @@ test('PATCH /api/deals/{id} обновляет status + пишет deal.status_c
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
$r = $this->patchJson('/api/deals/'.$deal->id, [
'tenant_id' => $this->tenant->id,
'status' => 'paid',
]);
$r->assertStatus(200);
@@ -108,7 +100,6 @@ test('PATCH /api/deals/{id} 422 на неизвестный status slug', functi
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
$r = $this->patchJson('/api/deals/'.$deal->id, [
'tenant_id' => $this->tenant->id,
'status' => 'not_a_real_slug',
]);
$r->assertStatus(422);
@@ -125,7 +116,6 @@ test('PATCH /api/deals/{id} 422 на manager_id чужого tenant\'а', functi
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create();
$r = $this->patchJson('/api/deals/'.$deal->id, [
'tenant_id' => $this->tenant->id,
'manager_id' => $foreignManager->id,
]);
$r->assertStatus(422);
@@ -138,7 +128,6 @@ test('PATCH /api/deals/{id} NO-OP не пишет ActivityLog', function () {
]);
$r = $this->patchJson('/api/deals/'.$deal->id, [
'tenant_id' => $this->tenant->id,
'status' => 'paid', // не меняем
'comment' => 'same', // не меняем
]);
@@ -155,7 +144,6 @@ test('PATCH /api/deals/{id} комбинированно — comment + status о
]);
$r = $this->patchJson('/api/deals/'.$deal->id, [
'tenant_id' => $this->tenant->id,
'comment' => 'Заметка',
'status' => 'worked',
]);
+3 -3
View File
@@ -65,8 +65,8 @@ test('POST /api/deals 422 если manager_id не принадлежит tenant
$otherManager = User::factory()->for($otherTenant)->create(['is_active' => true]);
// Назначаем чужого менеджера на свою сделку — должен быть 422.
$this->actingAs(User::factory()->for($this->tenant)->create());
$r = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 000-00-00',
'manager_id' => $otherManager->id,
@@ -79,8 +79,8 @@ test('POST /api/deals 422 если manager_id не активен (is_active=fal
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$inactive = User::factory()->for($this->tenant)->create(['is_active' => false]);
$this->actingAs(User::factory()->for($this->tenant)->create());
$r = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 000-00-00',
'manager_id' => $inactive->id,
@@ -92,8 +92,8 @@ test('POST /api/deals принимает manager_id из своего tenant\'а
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$manager = User::factory()->for($this->tenant)->create(['is_active' => true]);
$this->actingAs(User::factory()->for($this->tenant)->create());
$r = $this->postJson('/api/deals', [
'tenant_id' => $this->tenant->id,
'project_name' => 'X',
'phone' => '+7 (999) 000-00-00',
'manager_id' => $manager->id,
@@ -0,0 +1,25 @@
<?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);
});
@@ -0,0 +1,330 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import AdminBillingView from '../../resources/js/views/admin/AdminBillingView.vue';
import type { ApiAdminBillingTenant } from '../../resources/js/api/admin';
/**
* Создаёт объект, который проходит `axios.isAxiosError()` (проверяет флаг `isAxiosError: true`),
* с нужным `response.data.message`.
*/
function makeAxiosError(message: string, status = 422): unknown {
return Object.assign(new Error(message), {
isAxiosError: true,
response: { status, data: { message } },
});
}
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
return {
...orig,
listAdminBilling: vi.fn(),
listAdminTariffPlans: vi.fn(),
updateTenantStatus: vi.fn(),
refundTenant: vi.fn(),
changeTenantTariff: vi.fn(),
};
});
const adminApi = await import('../../resources/js/api/admin');
beforeEach(() => {
vi.clearAllMocks();
});
function makeApiBillingTenant(overrides: Partial<ApiAdminBillingTenant> = {}): ApiAdminBillingTenant {
return {
id: 42,
subdomain: 'acme',
organization_name: 'Acme ООО',
contact_email: 'admin@acme.io',
status: 'active',
balance_rub: '5000.00',
tariff_id: 1,
tariff_name: 'Команда',
mrr_rub: '990.00',
monthly_topups_rub: '10000.00',
monthly_charges_rub: '8000.00',
last_payment_at: '2026-05-01T10:00:00Z',
chargeback_unrecovered_rub: '0.00',
...overrides,
};
}
function makeBillingResponse(tenants: ApiAdminBillingTenant[]) {
return {
tenants,
summary: {
total_mrr_rub: '990.00',
monthly_revenue_rub: '10000.00',
overdue_count: 0,
refunds_count_30d: 0,
},
};
}
const mountView = () =>
mount(AdminBillingView, {
global: { plugins: [createVuetify()] },
});
describe('AdminBillingView — row-actions menu (G4)', () => {
it('каждая строка содержит кнопку действий [data-testid="row-actions-{id}"]', async () => {
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
);
const wrapper = mountView();
await flushPromises();
expect(wrapper.find('[data-testid="row-actions-42"]').exists()).toBe(true);
});
it('openAction("status", row) устанавливает actionDialog="status" и actionRow', async () => {
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
);
const wrapper = mountView();
await flushPromises();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
const row = vm.rowsState[0];
await vm.openAction('status', row);
await wrapper.vm.$nextTick();
expect(vm.actionDialog).toBe('status');
expect(vm.actionRow).toBe(row);
expect(vm.actionReason).toBe('');
expect(vm.actionError).toBe('');
});
it('confirmAction() вызывает updateTenantStatus и затем loadBilling', async () => {
vi.mocked(adminApi.listAdminBilling).mockResolvedValue(
makeBillingResponse([makeApiBillingTenant({ id: 42, status: 'active' })]),
);
vi.mocked(adminApi.updateTenantStatus).mockResolvedValueOnce({ id: 42, status: 'suspended' });
const wrapper = mountView();
await flushPromises();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
const row = vm.rowsState[0];
await vm.openAction('status', row);
vm.actionReason = 'Основание для блокировки тенанта';
await vm.confirmAction();
await flushPromises();
expect(adminApi.updateTenantStatus).toHaveBeenCalledWith(42, 'suspended', 'Основание для блокировки тенанта');
expect(adminApi.listAdminBilling).toHaveBeenCalledTimes(2); // mount + after action
expect(vm.actionDialog).toBeNull();
});
it('confirmAction() вызывает refundTenant с суммой и причиной', async () => {
vi.mocked(adminApi.listAdminBilling).mockResolvedValue(
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
);
vi.mocked(adminApi.refundTenant).mockResolvedValueOnce({
id: 42,
balance_rub: '4500.00',
transaction_id: 999,
});
const wrapper = mountView();
await flushPromises();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
const row = vm.rowsState[0];
await vm.openAction('refund', row);
vm.actionAmount = 500;
vm.actionReason = 'Возврат по заявке клиента';
await vm.confirmAction();
await flushPromises();
expect(adminApi.refundTenant).toHaveBeenCalledWith(42, 500, 'Возврат по заявке клиента');
expect(vm.actionDialog).toBeNull();
});
it('openAction("tariff") вызывает listAdminTariffPlans и заполняет tariffPlans', async () => {
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
);
vi.mocked(adminApi.listAdminTariffPlans).mockResolvedValueOnce([
{ id: 1, name: 'Старт', price_monthly: '490.00' },
{ id: 2, name: 'Команда', price_monthly: '990.00' },
]);
const wrapper = mountView();
await flushPromises();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
const row = vm.rowsState[0];
await vm.openAction('tariff', row);
await flushPromises();
expect(adminApi.listAdminTariffPlans).toHaveBeenCalledTimes(1);
expect(vm.tariffPlans).toHaveLength(2);
expect(vm.tariffPlans[0].name).toBe('Старт');
});
it('confirmAction("tariff") вызывает changeTenantTariff', async () => {
vi.mocked(adminApi.listAdminBilling).mockResolvedValue(
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
);
vi.mocked(adminApi.listAdminTariffPlans).mockResolvedValueOnce([
{ id: 2, name: 'Команда', price_monthly: '990.00' },
]);
vi.mocked(adminApi.changeTenantTariff).mockResolvedValueOnce({
id: 42,
tariff_id: 2,
tariff_name: 'Команда',
});
const wrapper = mountView();
await flushPromises();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
const row = vm.rowsState[0];
await vm.openAction('tariff', row);
await flushPromises();
vm.actionTariffId = 2;
vm.actionReason = 'Смена тарифа по просьбе клиента';
await vm.confirmAction();
await flushPromises();
expect(adminApi.changeTenantTariff).toHaveBeenCalledWith(42, 2, 'Смена тарифа по просьбе клиента');
expect(vm.actionDialog).toBeNull();
});
it('API-ошибка в confirmAction — actionError содержит backend-сообщение, диалог остаётся открытым', async () => {
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
);
vi.mocked(adminApi.updateTenantStatus).mockRejectedValueOnce(
makeAxiosError('Нельзя заблокировать — есть долги'),
);
const wrapper = mountView();
await flushPromises();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
const row = vm.rowsState[0];
await vm.openAction('status', row);
vm.actionReason = 'Причина блокировки тенанта';
await vm.confirmAction();
await flushPromises();
// Dialog stays open, exact backend message surfaces, loading cleared
expect(vm.actionDialog).toBe('status');
expect(vm.actionError).toBe('Нельзя заблокировать — есть долги');
expect(vm.actionLoading).toBe(false);
});
it('openAction("tariff") — ошибка загрузки тарифов устанавливает actionError, диалог остаётся открытым', async () => {
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
);
vi.mocked(adminApi.listAdminTariffPlans).mockRejectedValueOnce(
makeAxiosError('Тарифы временно недоступны', 503),
);
const wrapper = mountView();
await flushPromises();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
const row = vm.rowsState[0];
await vm.openAction('tariff', row);
await flushPromises();
expect(vm.actionDialog).toBe('tariff');
expect(vm.actionError).toBe('Тарифы временно недоступны');
});
it('возврат суммы больше баланса → actionError, refundTenant не вызывается', async () => {
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
makeBillingResponse([makeApiBillingTenant({ id: 42, balance_rub: '1000.00' })]),
);
const wrapper = mountView();
await flushPromises();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
const row = vm.rowsState[0]; // balance_rub = 1000
await vm.openAction('refund', row);
vm.actionAmount = 1500; // exceeds balance
vm.actionReason = 'Возврат по заявке клиента';
await vm.confirmAction();
expect(adminApi.refundTenant).not.toHaveBeenCalled();
expect(vm.actionError).toBe('Сумма возврата превышает баланс тенанта.');
expect(vm.actionDialog).toBe('refund');
});
it('NaN в сумме возврата → actionError, refundTenant не вызывается', async () => {
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
);
const wrapper = mountView();
await flushPromises();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
const row = vm.rowsState[0];
await vm.openAction('refund', row);
vm.actionAmount = NaN; // non-numeric input from v-model.number
vm.actionReason = 'Возврат по заявке клиента';
await vm.confirmAction();
expect(adminApi.refundTenant).not.toHaveBeenCalled();
expect(vm.actionError).toBeTruthy();
expect(vm.actionDialog).toBe('refund');
});
it('короткая причина (<10 символов) → confirmAction ставит actionError, не вызывает API', async () => {
vi.mocked(adminApi.listAdminBilling).mockResolvedValueOnce(
makeBillingResponse([makeApiBillingTenant({ id: 42 })]),
);
const wrapper = mountView();
await flushPromises();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
const row = vm.rowsState[0];
await vm.openAction('status', row);
vm.actionReason = 'Коротко'; // < 10 chars
await vm.confirmAction();
expect(adminApi.updateTenantStatus).not.toHaveBeenCalled();
expect(vm.actionError).toBeTruthy();
expect(vm.actionDialog).toBe('status'); // stays open
});
});
@@ -0,0 +1,180 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
import AdminIncidentDetailView from '../../resources/js/views/admin/AdminIncidentDetailView.vue';
import type { ApiAdminIncidentDetail } from '../../resources/js/api/admin';
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
return {
...orig,
getAdminIncidentDetail: vi.fn(),
notifyIncidentRkn: vi.fn(),
};
});
const adminApi = await import('../../resources/js/api/admin');
beforeEach(() => {
vi.clearAllMocks();
});
function makeDetail(overrides: Partial<ApiAdminIncidentDetail> = {}): ApiAdminIncidentDetail {
return {
id: 7,
incident_id: 'INC-2026-0516-0007',
type: 'data_breach',
severity: 'high',
summary: 'Утечка данных тенантов',
root_cause: 'Неправильная RLS-политика',
postmortem_url: 'https://example.com/postmortem',
started_at: '2026-05-16T10:00:00Z',
detected_at: '2026-05-16T10:30:00Z',
resolved_at: null,
status: 'investigating',
affected_tenants: [
{ id: 1, organization_name: 'Окна Москва ООО' },
{ id: 2, organization_name: 'ИП Петров' },
],
affected_users_count: 42,
notification_sent_at: null,
rkn_notified: false,
rkn_notified_at: null,
rkn_deadline_at: '2026-05-17T10:30:00Z',
created_by_admin: 'admin@liderra.ru',
closed_by_admin: null,
created_at: '2026-05-16T10:35:00Z',
updated_at: '2026-05-16T10:35:00Z',
...overrides,
};
}
const buildRouter = (id: number) => {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/admin/incidents', name: 'admin-incidents', component: { template: '<div />' } },
{
path: '/admin/incidents/:id',
name: 'admin-incident-detail',
component: AdminIncidentDetailView,
},
],
});
return router.push({ name: 'admin-incident-detail', params: { id } }).then(() => router);
};
const mountDetail = async (id: number) => {
const router = await buildRouter(id);
await router.isReady();
const wrapper = mount(AdminIncidentDetailView, {
global: {
plugins: [createVuetify(), router],
stubs: { teleport: true },
},
});
await flushPromises();
return wrapper;
};
describe('AdminIncidentDetailView.vue', () => {
it('вызывает getAdminIncidentDetail с id из route и рендерит summary/incident_id/severity', async () => {
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(makeDetail());
const wrapper = await mountDetail(7);
expect(adminApi.getAdminIncidentDetail).toHaveBeenCalledWith(7);
const text = wrapper.text();
expect(text).toContain('INC-2026-0516-0007');
expect(text).toContain('Утечка данных тенантов');
expect(text).toContain('High');
});
it('404 от API → data-testid="incident-not-found"', async () => {
vi.mocked(adminApi.getAdminIncidentDetail).mockRejectedValue({
response: { status: 404 },
});
const wrapper = await mountDetail(999);
expect(wrapper.find('[data-testid="incident-not-found"]').exists()).toBe(true);
});
it('500 от API → data-testid="incident-fetch-error" + кнопка Повторить', async () => {
vi.mocked(adminApi.getAdminIncidentDetail).mockRejectedValue({
response: { status: 500, data: { message: 'Backend error' } },
});
const wrapper = await mountDetail(7);
expect(wrapper.find('[data-testid="incident-fetch-error"]').exists()).toBe(true);
// retry button calls loadIncident
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(makeDetail());
const retryBtn = wrapper.find('[data-testid="incident-fetch-error"] button, [data-testid="incident-fetch-error"] .v-btn');
expect(retryBtn.exists()).toBe(true);
});
it('data_breach + rkn_notified=false → data-testid="rkn-notify-btn" видна', async () => {
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(makeDetail({ type: 'data_breach', rkn_notified: false }));
const wrapper = await mountDetail(7);
expect(wrapper.find('[data-testid="rkn-notify-btn"]').exists()).toBe(true);
});
it('клик rkn-notify → confirm → вызывает notifyIncidentRkn, карточка обновляется (rkn_notified=true, кнопка исчезает)', async () => {
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(
makeDetail({ type: 'data_breach', rkn_notified: false }),
);
const notified = makeDetail({ type: 'data_breach', rkn_notified: true, rkn_notified_at: '2026-05-16T11:00:00Z' });
vi.mocked(adminApi.notifyIncidentRkn).mockResolvedValue(notified);
const wrapper = await mountDetail(7);
// open dialog via btn
await wrapper.find('[data-testid="rkn-notify-btn"]').trigger('click');
await wrapper.vm.$nextTick();
// call confirmRkn directly via defineExpose
const vm = wrapper.vm as unknown as {
confirmRkn: () => Promise<void>;
incident: ApiAdminIncidentDetail | null;
rknDialog: boolean;
};
await vm.confirmRkn();
await flushPromises();
expect(adminApi.notifyIncidentRkn).toHaveBeenCalledWith(7);
expect(vm.incident?.rkn_notified).toBe(true);
expect(wrapper.find('[data-testid="rkn-notify-btn"]').exists()).toBe(false);
});
it('type !== data_breach → кнопка РКН-notify отсутствует', async () => {
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(
makeDetail({ type: 'service_outage', rkn_notified: false }),
);
const wrapper = await mountDetail(7);
expect(wrapper.find('[data-testid="rkn-notify-btn"]').exists()).toBe(false);
});
it('rkn_notified=true → показывает "РКН уведомлён", кнопки нет', async () => {
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(
makeDetail({ type: 'data_breach', rkn_notified: true, rkn_notified_at: '2026-05-17T08:00:00Z' }),
);
const wrapper = await mountDetail(7);
expect(wrapper.find('[data-testid="rkn-notify-btn"]').exists()).toBe(false);
expect(wrapper.text()).toContain('РКН уведомлён');
});
it('ошибка от notifyIncidentRkn → data-testid="rkn-error" виден', async () => {
vi.mocked(adminApi.getAdminIncidentDetail).mockResolvedValue(
makeDetail({ type: 'data_breach', rkn_notified: false }),
);
vi.mocked(adminApi.notifyIncidentRkn).mockRejectedValue(
new Error('РКН endpoint недоступен'),
);
const wrapper = await mountDetail(7);
const vm = wrapper.vm as unknown as {
confirmRkn: () => Promise<void>;
rknError: string;
};
await vm.confirmRkn();
await flushPromises();
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="rkn-error"]').exists()).toBe(true);
});
});
+23 -10
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
@@ -7,23 +7,24 @@ import AdminIncidentsView from '../../resources/js/views/admin/AdminIncidentsVie
const mountView = async () => {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/admin/incidents', component: AdminIncidentsView }],
routes: [
{ path: '/admin/incidents', name: 'admin-incidents', component: AdminIncidentsView },
{ path: '/admin/incidents/:id', name: 'admin-incident-detail', component: { template: '<div />' } },
],
});
await router.push('/admin/incidents');
await router.isReady();
return mount(AdminIncidentsView, {
global: { plugins: [createVuetify(), router] },
});
return { wrapper: mount(AdminIncidentsView, { global: { plugins: [createVuetify(), router] } }), router };
};
describe('AdminIncidentsView.vue', () => {
it('монтируется и содержит заголовок «Инциденты»', async () => {
const wrapper = await mountView();
const { wrapper } = await mountView();
expect(wrapper.text()).toContain('Инциденты');
});
it('содержит 3 stats: Открыто / Расследуется / РКН-уведомлений', async () => {
const wrapper = await mountView();
const { wrapper } = await mountView();
const text = wrapper.text();
expect(text).toContain('Открыто');
expect(text).toContain('Расследуется');
@@ -31,7 +32,7 @@ describe('AdminIncidentsView.vue', () => {
});
it('содержит фильтр-toggle по статусам (5 значений)', async () => {
const wrapper = await mountView();
const { wrapper } = await mountView();
const text = wrapper.text();
expect(text).toContain('Все');
expect(text).toContain('Открыты');
@@ -40,16 +41,28 @@ describe('AdminIncidentsView.vue', () => {
});
it('показывает PDN-breach с РКН pending chip', async () => {
const wrapper = await mountView();
const { wrapper } = await mountView();
const text = wrapper.text();
expect(text).toContain('Утечка ПДн');
expect(text).toContain('РКН pending');
});
it('содержит incident_id в формате INC-YYYY-MMDD-NNNN', async () => {
const wrapper = await mountView();
const { wrapper } = await mountView();
const text = wrapper.text();
expect(text).toContain('INC-2026-0507-0034');
expect(text).toContain('INC-2026-0506-0028');
});
it('клик по строке инцидента вызывает router.push на admin-incident-detail', async () => {
const { wrapper, router } = await mountView();
const pushSpy = vi.spyOn(router, 'push');
// get first row — mock data has id from ADMIN_INCIDENTS[0]
const vm = wrapper.vm as unknown as { rowsState: Array<{ id: number }> };
const firstId = vm.rowsState[0].id;
const row = wrapper.find(`[data-testid="incident-row-${firstId}"]`);
expect(row.exists()).toBe(true);
await row.trigger('click');
expect(pushSpy).toHaveBeenCalledWith({ name: 'admin-incident-detail', params: { id: firstId } });
});
});
+119
View File
@@ -23,6 +23,12 @@ import {
listAdminIncidents,
listSystemSettings,
updateSystemSetting,
listAdminTariffPlans,
updateTenantStatus,
refundTenant,
changeTenantTariff,
getAdminIncidentDetail,
notifyIncidentRkn,
} from '../../resources/js/api/admin';
import { apiClient, ensureCsrfCookie } from '../../resources/js/api/client';
@@ -353,4 +359,117 @@ describe('api/admin', () => {
vi.mocked(apiClient.get).mockRejectedValueOnce(new Error('500 Server Error'));
await expect(listAdminTenants({ status: 'active' })).rejects.toThrow('500 Server Error');
});
// === Sprint 3D G4: billing row-actions ===
it('listAdminTariffPlans() GET /api/admin/billing/tariff-plans + unwraps data.plans', async () => {
const plans = [{ id: 1, name: 'Базовый', price_monthly: '990.00' }];
vi.mocked(apiClient.get).mockResolvedValue({ data: { plans } });
const r = await listAdminTariffPlans();
expect(apiClient.get).toHaveBeenCalledWith('/api/admin/billing/tariff-plans');
expect(r).toHaveLength(1);
expect(r[0].name).toBe('Базовый');
});
it('updateTenantStatus() PATCH /api/admin/billing/tenants/{id}/status + ensureCsrfCookie', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 5, status: 'suspended' } });
const r = await updateTenantStatus(5, 'suspended', 'Нарушение условий');
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.patch).toHaveBeenCalledWith('/api/admin/billing/tenants/5/status', {
status: 'suspended',
reason: 'Нарушение условий',
});
expect(r.status).toBe('suspended');
});
it('refundTenant() POST /api/admin/billing/tenants/{id}/refund + ensureCsrfCookie', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: { id: 3, balance_rub: '5000.00', transaction_id: 101 },
});
const r = await refundTenant(3, 1000, 'Возврат по заявке');
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.post).toHaveBeenCalledWith('/api/admin/billing/tenants/3/refund', {
amount_rub: 1000,
reason: 'Возврат по заявке',
});
expect(r.transaction_id).toBe(101);
});
it('changeTenantTariff() PATCH /api/admin/billing/tenants/{id}/tariff + ensureCsrfCookie', async () => {
vi.mocked(apiClient.patch).mockResolvedValue({
data: { id: 2, tariff_id: 3, tariff_name: 'Команда' },
});
const r = await changeTenantTariff(2, 3, 'Апгрейд по договорённости');
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.patch).toHaveBeenCalledWith('/api/admin/billing/tenants/2/tariff', {
tariff_id: 3,
reason: 'Апгрейд по договорённости',
});
expect(r.tariff_name).toBe('Команда');
});
// === Sprint 3D G5/G6: incident detail + РКН-notify ===
it('getAdminIncidentDetail() GET /api/admin/incidents/{id} + unwraps data.incident', async () => {
const incident = {
id: 7,
incident_id: 'INC-2026-0516-0007',
type: 'data_breach',
severity: 'high',
summary: 'Тест',
root_cause: null,
postmortem_url: null,
started_at: '2026-05-16T10:00:00Z',
detected_at: '2026-05-16T10:30:00Z',
resolved_at: null,
status: 'investigating',
affected_tenants: [],
affected_users_count: null,
notification_sent_at: null,
rkn_notified: false,
rkn_notified_at: null,
rkn_deadline_at: null,
created_by_admin: null,
closed_by_admin: null,
created_at: null,
updated_at: null,
};
vi.mocked(apiClient.get).mockResolvedValue({ data: { incident } });
const r = await getAdminIncidentDetail(7);
expect(apiClient.get).toHaveBeenCalledWith('/api/admin/incidents/7');
expect(r.incident_id).toBe('INC-2026-0516-0007');
expect(r.id).toBe(7);
});
it('notifyIncidentRkn() POST /api/admin/incidents/{id}/rkn-notify + ensureCsrfCookie + unwraps data.incident', async () => {
const incident = {
id: 7,
incident_id: 'INC-2026-0516-0007',
type: 'data_breach',
severity: 'high',
summary: 'Тест',
root_cause: null,
postmortem_url: null,
started_at: '2026-05-16T10:00:00Z',
detected_at: '2026-05-16T10:30:00Z',
resolved_at: null,
status: 'investigating',
affected_tenants: [],
affected_users_count: null,
notification_sent_at: '2026-05-16T11:00:00Z',
rkn_notified: true,
rkn_notified_at: '2026-05-16T11:00:00Z',
rkn_deadline_at: '2026-05-17T10:30:00Z',
created_by_admin: null,
closed_by_admin: null,
created_at: null,
updated_at: null,
};
vi.mocked(apiClient.post).mockResolvedValue({ data: { incident } });
const r = await notifyIncidentRkn(7);
expect(ensureCsrfCookie).toHaveBeenCalledOnce();
expect(apiClient.post).toHaveBeenCalledWith('/api/admin/incidents/7/rkn-notify', {});
expect(r.rkn_notified).toBe(true);
expect(r.rkn_notified_at).toBe('2026-05-16T11:00:00Z');
});
});
+11
View File
@@ -1286,3 +1286,14 @@ recollage
пуш
изм
рерайтов
# Sprint 3F — API middleware J1/J2 plan (2026-05-16) — Russian IT-slang
роутов
стейджить
фронтенд
стаб
гейт
гвард
гварда
вестигиальным
спеков
+45 -41
View File
@@ -180,10 +180,10 @@ function pos(ring, angleDeg) {
const NODES = [
// ── ПРАВИЛА (4) ── центр + первое кольцо ───────
{ id: 'pravila', label: 'Pravila v1.14', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
{ id: 'claude_md', label: 'CLAUDE.md v2.0', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
{ id: 'psr_v1', label: 'PSR_v1 v3.0', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
{ id: 'tooling', label: 'Tooling v2.0', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
{ id: 'pravila', label: 'Pravila v1.16', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
{ id: 'claude_md', label: 'CLAUDE.md v2.2', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
{ id: 'psr_v1', label: 'PSR_v1 v3.2', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
{ id: 'tooling', label: 'Tooling v2.2', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
// ── ПЛАГИНЫ (5) ── второе кольцо ───────────────
{ id: 'superpowers', label: 'Superpowers v5.1', group: 'plugins', size: 30, ring: 2, ...pos(2, 45) },
@@ -416,7 +416,7 @@ const EDGES = [
CONFLICT('psr_v1', 'claude_md', 'Закрыто §5п.10 CLAUDE.md + хук CLAUDE.md-warn', 'GREEN'),
CONFLICT('upm', 'fd_plugin', 'PSR_v1 R14.5: не параллельно', 'GREEN'),
CONFLICT('mcp_21st', 'fd_plugin', 'PSR_v1 R14.5: не параллельно', 'GREEN'),
CONFLICT('hk_economy', 'superpowers', '§12 — sub-policy под ruflo; economy-режим §12 не отменяет (Pravila §12.4)', 'GREEN'),
CONFLICT('hk_economy', 'superpowers', '§12 — hard-rule уровня 0; economy-режим §12 не отменяет (Pravila §12.4)', 'GREEN'),
// ══════════════════════════════════════════════════
// RUFLO ОРКЕСТРАТОР — фактический реколлаж (iter5, 2026-05-15)
@@ -432,16 +432,16 @@ const EDGES = [
// память ruflo — recall-хук и воркер consolidate демона
E('ruflo_recall_hook', 'ruflo_memory', 'запускает\nruflo memory search'),
E('ruflo_daemon', 'ruflo_memory', 'воркер consolidate\nобращается к памяти'),
// Queen → 4 узла-правила (нормативная декларация уровня −1)
E('ruflo_queen', 'pravila', 'перенял\nsub-policy'),
E('ruflo_queen', 'claude_md', 'перенял\nsub-policy'),
E('ruflo_queen', 'psr_v1', 'перенял\nsub-policy'),
E('ruflo_queen', 'tooling', 'перенял\nsub-policy'),
// 4 узла-правила → Queen (реколлаж 16.05.2026: ruflo — advisory-подсистема; Pravila §14 — queen-триггер)
E('pravila', 'ruflo_queen', '§14:\nqueen-триггер'),
E('claude_md', 'ruflo_queen', '§3.5: описывает\n(advisory-подсистема)'),
E('psr_v1', 'ruflo_queen', '§14:\ncross-ref'),
E('tooling', 'ruflo_queen', '§4.10: реестр\n(advisory-подсистема)'),
// memory → ruflo
E('mem_ruflo', 'ruflo_queen', 'документирует\nинтеграцию'),
// 3 конфликта ruflo (3-color, iter2 §4)
CONFLICT('ruflo_queen', 'pravila', 'Нормативка декларирует ruflo уровнем −1 (overlord) — фактически параллельная подсистема: рой из 10 idle-воркеров, 0 задач', 'RED'),
CONFLICT('ruflo_queen', 'pravila', 'Закрыто реколлажем 16.05.2026: нормативка приведена к рантайму — ruflo переописан в advisory/automation-подсистему, декларация уровня −1 убрана', 'GREEN'),
CONFLICT('ruflo_memory', 'mem_state', 'Два хранилища памяти не синхронизированы; память ruflo почти пуста (0 записей)', 'BLACK'),
CONFLICT('ruflo_daemon', 'ag_pest', 'Daemon worker-jitter усиливает частоту Pest квирка 72', 'BLACK'),
];
@@ -478,8 +478,8 @@ const NODE_DETAILS = {
pravila: nd(
'Главный свод правил работы Клода — кто чем командует, что запрещено, какие обязательные действия.',
'Действует всегда — Клод читает его при старте каждой сессии.',
'§12 Superpowers стал sub-policy под ruflo Queen-led routing (уровень 1); в уровнях 06 цепочки приоритетов §12 остаётся hard-rule (скил инвокируется первым), economy-режим §12 не отменяет. Расходимость с другими документами — нарушение §7.',
[{ name: 'ruflo Queen', cond: 'уровень −1 по нормативке — ruflo Queen-led routing над Pravila' }],
'§12 Superpowers — hard-rule уровня 0 цепочки приоритетов: скил инвокируется первым, §9 «Отступления» не применяется, economy-режим §12 не отменяет. Расходимость с другими документами — нарушение §7.',
[],
[
{ name: 'CLAUDE.md', cond: 'подчинён, уровень 2a в цепочке приоритетов' },
{ name: 'PSR_v1', cond: 'подчинён, уровень 3 в цепочке приоритетов' },
@@ -487,15 +487,15 @@ const NODE_DETAILS = {
{ name: 'Все компоненты', cond: 'через цепочку приоритетов §1' }
],
[{ name: 'CLAUDE.md', cond: 'оба читаются при старте сессии' }],
[{ name: 'ruflo Queen', desc: 'Нормативка (Pravila §0/§12, CLAUDE.md §1, PSR_v1 R0) декларирует ruflo Queen-led routing уровнем 1 над Pravila — overlord. Фактическая инспекция рантайма 15.05.2026: рой hive-mind — Queen + 10 generic-воркеров, 0 задач и 0 раундов консенсуса за всё время; Клод работает напрямую. Декларация ≠ рантайм, механизма enforcement нет.', type: 'RED' }]
[{ name: 'ruflo Queen', desc: 'iter4–iter5: нормативка декларировала ruflo Queen-led routing уровнем 1 (overlord) — расходилось с рантаймом (рой idle, 0 задач). Реколлаж 16.05.2026 (Pravila v1.16 / CLAUDE.md v2.2 / PSR_v1 v3.2 / Tooling v2.2) привёл нормативку к факту: ruflo переописан в advisory/automation-подсистему, уровень −1 убран. Конфликт «декларация ≠ рантайм» закрыт.', type: 'GREEN' }]
),
claude_md: nd(
'Оперативная карта проекта — технологии, команды, фазы, 9-уровневая цепочка приоритетов (§1, уровень 1 — ruflo) и §3.5 orchestration layer, ссылки на документы.',
'Оперативная карта проекта — технологии, команды, фазы, 7-уровневая цепочка приоритетов (§1, уровни 06) и §3.5 — ruflo как advisory/automation-подсистема, ссылки на документы.',
'Читается при старте каждой сессии; обновляется при новом инструменте или новой фазе.',
'Править можно только через скил `/claude-md-management:claude-md-improver` или `:revise-claude-md` (правило §5 п.10). Прямые Edit/Write блокируются хуком предупреждения.',
[{ name: 'Pravila', cond: 'всегда подчинён (уровень 2a)' }],
[
{ name: 'Tooling v2.0', cond: 'ссылается как на реестр инструментов' },
{ name: 'Tooling v2.2', cond: 'ссылается как на реестр инструментов' },
{ name: 'плагин claude-md-management', cond: 'правило §5 п.10 — единственный канал правок' }
],
[
@@ -505,7 +505,7 @@ const NODE_DETAILS = {
[{ name: 'PSR_v1', desc: 'Правило §5 п.10 запрещает прямые правки, но PSR_v1 это явно не повторяет — есть риск Edit без скила', type: 'GREEN' }]
),
psr_v1: nd(
'Правила совместной работы плагинов — кто с кем работает, какая процедура обязательна. R0 stack-gate переформулирован в sub-policy paired-stack delegation pattern под ruflo Queen-led routing.',
'Правила совместной работы плагинов — кто с кем работает, какая процедура обязательна. R0 — головной фильтр выбора плагинов (с реколлажа 16.05.2026 снова на вершине стека, не sub-policy под ruflo).',
'При выборе UI-инструмента (плагин Frontend Design против плагина UI UX Pro Max против MCP-сервера 21st Magic), при координации парных плагинов, при включении дополнительного MCP (внешнего сервиса-инструмента Claude) вне основных фаз.',
'Обязательное правило R14.5: плагины UI UX Pro Max, 21st Magic, Frontend Design — нельзя использовать одновременно. Обязательное правило R6.0 (фильтр стека) и R6.1 (палитра Forest) — нужно соблюдать при UI-выводе плагинов.',
[{ name: 'Pravila', cond: 'подчинён, уровень 3 в цепочке' }],
@@ -518,7 +518,7 @@ const NODE_DETAILS = {
[{ name: 'CLAUDE.md', desc: 'CLAUDE.md §5 п.10 требует править только через скил claude-md-management, а PSR_v1 это ограничение не повторяет — риск прямых Edit', type: 'GREEN' }]
),
tooling: nd(
'Реестр 55 позиций — 35 формализованных инструментов + 20 ruflo-плагинов; +§4.10 Orchestration layer. Когда что использовать, команды установки, конфликты.',
'Реестр 55 позиций — 35 формализованных инструментов + 20 ruflo-плагинов; §4.10 — ruflo как advisory/automation-подсистема. Когда что использовать, команды установки, конфликты.',
'При выборе инструмента для фазы (нулевая документация / первая backend / вторая frontend / третья перед запуском в боевую среду), при добавлении нового инструмента, при обновлении версий.',
'При прямом конфликте с CLAUDE.md побеждает CLAUDE.md (оперативная карта уровня 2a). Любая правка требует синхронизации с CLAUDE.md §3.',
[
@@ -533,14 +533,14 @@ const NODE_DETAILS = {
superpowers: nd(
'Плагин поведения Клода — 14 скилов для тестов, отладки, планирования, параллельной работы.',
'При творческих, отладочных, тестовых и многошаговых задачах: скил brainstorming (продумать варианты) / скил TDD (разработка через тесты — failing test first) / скил systematic-debugging / скил verification-before-completion (обязательная проверка готовности) / скил writing-plans / скил parallel-work / скил worktree / скил finishing-PR (запрос на слияние кода) / скил subagent-driven-development / скил writing-skills (карта типов в §12.2 Pravila).',
'§12 Superpowers — sub-policy под ruflo Queen-led routing (уровень −1); в уровнях 0–6 остаётся hard-rule (скил инвокируется первым). Единственная отмена — явная просьба заказчика «не используй superpowers сейчас» на текущее действие; §9 «Отступления» к §12 не применяется; economy-режим §12 не отменяет.',
'§12 Superpowers — hard-rule уровня 0 цепочки приоритетов: скил инвокируется первым. Единственная отмена — явная просьба заказчика «не используй superpowers сейчас» на текущее действие; §9 «Отступления» к §12 не применяется; economy-режим §12 не отменяет.',
[
{ name: 'Pravila §12', cond: 'обязательное правило: скил запускается первым' },
{ name: 'PSR_v1', cond: 'координирует как пару с плагином Frontend Design' }
],
[{ name: 'Все 14 скилов Superpowers', cond: 'содержит' }],
[{ name: 'плагин Frontend Design', cond: 'пара — работают вместе при UI-задачах' }],
[{ name: 'хук economy-mode', desc: 'Режим экономии 100% теоретически может «сэкономить» запуск скила — §12 (sub-policy под ruflo Queen-led routing) economy-режим не отменяет.', type: 'GREEN' }]
[{ name: 'хук economy-mode', desc: 'Режим экономии 100% теоретически может «сэкономить» запуск скила — §12 (hard-rule уровня 0) economy-режим не отменяет.', type: 'GREEN' }]
),
fd_plugin: nd(
'Плагин знаний о UI — Vue, Vuetify, доступность (accessibility), паттерны компонентов для Лидерры.',
@@ -764,11 +764,11 @@ const NODE_DETAILS = {
hk_economy: nd(
'Перед каждым промптом разбирает «экономия X%» и выставляет режим строгости (0% = максимальное качество, 100% = по умолчанию).',
'UserPromptSubmit (перед отправкой промпта пользователя) — ищет шаблон /экономия\\s*(\\d+)%/.',
'§12 Superpowers — sub-policy под ruflo Queen-led routing, но economy-режим §12 НЕ отменяет ни на каком уровне. Действует только на текущую задачу — следующий промпт разбирается заново.',
'§12 Superpowers — hard-rule уровня 0, economy-режим §12 НЕ отменяет ни на каком уровне. Действует только на текущую задачу — следующий промпт разбирается заново.',
[{ name: '.claude/settings.json', cond: 'описан как хук UserPromptSubmit' }],
[],
[],
[{ name: 'плагин Superpowers (§12)', desc: 'Экономия=100% теоретически может «сэкономить» вызов скила — §12 (sub-policy под ruflo Queen-led routing) economy-режим не отменяет ни на каком уровне (Pravila §12.4).', type: 'GREEN' }]
[{ name: 'плагин Superpowers (§12)', desc: 'Экономия=100% теоретически может «сэкономить» вызов скила — §12 (hard-rule уровня 0) economy-режим не отменяет ни на каком уровне (Pravila §12.4).', type: 'GREEN' }]
),
// ── АГЕНТЫ ───────────────────────────────────────
@@ -1038,7 +1038,7 @@ const NODE_DETAILS = {
]
),
mem_sp: nd(
'Правило §12 (sub-policy под ruflo Queen-led routing) + архитектура хука economy из 6 компонентов — дисциплина вызова скилов.',
'Правило §12 (hard-rule уровня 0) + архитектура хука economy из 6 компонентов — дисциплина вызова скилов.',
'При работе со скилами — для соответствия обязательному правилу §12 Pravila.',
'Описывает архитектуру хука economy (6 компонентов) — менять только при изменении самого хука.',
[],
@@ -1137,10 +1137,13 @@ const NODE_DETAILS = {
// ── RUFLO ОРКЕСТРАТОР (фактический реколлаж iter5) ──
ruflo_queen: nd(
'Queen оркестратора ruflo v3.7.0-alpha.38 — стратегическая «королева» роя hive-mind (топология hierarchical-mesh, консенсус byzantine). По нормативке (CLAUDE.md §1) — entry-point уровня −1 для всех задач Клода.',
'По нормативке — первичная классификация любой задачи и маршрутизация. Фактически рой ни разу не запускался на реальную задачу — Клод работает напрямую.',
'Фактическая инспекция рантайма 15.05.2026: Queen активна (term 1, нагрузка 0), но за всё время — 0 задач, 0 раундов консенсуса, 0 общей памяти. ruflo НЕ перехватывает рабочий процесс Claude Code. «Queen-led routing уровня −1» — нормативная декларация, не рантайм. Alpha-версия, LLM API-ключей нет.',
[],
'Queen оркестратора ruflo v3.7.0-alpha.38 — стратегическая «королева» роя hive-mind (топология hierarchical-mesh, консенсус byzantine). С реколлажа 16.05.2026 (CLAUDE.md §3.5, Tooling §4.10) — advisory/automation-подсистема, не entry-point: рой работает параллельно, Клод — напрямую.',
'Запускается только по триггеру queen/королева в промпте (Pravila §14 — hard-rule маршрутизации). Без триггера рой простаивает — Клод работает напрямую.',
'Фактическая инспекция рантайма 15.05.2026: Queen активна (term 1, нагрузка 0), но за всё время — 0 задач, 0 раундов консенсуса, 0 общей памяти. Реколлаж 16.05.2026 привёл нормативку к этому факту — ruflo переописан в advisory/automation-подсистему, декларация уровня −1 убрана. Alpha-версия, LLM API-ключей нет.',
[
{ name: 'Pravila §14', cond: 'queen-триггер — hard-rule маршрутизации задачи через Queen' },
{ name: 'CLAUDE.md §3.5 / Tooling §4.10', cond: 'нормативно описан как advisory/automation-подсистема' }
],
[
{ name: '10 воркеров hive-mind', cond: 'координирует рой — все 10 простаивают' },
{ name: 'каталог агентов ruflo', cond: 'ruflo init высыпал в .claude/agents/ — не задействовано' },
@@ -1151,7 +1154,7 @@ const NODE_DETAILS = {
{ name: 'memory:project_ruflo_integration', cond: 'memory-файл документирует интеграцию' },
{ name: 'ruflo MCP', cond: 'MCP-сервер экспонирует инструменты управления роем' }
],
[{ name: 'Pravila', desc: 'Нормативка (Pravila §0/§12, CLAUDE.md §1, PSR_v1 R0) декларирует ruflo Queen-led routing уровнем 1 overlord над всей иерархией. Фактическая инспекция рантайма 15.05.2026: рой hive-mind — Queen + 10 generic-воркеров, 0 задач и 0 раундов консенсуса за всё время; Клод работает напрямую. Декларация ≠ рантайм, механизма enforcement нет.', type: 'RED' }]
[{ name: 'Pravila', desc: 'iter4–iter5: нормативка декларировала ruflo Queen-led routing уровнем 1 (overlord над всей иерархией) — расходилось с рантаймом (рой idle, 0 задач, 0 раундов консенсуса). Реколлаж 16.05.2026 (Pravila v1.16 / CLAUDE.md v2.2 / PSR_v1 v3.2 / Tooling v2.2) привёл нормативку к факту: ruflo переописан в advisory/automation-подсистему, уровень −1 убран. Конфликт «декларация ≠ рантайм» закрыт.', type: 'GREEN' }]
),
ruflo_workers: nd(
'Рабочие агенты роя hive-mind ruflo — 10 штук. Все одного типа (generic worker), без специализации. На карте до iter5 рисовались 9 «ролей» (Архитектор/Кодер/…) — таких ролей в рантайме не существует.',
@@ -1246,7 +1249,7 @@ const EDGE_DETAILS = {
'pravila->claude_md': { type: 'подчиняет', when: 'всегда — CLAUDE.md уровень ниже Pravila', transfers: 'контроль', mandatory: 'обязательно', rule: 'Pravila §1 (уровень 1→2a)' },
'pravila->psr_v1': { type: 'подчиняет', when: 'всегда — PSR_v1 уровень 3 ниже Pravila', transfers: 'контроль', mandatory: 'обязательно', rule: 'Pravila §1 (уровень 1→3)' },
'claude_md->tooling': { type: 'документирует', when: 'при правке реестра инструментов', transfers: 'документация', mandatory: 'обязательно', rule: 'CLAUDE.md §0, §3 (ссылка на Прил. Н)' },
'pravila->superpowers': { type: 'подчиняет', when: 'задача попадает под карту §12.2 (14 типов)', transfers: 'контроль', mandatory: 'hard-block', rule: 'Pravila §12 (sub-policy под ruflo Queen-led routing; в уровнях 06 — hard rule, §9 не применяется)' },
'pravila->superpowers': { type: 'подчиняет', when: 'задача попадает под карту §12.2 (14 типов)', transfers: 'контроль', mandatory: 'hard-block', rule: 'Pravila §12 (hard-rule уровня 0; скил первым, §9 не применяется)' },
// ── PSR_v1 координирует плагины ─────────────────
'psr_v1->superpowers': { type: 'координирует', when: 'paired-stack: процесс/решатель', transfers: 'контроль', mandatory: 'обязательно', rule: 'PSR_v1 R5 (paired stack ядро)' },
@@ -1335,10 +1338,10 @@ const EDGE_DETAILS = {
'psr_v1->claude_md': { type: 'конфликт', when: 'PSR_v1 уровень 3 vs CLAUDE.md 2a — приоритет CLAUDE.md', transfers: 'контроль', mandatory: 'hard-block', rule: 'CLAUDE.md §1 (priority chain)' },
'upm->fd_plugin': { type: 'конфликт', when: 'UPM и FD оба претендуют на UI-решения', transfers: 'coverage', mandatory: 'hard-block', rule: 'PSR_v1 R14.5 (не параллельно)' },
'mcp_21st->fd_plugin': { type: 'конфликт', when: '21st Magic и FD оба генераторы UI', transfers: 'coverage', mandatory: 'hard-block', rule: 'PSR_v1 R14.5 (не параллельно)' },
'hk_economy->superpowers': { type: 'конфликт', when: 'economy-режим теоретически может «сэкономить» вызов скила — §12 (sub-policy под ruflo) economy не отменяет', transfers: 'контроль', mandatory: 'hard-block', rule: 'Pravila §12.4 (только явный «не используй»)' },
'hk_economy->superpowers': { type: 'конфликт', when: 'economy-режим теоретически может «сэкономить» вызов скила — §12 (hard-rule уровня 0) economy не отменяет', transfers: 'контроль', mandatory: 'hard-block', rule: 'Pravila §12.4 (только явный «не используй»)' },
// ── RUFLO ОРКЕСТРАТОР — фактический реколлаж (iter5, 2026-05-15) ──
'ruflo_queen->ruflo_workers': { type: 'подчиняет', when: 'hive-mind активен, но рой ни разу не получал задач', transfers: 'контроль', mandatory: 'опционально (рой idle)', rule: 'Tooling §4.10 (orchestration layer)' },
// ── RUFLO ОРКЕСТРАТОР — реколлаж (iter5 2026-05-15 + нормативный sync 2026-05-16) ──
'ruflo_queen->ruflo_workers': { type: 'подчиняет', when: 'hive-mind активен, но рой ни разу не получал задач', transfers: 'контроль', mandatory: 'опционально (рой idle)', rule: 'Tooling §4.10 (ruflo как advisory-подсистема)' },
'ruflo_queen->ruflo_agents_catalog': { type: 'артефакт', when: '`ruflo init` высыпал каталог в .claude/agents/', transfers: 'ничего (файлы лежат)', mandatory: 'не задействовано', rule: 'артефакт установки ruflo' },
'ruflo_queen->ruflo_commands': { type: 'артефакт', when: '`ruflo init` высыпал команды в .claude/commands/', transfers: 'ничего (файлы лежат)', mandatory: 'не задействовано', rule: 'артефакт установки ruflo' },
'ruflo_queen->ruflo_plugins': { type: 'артефакт', when: 'плагины ruflo — 0 установлено из ~20 в реестре', transfers: 'ничего', mandatory: 'не задействовано', rule: 'артефакт установки ruflo' },
@@ -1346,10 +1349,11 @@ const EDGE_DETAILS = {
'ruflo_recall_hook->ruflo_memory': { type: 'читает', when: 'recall-хук на каждый промпт запускает `ruflo memory search`', transfers: 'данные', mandatory: '«мягко» (fail-open)', rule: '.claude/settings.json (UserPromptSubmit)' },
'ruflo_daemon->ruflo_memory': { type: 'читает', when: 'воркер consolidate демона обращается к памяти', transfers: 'данные', mandatory: 'опционально', rule: '.claude-flow daemon' },
'ruflo_mcp->ruflo_queen': { type: 'экспонирует', when: 'MCP-сервер отдаёт инструменты hive-mind_*/agent_*/swarm_*', transfers: 'инструменты', mandatory: 'опционально', rule: 'Tooling §4.10' },
'ruflo_queen->pravila': { type: 'конфликт', when: 'нормативка декларирует уровень −1, фактически parallel subsystem', transfers: 'контроль', mandatory: 'декларативно', rule: 'нет регламента enforcement (Pravila §0/§12, CLAUDE.md §1, PSR_v1 R0)' },
'ruflo_queen->claude_md': { type: 'перенял sub-policy', when: 'нормативная декларация уровня −1 (entry-point)', transfers: 'контроль (декларативно)', mandatory: 'декларативно — без enforcement', rule: 'CLAUDE.md §1 priority chain (уровень 1)' },
'ruflo_queen->psr_v1': { type: 'перенял sub-policy', when: 'нормативная декларация уровня −1', transfers: 'контроль (декларативно)', mandatory: 'декларативно — без enforcement', rule: 'PSR_v1 R0 (sub-policy delegation pattern)' },
'ruflo_queen->tooling': { type: 'перенял sub-policy', when: 'нормативная декларация уровня −1', transfers: 'контроль (декларативно)', mandatory: 'декларативно — без enforcement', rule: 'Tooling §4.10 (orchestration layer)' },
'ruflo_queen->pravila': { type: 'конфликт', when: 'реколлаж 16.05.2026 привёл нормативку к рантайму — конфликт «декларация ≠ рантайм» закрыт', transfers: 'coverage', mandatory: 'закрыто', rule: 'Закрыто реколлажем: Pravila v1.16 / CLAUDE.md v2.2 / PSR_v1 v3.2 / Tooling v2.2' },
'pravila->ruflo_queen': { type: 'триггерит', when: 'триггер-слова queen/королева в промпте — задача маршрутизируется через ruflo Queen', transfers: 'триггер', mandatory: 'hard-rule', rule: 'Pravila §14 (queen/королева → hive-mind spawn)' },
'claude_md->ruflo_queen': { type: 'документирует', when: 'CLAUDE.md §3.5 описывает ruflo как advisory/automation-подсистему', transfers: 'документация', mandatory: 'рекомендуется', rule: 'CLAUDE.md §3.5' },
'psr_v1->ruflo_queen': { type: 'документирует', when: 'PSR_v1 §14 — cross-ref на queen-триггер Pravila §14', transfers: 'документация', mandatory: 'рекомендуется', rule: 'PSR_v1 §14' },
'tooling->ruflo_queen': { type: 'документирует', when: 'Tooling §4.10 — реестр ruflo как advisory/automation-подсистемы', transfers: 'документация', mandatory: 'рекомендуется', rule: 'Tooling §4.10' },
'mem_ruflo->ruflo_queen': { type: 'документирует', when: 'memory-файл хранит историю ruflo-интеграции', transfers: 'данные', mandatory: 'рекомендуется', rule: 'memory/project_ruflo_integration.md' },
'ruflo_memory->mem_state': { type: 'конфликт', when: 'два хранилища памяти не синхронизированы; память ruflo почти пуста', transfers: 'coverage', mandatory: 'опционально', rule: 'нет регламента синхронизации (alpha-баг HNSW #1122)' },
'ruflo_daemon->ag_pest': { type: 'конфликт', when: 'daemon worker-jitter усиливает частоту Pest-квирка 72', transfers: 'coverage', mandatory: 'опционально', rule: 'memory feedback_environment квирк #93' },
@@ -1369,10 +1373,10 @@ const META_WINDOW = '0916.05.2026'; // окно подсчёта испо
// usesSrc: 'скил' | 'агент' | 'MCP' | 'хук' | 'memory-чтение' | 'коммиты' | 'инспекция' | '—'
const NODE_META = {
// ── ПРАВИЛА (4) — узлы-правила, напрямую не вызываются ──
pravila: { since: '06.05.2026', changed: '15.05.2026', uses: null, usesSrc: '—' },
claude_md: { since: '06.05.2026', changed: '15.05.2026', uses: null, usesSrc: '—' },
psr_v1: { since: '09.05.2026', changed: '15.05.2026', uses: null, usesSrc: '—' },
tooling: { since: '06.05.2026', changed: '15.05.2026', uses: null, usesSrc: '—' },
pravila: { since: '06.05.2026', changed: '16.05.2026', uses: null, usesSrc: '—' },
claude_md: { since: '06.05.2026', changed: '16.05.2026', uses: null, usesSrc: '—' },
psr_v1: { since: '09.05.2026', changed: '16.05.2026', uses: null, usesSrc: '—' },
tooling: { since: '06.05.2026', changed: '16.05.2026', uses: null, usesSrc: '—' },
// ── ПЛАГИНЫ (5) ──
superpowers: { since: '09.05.2026', changed: '—', uses: null, usesSrc: '—' },
@@ -1460,7 +1464,7 @@ const NODE_META = {
mem_github: { since: '07.05.2026', changed: '15.05.2026', uses: 33, usesSrc: 'memory-чтение' },
// ── RUFLO ОРКЕСТРАТОР (9) — все внедрены big-bang'ом 15.05.2026 ──
ruflo_queen: { since: '15.05.2026', changed: '', uses: 0, usesSrc: 'инспекция' },
ruflo_queen: { since: '15.05.2026', changed: '16.05.2026', uses: 0, usesSrc: 'инспекция' },
ruflo_plugins: { since: '15.05.2026', changed: '—', uses: 0, usesSrc: 'инспекция' },
ruflo_workers: { since: '15.05.2026', changed: '—', uses: 0, usesSrc: 'инспекция' },
ruflo_agents_catalog: { since: '15.05.2026', changed: '—', uses: 0, usesSrc: 'инспекция',
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,355 @@
# 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 молча игнорирует.