CluPilotCloud/tests/Feature/NewDeviceWarningTest.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();
}