clusev/tests/Feature/SessionManagementTest.php

181 lines
6.7 KiB
PHP

<?php
namespace Tests\Feature;
use App\Livewire\Modals\ConfirmAction;
use App\Livewire\Settings\Sessions;
use App\Models\AuditEvent;
use App\Models\User;
use App\Services\SessionService;
use App\Support\Confirm\ConfirmToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Session;
use Livewire\Livewire;
use Tests\TestCase;
class SessionManagementTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// phpunit.xml pins SESSION_DRIVER=array for the suite; the session-management
// feature only exists with the database driver (rows in the `sessions` table).
config(['session.driver' => 'database']);
}
/** Insert a raw session row for $user; returns the generated id. */
private function seedSession(User $user, string $id, ?string $ip = '203.0.113.1', ?string $ua = 'Mozilla/5.0 Chrome', int $ago = 0): string
{
DB::table('sessions')->insert([
'id' => $id,
'user_id' => $user->id,
'ip_address' => $ip,
'user_agent' => $ua,
'payload' => base64_encode(serialize([])),
'last_activity' => now()->subSeconds($ago)->getTimestamp(),
]);
return $id;
}
public function test_for_user_returns_rows_with_current_flagged(): void
{
$user = User::factory()->create();
$other = User::factory()->create();
$currentId = Session::getId();
$this->seedSession($user, $currentId, ago: 0);
$this->seedSession($user, 'sess-old', ago: 600);
$this->seedSession($other, 'sess-other'); // must NOT appear
$rows = app(SessionService::class)->forUser($user);
$this->assertCount(2, $rows);
// Newest activity first.
$this->assertSame($currentId, $rows[0]['id']);
$this->assertTrue($rows[0]['isCurrent']);
$this->assertSame('sess-old', $rows[1]['id']);
$this->assertFalse($rows[1]['isCurrent']);
$this->assertSame('203.0.113.1', $rows[0]['ip']);
$this->assertStringContainsString('Chrome', $rows[0]['userAgentShort']);
$this->assertTrue($rows[0]['lastActivity']->isToday());
}
public function test_logout_other_devices_deletes_others_keeps_current(): void
{
$user = User::factory()->create();
$currentId = Session::getId();
$this->seedSession($user, $currentId);
$this->seedSession($user, 'sess-b');
$this->seedSession($user, 'sess-c');
app(SessionService::class)->logoutOtherDevices($user);
$this->assertDatabaseHas('sessions', ['id' => $currentId]);
$this->assertDatabaseMissing('sessions', ['id' => 'sess-b']);
$this->assertDatabaseMissing('sessions', ['id' => 'sess-c']);
}
public function test_logout_user_everywhere_deletes_all_and_rotates_token(): void
{
$user = User::factory()->create(['remember_token' => 'original-token']);
$this->seedSession($user, Session::getId());
$this->seedSession($user, 'sess-b');
app(SessionService::class)->logoutUserEverywhere($user);
$this->assertSame(0, DB::table('sessions')->where('user_id', $user->id)->count());
$this->assertNotSame('original-token', $user->fresh()->remember_token);
$this->assertNotNull($user->fresh()->remember_token);
}
public function test_logout_everyone_truncates_and_rotates_all_tokens(): void
{
$a = User::factory()->create(['remember_token' => 'token-a']);
$b = User::factory()->create(['remember_token' => 'token-b']);
$this->seedSession($a, 'sess-a');
$this->seedSession($b, 'sess-b');
app(SessionService::class)->logoutEveryone();
$this->assertSame(0, DB::table('sessions')->count());
$this->assertNotSame('token-a', $a->fresh()->remember_token);
$this->assertNotSame('token-b', $b->fresh()->remember_token);
}
public function test_component_lists_the_users_sessions(): void
{
$user = User::factory()->create();
$this->seedSession($user, Session::getId());
Livewire::actingAs($user)->test(Sessions::class)
->assertOk()
->assertViewHas('sessions', fn ($sessions) => count($sessions) === 1);
}
public function test_logout_others_action_confirms_then_deletes(): void
{
$user = User::factory()->create();
$current = Session::getId();
$this->seedSession($user, $current);
$this->seedSession($user, 'sess-other');
// The button opens the R5 confirm modal carrying the apply event + audit descriptor.
Livewire::actingAs($user)->test(Sessions::class)
->call('confirmLogoutOthers')
->assertDispatched('openModal');
// The confirm modal re-dispatches the apply event with the signed token; simulate that.
$this->actingAs($user);
$token = ConfirmToken::issue('sessionsLogoutOthers');
ConfirmToken::confirm($token);
Livewire::actingAs($user)->test(Sessions::class)
->call('logoutOthers', $token);
$this->assertDatabaseHas('sessions', ['id' => $current]);
$this->assertDatabaseMissing('sessions', ['id' => 'sess-other']);
}
public function test_logout_all_action_truncates_and_redirects_to_login(): void
{
$user = User::factory()->create(['remember_token' => 'tok']);
$this->seedSession($user, Session::getId());
Livewire::actingAs($user)->test(Sessions::class)
->call('confirmLogoutAll')
->assertDispatched('openModal');
$this->actingAs($user);
$token = ConfirmToken::issue('sessionsLogoutAll');
ConfirmToken::confirm($token);
Livewire::actingAs($user)->test(Sessions::class)
->call('logoutAll', $token)
->assertRedirect(route('login'));
$this->assertSame(0, DB::table('sessions')->count());
$this->assertNotSame('tok', $user->fresh()->remember_token);
}
public function test_confirm_modal_writes_audit_row_on_confirm(): void
{
// The generic confirm modal persists the audit row from the SEALED descriptor
// in the signed token (mirrors Settings\Security). Exercise it directly.
$user = User::factory()->create();
$this->actingAs($user);
$token = ConfirmToken::issue('sessionsLogoutOthers', [], 'session.logout_others', $user->email);
Livewire::actingAs($user)->test(ConfirmAction::class, ['token' => $token])->call('confirm');
$this->assertDatabaseHas('audit_events', [
'action' => 'session.logout_others',
'target' => $user->email,
]);
$this->assertSame(1, AuditEvent::where('action', 'session.logout_others')->count());
}
}