142 lines
5.7 KiB
PHP
142 lines
5.7 KiB
PHP
<?php
|
|
|
|
use App\Livewire\Sessions as PortalSessions;
|
|
use App\Models\LoginSession;
|
|
use App\Models\User;
|
|
use App\Models\UserDevice;
|
|
use App\Services\Devices\SessionRegistry;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Livewire\Livewire;
|
|
|
|
/**
|
|
* Seeing where an account is signed in, and ending one of those places.
|
|
*
|
|
* The scoping tests are the point. Operators and customers are two guards and
|
|
* two tables (R21), so operator 1 and customer 1 share an integer and nothing
|
|
* else — an unscoped query here would let either of them sign the other out,
|
|
* and it would look exactly like a working feature while doing it.
|
|
*/
|
|
function makeSession(string $guard, int $id, string $sessionId, string $device = 'Chrome auf macOS', ?int $lastActivity = null): LoginSession
|
|
{
|
|
$deviceRow = UserDevice::create([
|
|
'guard' => $guard,
|
|
'authenticatable_id' => $id,
|
|
'token_hash' => hash('sha256', Str::random(40)),
|
|
'name' => $device,
|
|
'last_seen_at' => now(),
|
|
]);
|
|
|
|
DB::table('sessions')->insert([
|
|
'id' => $sessionId,
|
|
'ip_address' => '203.0.113.10',
|
|
'user_agent' => 'test',
|
|
'payload' => '',
|
|
'last_activity' => $lastActivity ?? now()->getTimestamp(),
|
|
]);
|
|
|
|
return LoginSession::create([
|
|
'device_id' => $deviceRow->id,
|
|
'session_id' => $sessionId,
|
|
'ip_address' => '203.0.113.10',
|
|
'last_seen_at' => now(),
|
|
]);
|
|
}
|
|
|
|
it('lists the sessions of one identity and marks the current one', function () {
|
|
$user = User::factory()->create();
|
|
$here = makeSession('web', $user->id, 'session-here', 'Chrome auf macOS');
|
|
makeSession('web', $user->id, 'session-there', 'Safari auf iPhone');
|
|
|
|
$rows = app(SessionRegistry::class)->for('web', $user->id, $here->uuid);
|
|
|
|
expect($rows)->toHaveCount(2)
|
|
->and($rows->firstWhere('uuid', $here->uuid)->is_current)->toBeTrue()
|
|
->and($rows->firstWhere('device', 'Safari auf iPhone')->is_current)->toBeFalse();
|
|
});
|
|
|
|
it('does not show a customer the sessions of the operator with the same id', function () {
|
|
$user = User::factory()->create();
|
|
makeSession('web', $user->id, 'session-customer');
|
|
makeSession('operator', $user->id, 'session-operator', 'Firefox auf Linux');
|
|
|
|
$rows = app(SessionRegistry::class)->for('web', $user->id);
|
|
|
|
expect($rows)->toHaveCount(1)
|
|
->and($rows->first()->device)->not->toBe('Firefox auf Linux');
|
|
});
|
|
|
|
it('refuses to end a session that belongs to the other identity', function () {
|
|
$user = User::factory()->create();
|
|
$operatorSession = makeSession('operator', $user->id, 'session-operator');
|
|
|
|
$ended = app(SessionRegistry::class)->end('web', $user->id, $operatorSession->uuid);
|
|
|
|
expect($ended)->toBeFalse()
|
|
->and(DB::table('sessions')->where('id', 'session-operator')->exists())->toBeTrue();
|
|
});
|
|
|
|
it('ending a session removes the framework row, which is what signs the browser out', function () {
|
|
$user = User::factory()->create();
|
|
$other = makeSession('web', $user->id, 'session-there');
|
|
|
|
expect(app(SessionRegistry::class)->end('web', $user->id, $other->uuid))->toBeTrue()
|
|
->and(DB::table('sessions')->where('id', 'session-there')->exists())->toBeFalse()
|
|
->and(LoginSession::query()->where('uuid', $other->uuid)->exists())->toBeFalse();
|
|
});
|
|
|
|
it('leaves the session doing the asking alone when ending the others', function () {
|
|
// Signing somebody out of the page they are using to sign other people out
|
|
// is not what the button says, and leaves them unable to see whether it
|
|
// worked.
|
|
$user = User::factory()->create();
|
|
$here = makeSession('web', $user->id, 'session-here');
|
|
makeSession('web', $user->id, 'session-b');
|
|
makeSession('web', $user->id, 'session-c');
|
|
|
|
$ended = app(SessionRegistry::class)->endOthers('web', $user->id, $here->uuid);
|
|
|
|
expect($ended)->toBe(2)
|
|
->and(DB::table('sessions')->where('id', 'session-here')->exists())->toBeTrue()
|
|
->and(DB::table('sessions')->count())->toBe(1);
|
|
});
|
|
|
|
it('hides a session whose framework row has expired but not yet been collected', function () {
|
|
// Laravel collects expired sessions by lottery, so on a quiet installation
|
|
// rows sit there for days. Listing them offers somebody a laptop they shut
|
|
// last month as somewhere they are signed in right now.
|
|
$user = User::factory()->create();
|
|
makeSession('web', $user->id, 'session-stale', 'Chrome auf Windows',
|
|
lastActivity: now()->subMinutes((int) config('session.lifetime') + 5)->getTimestamp());
|
|
|
|
expect(app(SessionRegistry::class)->for('web', $user->id))->toHaveCount(0);
|
|
});
|
|
|
|
it('lets a customer end another session from the settings page', function () {
|
|
$user = User::factory()->create();
|
|
$other = makeSession('web', $user->id, 'session-there', 'Safari auf iPhone');
|
|
|
|
Livewire::actingAs($user)
|
|
->test(PortalSessions::class)
|
|
->assertSee('Safari auf iPhone')
|
|
->call('end', $other->uuid)
|
|
->assertDispatched('notify', message: __('sessions.ended'));
|
|
|
|
expect(DB::table('sessions')->where('id', 'session-there')->exists())->toBeFalse();
|
|
});
|
|
|
|
it('ends the others only when the confirmation modal says so', function () {
|
|
// R23: the destructive one goes through the product's own modal, which
|
|
// mutates nothing and raises an event this component catches — so the
|
|
// identity resolution stays at the one place that already had it.
|
|
$user = User::factory()->create();
|
|
makeSession('web', $user->id, 'session-b');
|
|
makeSession('web', $user->id, 'session-c');
|
|
|
|
Livewire::actingAs($user)
|
|
->test(PortalSessions::class)
|
|
->dispatch('sessions-end-others-confirmed')
|
|
->assertDispatched('notify');
|
|
|
|
expect(DB::table('sessions')->count())->toBe(0);
|
|
});
|