$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(); }