Files
portal/app/tests/Feature/Account/UserSessionsTest.php
T
Дмитрий 2225a8487e
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat(security): рабочая «Завершить сессию» — реальный отзыв активных сессий
UI-аудит: вкладка Безопасность показывала фейк-сессии с мёртвой кнопкой.
Теперь — реальные активные сессии + рабочий отзыв.

- UserSessionTracker (новый): запись сессии при входе (login + 2FA verify +
  recovery-use) в существующую таблицу user_sessions; отзыв = удаление строки
  + удаление сессии из Redis по session_id (реальный выход с устройства);
  logout снимает текущую сессию из списка. Best-effort (не ломает вход/выход).
- AccountController: GET /api/account/security отдаёт реальные сессии;
  DELETE /api/account/sessions/{id} — отзыв (только свои; чужая → 404).
- Фронт SessionsTable: список + кнопка «Завершить» (кроме текущей).
- phpstan-baseline обновлён (Pest-$this нового теста).

Pest: 10/10. Верификация: Playwright (2 сессии → «Завершить» → исчезла).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 17:15:26 +03:00

74 lines
2.5 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
function insertSession(int $userId, string $token, string $ua = 'Mozilla/5.0 (Windows NT 10.0) Chrome/120'): int
{
return DB::table('user_sessions')->insertGetId([
'user_id' => $userId,
'token_hash' => $token,
'ip_address' => '10.0.0.1',
'user_agent' => $ua,
'last_active_at' => now(),
'created_at' => now(),
'expires_at' => now()->addHours(2),
]);
}
test('GET /api/account/security возвращает активные сессии', function () {
insertSession($this->user->id, 'sess-token-aaa');
$response = $this->getJson('/api/account/security');
$response->assertOk();
expect($response->json('sessions'))->toBeArray()->toHaveCount(1);
expect($response->json('sessions.0.device'))->toContain('Chrome');
expect($response->json('sessions.0.id'))->toBeInt();
});
test('DELETE /api/account/sessions/{id} отзывает свою сессию', function () {
$id = insertSession($this->user->id, 'sess-token-bbb');
$this->deleteJson("/api/account/sessions/{$id}")->assertOk();
expect(DB::table('user_sessions')->where('id', $id)->exists())->toBeFalse();
});
test('DELETE чужой сессии: 404, чужая строка цела', function () {
$other = User::factory()->create(['tenant_id' => $this->tenant->id]);
$id = insertSession($other->id, 'sess-token-ccc');
$this->deleteJson("/api/account/sessions/{$id}")->assertStatus(404);
expect(DB::table('user_sessions')->where('id', $id)->exists())->toBeTrue();
});
test('просроченные сессии не показываются', function () {
DB::table('user_sessions')->insert([
'user_id' => $this->user->id,
'token_hash' => 'sess-token-expired',
'ip_address' => '10.0.0.2',
'user_agent' => 'old',
'last_active_at' => now()->subDay(),
'created_at' => now()->subDay(),
'expires_at' => now()->subHour(),
]);
$response = $this->getJson('/api/account/security');
$response->assertOk();
expect($response->json('sessions'))->toHaveCount(0);
});