188 lines
7.4 KiB
PHP
188 lines
7.4 KiB
PHP
<?php
|
|
|
|
use App\Mail\NewDeviceSignInMail;
|
|
use App\Models\Operator;
|
|
use App\Models\User;
|
|
use App\Models\UserDevice;
|
|
use App\Services\Devices\DeviceRecognition;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Mail;
|
|
|
|
/**
|
|
* A new-device warning is only worth anything if it is rare.
|
|
*
|
|
* A warning that fires on ordinary behaviour trains its reader to delete it,
|
|
* and the one that matters goes with it. Most of these tests are therefore
|
|
* about the cases that must produce NO mail — they are the feature, far more
|
|
* than the case that does.
|
|
*/
|
|
function signIn(string $guard, $user, array $cookies = [], string $agent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/126.0 Safari/537.36', string $ip = '203.0.113.10')
|
|
{
|
|
$request = Request::create('/anmelden', 'POST', [], $cookies, [], [
|
|
'HTTP_USER_AGENT' => $agent,
|
|
'REMOTE_ADDR' => $ip,
|
|
]);
|
|
|
|
return app(DeviceRecognition::class)->recognise($user, $guard, $request);
|
|
}
|
|
|
|
it('does not warn about the very first device on an account', function () {
|
|
// Telling somebody they signed in from a new device, in reply to them
|
|
// creating the account, is noise on the one message that must stay worth
|
|
// reading.
|
|
$user = User::factory()->create();
|
|
|
|
$outcome = signIn('web', $user);
|
|
|
|
expect($outcome->isNew)->toBeTrue()
|
|
->and($outcome->shouldWarn)->toBeFalse()
|
|
->and(UserDevice::query()->where('authenticatable_id', $user->id)->count())->toBe(1);
|
|
});
|
|
|
|
it('warns when a second, unknown device appears', function () {
|
|
$user = User::factory()->create();
|
|
signIn('web', $user); // the first, silent
|
|
|
|
$outcome = signIn('web', $user); // no cookie: another browser
|
|
|
|
expect($outcome->isNew)->toBeTrue()
|
|
->and($outcome->shouldWarn)->toBeTrue();
|
|
});
|
|
|
|
it('stays quiet when the same device comes back', function () {
|
|
$user = User::factory()->create();
|
|
$first = signIn('web', $user);
|
|
$token = tokenFor($first->device);
|
|
|
|
$again = signIn('web', $user, [DeviceRecognition::COOKIE => $token]);
|
|
|
|
expect($again->isNew)->toBeFalse()
|
|
->and($again->shouldWarn)->toBeFalse()
|
|
->and($again->device->id)->toBe($first->device->id);
|
|
});
|
|
|
|
it('stays quiet when the browser updates itself', function () {
|
|
// The reason the user-agent string is not what identifies a device. Chrome
|
|
// ships every four weeks; recognising by the string would mean twelve
|
|
// warnings a year, per customer, describing nothing.
|
|
$user = User::factory()->create();
|
|
$first = signIn('web', $user, agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/126.0 Safari/537.36');
|
|
$token = tokenFor($first->device);
|
|
|
|
$again = signIn('web', $user,
|
|
cookies: [DeviceRecognition::COOKIE => $token],
|
|
agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/141.0 Safari/537.36',
|
|
);
|
|
|
|
expect($again->shouldWarn)->toBeFalse()
|
|
->and($again->device->id)->toBe($first->device->id);
|
|
});
|
|
|
|
it('stays quiet when the address changes, and follows it', function () {
|
|
// The reason the IP is not what identifies a device: it changes on every
|
|
// train, every café and every mobile handover.
|
|
$user = User::factory()->create();
|
|
$first = signIn('web', $user, ip: '203.0.113.10');
|
|
$token = tokenFor($first->device);
|
|
|
|
$again = signIn('web', $user, cookies: [DeviceRecognition::COOKIE => $token], ip: '198.51.100.77');
|
|
|
|
expect($again->shouldWarn)->toBeFalse()
|
|
// Still recorded, because the list is where somebody checks it — it is
|
|
// only barred from DECIDING anything.
|
|
->and($again->device->refresh()->last_ip)->toBe('198.51.100.77');
|
|
});
|
|
|
|
it('relabels a device when the browser changes, without treating it as new', function () {
|
|
$user = User::factory()->create();
|
|
$first = signIn('web', $user, agent: 'Mozilla/5.0 (Windows NT 10.0) Chrome/126.0 Safari/537.36');
|
|
$token = tokenFor($first->device);
|
|
|
|
expect($first->device->name)->toBe(__('devices.name_on', ['browser' => 'Chrome', 'platform' => 'Windows']));
|
|
|
|
$again = signIn('web', $user,
|
|
cookies: [DeviceRecognition::COOKIE => $token],
|
|
agent: 'Mozilla/5.0 (Windows NT 10.0) Firefox/130.0',
|
|
);
|
|
|
|
expect($again->shouldWarn)->toBeFalse()
|
|
->and($again->device->refresh()->name)->toBe(__('devices.name_on', ['browser' => 'Firefox', 'platform' => 'Windows']));
|
|
});
|
|
|
|
it('does not orphan the first person on a shared browser', function () {
|
|
// Two people, one machine. Issuing a fresh token for the second would
|
|
// replace the cookie, leaving the first with a device row nothing matches
|
|
// — and a new-device warning on their own desk the next time they sign in.
|
|
$alice = User::factory()->create();
|
|
$bob = User::factory()->create();
|
|
|
|
$aliceFirst = signIn('web', $alice);
|
|
$token = tokenFor($aliceFirst->device);
|
|
|
|
signIn('web', $bob, [DeviceRecognition::COOKIE => $token]);
|
|
$aliceAgain = signIn('web', $alice, [DeviceRecognition::COOKIE => $token]);
|
|
|
|
expect($aliceAgain->shouldWarn)->toBeFalse()
|
|
->and($aliceAgain->device->id)->toBe($aliceFirst->device->id)
|
|
->and(UserDevice::query()->where('token_hash', hash('sha256', $token))->count())->toBe(2);
|
|
});
|
|
|
|
it('keeps operators and customers apart even at the same id', function () {
|
|
// Operator 1 is not customer 1 (R21). A device shared across the two would
|
|
// let one of them silence the other's warning.
|
|
$user = User::factory()->create();
|
|
$operator = Operator::factory()->create();
|
|
|
|
$customerDevice = signIn('web', $user);
|
|
$token = tokenFor($customerDevice->device);
|
|
|
|
$operatorSignIn = signIn('operator', $operator, [DeviceRecognition::COOKIE => $token]);
|
|
|
|
expect($operatorSignIn->device->id)->not->toBe($customerDevice->device->id)
|
|
->and($operatorSignIn->device->guard)->toBe('operator');
|
|
});
|
|
|
|
it('sends the warning from the console sign-in path too', function () {
|
|
// Both ways in raise the same event, which is the whole reason it is hooked
|
|
// rather than the two controllers.
|
|
Mail::fake();
|
|
|
|
$operator = operator('Owner');
|
|
signIn('operator', $operator); // first, silent
|
|
|
|
event(new Illuminate\Auth\Events\Login('operator', $operator, false));
|
|
|
|
Mail::assertQueued(NewDeviceSignInMail::class);
|
|
});
|
|
|
|
it('never blocks a sign-in when recording the device fails', function () {
|
|
// A safeguard that locks the owner out when it breaks has stopped being
|
|
// one. Deliberate, and tested so nobody removes it as an oversight.
|
|
Mail::fake();
|
|
|
|
$user = User::factory()->create();
|
|
|
|
// A real failure rather than a stubbed one: the class is final on purpose,
|
|
// and a test that replaces it would prove only that the replacement throws.
|
|
// This is the actual shape of the outage — the device store unreachable.
|
|
Illuminate\Support\Facades\Schema::drop('login_sessions');
|
|
Illuminate\Support\Facades\Schema::drop('user_devices');
|
|
|
|
event(new Illuminate\Auth\Events\Login('web', $user, false));
|
|
|
|
// No throwsNoExceptions() needed: event() propagates whatever a listener
|
|
// throws, so an escaping exception fails this test on its own.
|
|
Mail::assertNothingQueued();
|
|
});
|
|
|
|
/** The plaintext token that was queued into the cookie for a device. */
|
|
function tokenFor(UserDevice $device): string
|
|
{
|
|
$queued = collect(Illuminate\Support\Facades\Cookie::getQueuedCookies())
|
|
->first(fn ($c) => $c->getName() === DeviceRecognition::COOKIE);
|
|
|
|
expect($queued)->not->toBeNull();
|
|
|
|
return $queued->getValue();
|
|
}
|