mirror of
https://github.com/snipe/snipe-it.git
synced 2026-08-18 03:06:23 +00:00
Superadmin: Added ability to login (impersonate) another user
This commit is contained in:
@ -221,6 +221,11 @@ LOG_CHANNEL=single
|
||||
LOG_DEPRECATIONS=false
|
||||
LOG_MAX_DAYS=10
|
||||
APP_LOCKED=false
|
||||
# Comma-separated usernames allowed to impersonate other users (e.g. admin,jsmith).
|
||||
# Users in this list must ALSO be superusers. Wrap usernames that contain commas
|
||||
# in double quotes, e.g. ALLOW_USER_IMPERSONATION=admin,"jane, doe". Leave blank
|
||||
# to disable entirely.
|
||||
ALLOW_USER_IMPERSONATION=
|
||||
APP_CIPHER=AES-256-CBC
|
||||
APP_FORCE_TLS=false
|
||||
APP_ALLOW_INSECURE_HOSTS=false
|
||||
|
||||
@ -26,6 +26,8 @@ enum ActionType: string
|
||||
case Merged = 'merged';
|
||||
case TokenRevoked = 'token revoked';
|
||||
case TokenUnrevoked = 'token unrevoked';
|
||||
case Impersonated = 'impersonated';
|
||||
case StoppedImpersonating = 'stopped impersonating';
|
||||
|
||||
// Licenses
|
||||
case DeleteSeats = 'delete seats';
|
||||
|
||||
@ -122,6 +122,10 @@ class IconHelper
|
||||
return 'fa-solid fa-key';
|
||||
case 'api-key':
|
||||
return 'fas fa-user-secret';
|
||||
case 'impersonate':
|
||||
return 'fa-solid fa-person-walking-arrow-right';
|
||||
case 'undo':
|
||||
return 'fas fa-arrow-left';
|
||||
case 'nav-toggle':
|
||||
return 'fas fa-bars';
|
||||
case 'dashboard':
|
||||
|
||||
@ -54,7 +54,23 @@ class SettingsController extends Controller
|
||||
{
|
||||
$settings = Setting::getSettings();
|
||||
|
||||
return view('settings/index', compact('settings'));
|
||||
$impersonationUsernames = (array) config('app.user_impersonation_usernames');
|
||||
if (empty($impersonationUsernames)) {
|
||||
$impersonators = collect();
|
||||
$missingImpersonationUsernames = [];
|
||||
} else {
|
||||
$impersonators = User::withTrashed()
|
||||
->whereIn(DB::raw('LOWER(username)'), array_map('mb_strtolower', $impersonationUsernames))
|
||||
->orderBy('username')
|
||||
->get();
|
||||
$foundLower = $impersonators->map(fn ($u) => mb_strtolower((string) $u->username))->all();
|
||||
$missingImpersonationUsernames = array_values(array_filter(
|
||||
$impersonationUsernames,
|
||||
fn ($name) => ! in_array(mb_strtolower($name), $foundLower, true)
|
||||
));
|
||||
}
|
||||
|
||||
return view('settings/index', compact('settings', 'impersonators', 'missingImpersonationUsernames'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
92
app/Http/Controllers/Users/ImpersonateController.php
Normal file
92
app/Http/Controllers/Users/ImpersonateController.php
Normal file
@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Users;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Actionlog;
|
||||
use App\Models\CompanyableScope;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ImpersonateController extends Controller
|
||||
{
|
||||
public function start(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
$actor = Auth::user();
|
||||
|
||||
if (empty(config('app.user_impersonation_usernames'))) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! $actor || ! $actor->canImpersonate()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($user->id === $actor->id) {
|
||||
return redirect()->route('users.show', $user)
|
||||
->with('error', trans('admin/users/message.impersonate.cannot_impersonate_self'));
|
||||
}
|
||||
|
||||
if ($user->deleted_at !== null || $user->activated != 1) {
|
||||
return redirect()->route('users.show', $user)
|
||||
->with('error', trans('admin/users/message.impersonate.target_not_active'));
|
||||
}
|
||||
|
||||
$log = new Actionlog;
|
||||
$log->item_type = User::class;
|
||||
$log->item_id = $user->id;
|
||||
$log->target_type = User::class;
|
||||
$log->target_id = $user->id;
|
||||
$log->created_at = date('Y-m-d H:i:s');
|
||||
$log->created_by = $actor->id;
|
||||
$log->logaction('impersonated');
|
||||
|
||||
$impersonatorId = $actor->id;
|
||||
Auth::login($user);
|
||||
$request->session()->put('impersonator_id', $impersonatorId);
|
||||
|
||||
return redirect()->route('home')
|
||||
->with('success', trans('admin/users/message.impersonate.started', ['name' => $user->display_name]));
|
||||
}
|
||||
|
||||
public function stop(Request $request): RedirectResponse
|
||||
{
|
||||
$impersonatorId = $request->session()->pull('impersonator_id');
|
||||
|
||||
if (! $impersonatorId) {
|
||||
return redirect()->route('home');
|
||||
}
|
||||
|
||||
$impersonatedId = Auth::id();
|
||||
// Bypass CompanyableScope: the impersonated user may not share a company with the
|
||||
// original superuser, but we still need to restore their session.
|
||||
$impersonator = User::withTrashed()
|
||||
->withoutGlobalScope(CompanyableScope::class)
|
||||
->find($impersonatorId);
|
||||
|
||||
if (! $impersonator) {
|
||||
Auth::logout();
|
||||
|
||||
return redirect()->route('login')
|
||||
->with('error', trans('admin/users/message.impersonate.impersonator_missing'));
|
||||
}
|
||||
|
||||
if ($impersonatedId) {
|
||||
$log = new Actionlog;
|
||||
$log->item_type = User::class;
|
||||
$log->item_id = $impersonatedId;
|
||||
$log->target_type = User::class;
|
||||
$log->target_id = $impersonatedId;
|
||||
$log->created_at = date('Y-m-d H:i:s');
|
||||
$log->created_by = $impersonator->id;
|
||||
$log->logaction('stopped impersonating');
|
||||
}
|
||||
|
||||
Auth::login($impersonator);
|
||||
|
||||
return redirect()->route('users.show', $impersonatedId ?: $impersonator->id)
|
||||
->with('success', trans('admin/users/message.impersonate.stopped'));
|
||||
}
|
||||
}
|
||||
@ -552,6 +552,17 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
return $this->checkPermissionSection('superuser');
|
||||
}
|
||||
|
||||
public function canImpersonate(): bool
|
||||
{
|
||||
if (! $this->isSuperUser()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowed = array_map('mb_strtolower', (array) config('app.user_impersonation_usernames'));
|
||||
|
||||
return in_array(mb_strtolower((string) $this->username), $allowed, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user is an admin
|
||||
*
|
||||
|
||||
@ -23,6 +23,7 @@ use App\Observers\LocationObserver;
|
||||
use App\Observers\MaintenanceObserver;
|
||||
use App\Observers\SettingObserver;
|
||||
use App\Observers\UserObserver;
|
||||
use App\View\Composers\ImpersonationBannerComposer;
|
||||
use App\View\Composers\SidebarComposer;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
use Illuminate\Routing\UrlGenerator;
|
||||
@ -78,6 +79,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
Paginator::useBootstrap();
|
||||
|
||||
View::composer('layouts.default', SidebarComposer::class);
|
||||
View::composer('partials.impersonation-banner', ImpersonationBannerComposer::class);
|
||||
|
||||
Schema::defaultStringLength(191);
|
||||
Accessory::observe(AccessoryObserver::class);
|
||||
|
||||
28
app/View/Composers/ImpersonationBannerComposer.php
Normal file
28
app/View/Composers/ImpersonationBannerComposer.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Composers;
|
||||
|
||||
use App\Models\CompanyableScope;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ImpersonationBannerComposer
|
||||
{
|
||||
public function compose(View $view): void
|
||||
{
|
||||
$impersonatorId = Session::get('impersonator_id');
|
||||
$impersonator = null;
|
||||
|
||||
if ($impersonatorId && Auth::check()) {
|
||||
// Bypass CompanyableScope: the impersonator is stored by their own action in start(),
|
||||
// so looking them up must not be filtered by the impersonated user's company view.
|
||||
$impersonator = User::withTrashed()
|
||||
->withoutGlobalScope(CompanyableScope::class)
|
||||
->find($impersonatorId);
|
||||
}
|
||||
|
||||
$view->with('impersonator', $impersonator);
|
||||
}
|
||||
}
|
||||
@ -357,6 +357,41 @@ return [
|
||||
|
||||
'lock_passwords' => env('APP_LOCKED', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Superuser Impersonation
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Comma-separated list of usernames allowed to impersonate other users.
|
||||
| Users in this list must ALSO be superusers. Empty or unset means the
|
||||
| feature is completely off. Wrap usernames that contain commas in double
|
||||
| quotes. Example: ALLOW_USER_IMPERSONATION=admin,"jane, doe"
|
||||
|
|
||||
*/
|
||||
|
||||
'user_impersonation_usernames' => (function () {
|
||||
$raw = env('ALLOW_USER_IMPERSONATION', '');
|
||||
|
||||
// Reject anything that isn't a string. env() converts literal "true"/"false"/"null"/etc.
|
||||
// to PHP bool/null, which would otherwise stringify to garbage tokens.
|
||||
if (! is_string($raw) || trim($raw) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$usernames = [];
|
||||
$seen = [];
|
||||
foreach (str_getcsv($raw, ',', '"', '\\') as $token) {
|
||||
$token = trim((string) $token);
|
||||
$lower = mb_strtolower($token);
|
||||
if ($token !== '' && ! isset($seen[$lower])) {
|
||||
$usernames[] = $token;
|
||||
$seen[$lower] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $usernames;
|
||||
})(),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Minimum PHP version
|
||||
|
||||
@ -444,6 +444,12 @@ return [
|
||||
'mail_from' => 'Mail From Address',
|
||||
'database_driver' => 'Database Driver',
|
||||
'bs_table_storage' => 'Table Storage',
|
||||
'user_impersonation' => 'User Impersonation',
|
||||
'user_impersonation_disabled' => 'Disabled. Set ALLOW_USER_IMPERSONATION in your .env to a comma-separated list of usernames.',
|
||||
'user_impersonation_not_superuser' => 'Not a superuser',
|
||||
'user_impersonation_not_superuser_help' => 'This user is in the impersonation allowlist but is not a superuser, so they cannot actually impersonate anyone. Remove them from the .env or grant superuser access.',
|
||||
'user_impersonation_missing' => 'User not found',
|
||||
'user_impersonation_deactivated' => 'Deactivated',
|
||||
'timezone' => 'Timezone',
|
||||
'test_mail' => 'Test Mail',
|
||||
'profile_edit' => 'Edit Profile',
|
||||
|
||||
@ -65,4 +65,8 @@ return [
|
||||
'no_companies_assigned' => '(No companies assigned)',
|
||||
'cannot_edit_privileged_user_companies' => 'Only an admin or super admin can modify the company assignments of an admin or super admin user.',
|
||||
'cannot_manage_companies_without_membership' => 'Full multiple company support with floater mode is enabled, so you must be assigned to at least one company before you can manage another user\'s company assignments.',
|
||||
'impersonate_user' => 'Login as :name',
|
||||
'impersonating_banner_title' => 'Impersonating:',
|
||||
'impersonating_banner_text' => 'You are currently logged in as :name. Anything you do will be recorded as if :name did it. Your real account is :impersonator.',
|
||||
'impersonating_stop_link' => 'Switch back to :name',
|
||||
];
|
||||
|
||||
@ -20,6 +20,14 @@ return [
|
||||
'user_has_no_email' => 'This user does not have an email address in their profile.',
|
||||
'log_record_not_found' => 'A matching log record for this user could not be found.',
|
||||
|
||||
'impersonate' => [
|
||||
'started' => 'You are now logged in as :name.',
|
||||
'stopped' => 'You are back to your own account.',
|
||||
'cannot_impersonate_self' => 'You cannot log in as yourself.',
|
||||
'target_not_active' => 'That user is deactivated or deleted and cannot be logged in as.',
|
||||
'impersonator_missing' => 'The original account for this impersonation session no longer exists. Please log in again.',
|
||||
],
|
||||
|
||||
'success' => [
|
||||
'create' => 'User was successfully created.',
|
||||
'update' => 'User was successfully updated.',
|
||||
|
||||
@ -281,6 +281,9 @@ return [
|
||||
'reports' => 'Reports',
|
||||
'restored' => 'restored',
|
||||
'restore' => 'Restore',
|
||||
'impersonated' => 'Logged in as',
|
||||
'impersonate' => 'Login as This User',
|
||||
'stopped_impersonating' => 'Stopped impersonating',
|
||||
'requestable_models' => 'Requestable Models',
|
||||
'requestable_items' => 'Requestable Items',
|
||||
'requestable' => 'Requestable',
|
||||
|
||||
@ -1939,6 +1939,8 @@
|
||||
|
||||
<div class="content-wrapper" role="main" id="setting-list">
|
||||
|
||||
@include('partials.impersonation-banner')
|
||||
|
||||
@if ($debug_in_production)
|
||||
<div class="row" style="margin-bottom: 0px; background-color: red; color: white; font-size: 15px;">
|
||||
<div class="col-md-12"
|
||||
|
||||
16
resources/views/partials/impersonation-banner.blade.php
Normal file
16
resources/views/partials/impersonation-banner.blade.php
Normal file
@ -0,0 +1,16 @@
|
||||
@if (!empty($impersonator) && Auth::check())
|
||||
<div class="row" role="alert" aria-live="polite" style="margin-bottom: 0px;">
|
||||
<div class="col-md-12" style="background-color: #b94a48; color: #ffffff; padding: 14px 30px 14px 30px; font-size: 17px;">
|
||||
<x-icon type="impersonate" class="pull-left" style="margin-right: 15px; margin-top: 2px;"/>
|
||||
<strong>{{ trans('admin/users/general.impersonating_banner_title') }}</strong>
|
||||
{{ trans('admin/users/general.impersonating_banner_text', ['name' => Auth::user()->display_name, 'impersonator' => $impersonator->display_name]) }}
|
||||
<form action="{{ route('users.impersonate.stop') }}" method="POST" class="form-inline pull-right" style="display: inline;">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-default" style="background-color: #ffffff; color: #b94a48; border-color: #ffffff; font-weight: bold;">
|
||||
<x-icon type="undo" class="fa-fw"/>
|
||||
{{ trans('admin/users/general.impersonating_stop_link', ['name' => $impersonator->display_name]) }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@ -455,6 +455,43 @@
|
||||
<div class="col-md-4">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- row -->
|
||||
<div class="row">
|
||||
<div class="col-md-2">
|
||||
<strong>{{ trans('admin/settings/general.user_impersonation') }}:</strong>
|
||||
</div>
|
||||
<div class="col-md-10">
|
||||
@if ($impersonators->isEmpty() && empty($missingImpersonationUsernames))
|
||||
<em class="text-muted">{{ trans('admin/settings/general.user_impersonation_disabled') }}</em>
|
||||
@else
|
||||
<ul>
|
||||
@foreach ($impersonators as $impersonator)
|
||||
<li>
|
||||
<a href="{{ route('users.show', $impersonator->id) }}">{{ $impersonator->display_name }}</a>
|
||||
<code>{{ $impersonator->username }}</code>
|
||||
@if (! $impersonator->isSuperUser())
|
||||
<span class="label label-warning" data-tooltip="true" title="{{ trans('admin/settings/general.user_impersonation_not_superuser_help') }}">
|
||||
{{ trans('admin/settings/general.user_impersonation_not_superuser') }}
|
||||
</span>
|
||||
@endif
|
||||
@if ($impersonator->deleted_at !== null)
|
||||
<span class="label label-danger">{{ trans('general.deleted') }}</span>
|
||||
@elseif ($impersonator->activated != 1)
|
||||
<span class="label label-default">{{ trans('admin/settings/general.user_impersonation_deactivated') }}</span>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
@foreach ($missingImpersonationUsernames as $missingUsername)
|
||||
<li>
|
||||
<code>{{ $missingUsername }}</code>
|
||||
<span class="label label-danger">{{ trans('admin/settings/general.user_impersonation_missing') }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -292,11 +292,25 @@
|
||||
</x-well>
|
||||
|
||||
|
||||
<!-- Impersonation button -->
|
||||
@if (Auth::check() && Auth::user()->canImpersonate() && ($user->id !== Auth::id()) && ($user->deleted_at === null) && ($user->activated == 1))
|
||||
<form action="{{ route('users.impersonate.start', $user->id) }}" method="POST" class="form-inline" style="display: inline;">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-danger hidden-print btn-social btn-block" data-tooltip="true" data-title="{{ trans('admin/users/general.impersonate_user', ['name' => $user->display_name]) }}">
|
||||
<x-icon type="impersonate" class="fa-fw" style="font-size: 17px;"/>
|
||||
{{ trans('general.impersonate') }}
|
||||
<span class="sr-only">{{ trans('admin/users/general.impersonate_user', ['name' => $user->display_name]) }}</span>
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
|
||||
@if ( ($user->activated == '1') && (auth()->user()->isSuperUser()) && ($user->two_factor_active_and_enrolled()) && ($snipeSettings->two_factor_enabled!='0') && ($snipeSettings->two_factor_enabled!=''))
|
||||
|
||||
<!-- 2FA reset -->
|
||||
|
||||
<a class="btn btn-theme btn-sm" id="two_factor_reset" style="margin-right: 10px; margin-top: 10px;">
|
||||
<a class="btn btn-theme hidden-print btn-social btn-block" id="two_factor_reset" style="margin-right: 10px; margin-top: 10px;">
|
||||
<x-icon type="mobile" class="fa-fw"/>
|
||||
{{ trans('admin/settings/general.two_factor_reset') }}
|
||||
</a>
|
||||
<span id="two_factor_reseticon">
|
||||
@ -312,11 +326,6 @@
|
||||
|
||||
@endif
|
||||
|
||||
@if ($snipeSettings->isQrEnabled())
|
||||
<div class="col-md-12 text-center user-qr-img" style="padding-top: 15px;">
|
||||
<img src="{{ route('qr_code/common', ['object_type' => 'users', 'id' => $user->id]) }}" class="img-thumbnail" style="height: 150px; width: 150px; margin-right: 10px;" alt="QR code for {{ $user->display_name }}">
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</x-page-column>
|
||||
<!-- end side stats well column-->
|
||||
@ -543,7 +552,7 @@
|
||||
<x-table.files object_type="users" :object="$user"/>
|
||||
</x-tabs.pane>
|
||||
|
||||
<x-tabs.pane name="eulas" :count="$user->accessories()->count()">
|
||||
<x-tabs.pane name="eulas" :count="$user->eulas()->count()">
|
||||
<x-slot:table_header>
|
||||
{{ trans('general.eula') }}
|
||||
</x-slot:table_header>
|
||||
@ -611,7 +620,8 @@
|
||||
<x-button.clone :item="$user" :route="route('users.clone.show', $user)"/>
|
||||
<x-button.restore :item="$user" :route="route('users.restore.store', $user)"/>
|
||||
|
||||
@if($user->allAssignedCount() != '0')
|
||||
|
||||
@if($user->allAssignedCount() != '0')
|
||||
<a href="{{ route('users.print', $user->id) }}" class="btn btn-sm btn-theme hidden-print" target="_blank" rel="noopener" data-tooltip="true" data-title="{{ trans('admin/users/general.print_assigned') }}">
|
||||
<x-icon type="print" class="fa-fw"/>
|
||||
</a>
|
||||
|
||||
@ -90,6 +90,22 @@ Route::group(['prefix' => 'users', 'middleware' => ['auth']], function () {
|
||||
]
|
||||
)->name('users.acceptance_reminder')->withTrashed();
|
||||
|
||||
Route::post(
|
||||
'{user}/impersonate',
|
||||
[
|
||||
Users\ImpersonateController::class,
|
||||
'start',
|
||||
]
|
||||
)->name('users.impersonate.start');
|
||||
|
||||
Route::post(
|
||||
'impersonate/stop',
|
||||
[
|
||||
Users\ImpersonateController::class,
|
||||
'stop',
|
||||
]
|
||||
)->name('users.impersonate.stop');
|
||||
|
||||
Route::post(
|
||||
'bulkedit',
|
||||
[
|
||||
|
||||
79
tests/Feature/Settings/ImpersonationSettingsRowTest.php
Normal file
79
tests/Feature/Settings/ImpersonationSettingsRowTest.php
Normal file
@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Settings;
|
||||
|
||||
use App\Models\User;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ImpersonationSettingsRowTest extends TestCase
|
||||
{
|
||||
public function test_row_shows_disabled_state_when_no_usernames_configured()
|
||||
{
|
||||
config(['app.user_impersonation_usernames' => []]);
|
||||
|
||||
$admin = User::factory()->superuser()->create();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('settings.index'))
|
||||
->assertOk()
|
||||
->assertSee(trans('admin/settings/general.user_impersonation'))
|
||||
->assertSee(trans('admin/settings/general.user_impersonation_disabled'));
|
||||
}
|
||||
|
||||
public function test_row_lists_a_valid_superuser_without_warnings()
|
||||
{
|
||||
$allowed = User::factory()->superuser()->create(['username' => 'allowed_super']);
|
||||
config(['app.user_impersonation_usernames' => [$allowed->username]]);
|
||||
|
||||
$admin = User::factory()->superuser()->create();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('settings.index'))
|
||||
->assertOk()
|
||||
->assertSee($allowed->display_name)
|
||||
->assertSee($allowed->username)
|
||||
->assertDontSee(trans('admin/settings/general.user_impersonation_not_superuser'))
|
||||
->assertDontSee(trans('admin/settings/general.user_impersonation_missing'));
|
||||
}
|
||||
|
||||
public function test_row_flags_non_superuser_in_allowlist()
|
||||
{
|
||||
$notSuper = User::factory()->admin()->create(['username' => 'not_super']);
|
||||
config(['app.user_impersonation_usernames' => [$notSuper->username]]);
|
||||
|
||||
$admin = User::factory()->superuser()->create();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('settings.index'))
|
||||
->assertOk()
|
||||
->assertSee($notSuper->display_name)
|
||||
->assertSee(trans('admin/settings/general.user_impersonation_not_superuser'));
|
||||
}
|
||||
|
||||
public function test_row_flags_missing_username()
|
||||
{
|
||||
config(['app.user_impersonation_usernames' => ['ghost_admin']]);
|
||||
|
||||
$admin = User::factory()->superuser()->create();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('settings.index'))
|
||||
->assertOk()
|
||||
->assertSee('ghost_admin')
|
||||
->assertSee(trans('admin/settings/general.user_impersonation_missing'));
|
||||
}
|
||||
|
||||
public function test_row_case_insensitively_matches_usernames()
|
||||
{
|
||||
$allowed = User::factory()->superuser()->create(['username' => 'SnipeAdmin']);
|
||||
config(['app.user_impersonation_usernames' => ['snipeadmin']]);
|
||||
|
||||
$admin = User::factory()->superuser()->create();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get(route('settings.index'))
|
||||
->assertOk()
|
||||
->assertSee($allowed->display_name)
|
||||
->assertDontSee(trans('admin/settings/general.user_impersonation_missing'));
|
||||
}
|
||||
}
|
||||
241
tests/Feature/Users/ImpersonateUserTest.php
Normal file
241
tests/Feature/Users/ImpersonateUserTest.php
Normal file
@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Users;
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\User;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ImpersonateUserTest extends TestCase
|
||||
{
|
||||
protected function allow(User ...$users): void
|
||||
{
|
||||
config(['app.user_impersonation_usernames' => array_map(fn ($u) => $u->username, $users)]);
|
||||
}
|
||||
|
||||
public function test_impersonate_endpoint_is_disabled_when_list_is_empty()
|
||||
{
|
||||
config(['app.user_impersonation_usernames' => []]);
|
||||
|
||||
$actor = User::factory()->superuser()->create();
|
||||
$target = User::factory()->create(['activated' => 1]);
|
||||
|
||||
$this->actingAs($actor)
|
||||
->post(route('users.impersonate.start', $target))
|
||||
->assertNotFound();
|
||||
|
||||
$this->assertNull(session('impersonator_id'));
|
||||
}
|
||||
|
||||
public function test_non_superuser_cannot_impersonate_even_if_id_is_in_list()
|
||||
{
|
||||
$actor = User::factory()->admin()->create();
|
||||
$target = User::factory()->create(['activated' => 1]);
|
||||
$this->allow($actor);
|
||||
|
||||
$this->actingAs($actor)
|
||||
->post(route('users.impersonate.start', $target))
|
||||
->assertForbidden();
|
||||
|
||||
$this->assertNull(session('impersonator_id'));
|
||||
}
|
||||
|
||||
public function test_superuser_not_in_allowlist_cannot_impersonate()
|
||||
{
|
||||
$actor = User::factory()->superuser()->create();
|
||||
$someoneElse = User::factory()->superuser()->create();
|
||||
$target = User::factory()->create(['activated' => 1]);
|
||||
$this->allow($someoneElse);
|
||||
|
||||
$this->actingAs($actor)
|
||||
->post(route('users.impersonate.start', $target))
|
||||
->assertForbidden();
|
||||
|
||||
$this->assertNull(session('impersonator_id'));
|
||||
}
|
||||
|
||||
public function test_allowlisted_superuser_can_impersonate_activated_user()
|
||||
{
|
||||
$actor = User::factory()->superuser()->create();
|
||||
$target = User::factory()->create(['activated' => 1]);
|
||||
$this->allow($actor);
|
||||
|
||||
$response = $this->actingAs($actor)
|
||||
->post(route('users.impersonate.start', $target));
|
||||
|
||||
$response->assertRedirect(route('home'));
|
||||
$this->assertSame($target->id, auth()->id());
|
||||
$this->assertSame($actor->id, session('impersonator_id'));
|
||||
|
||||
$this->assertDatabaseHas('action_logs', [
|
||||
'item_type' => User::class,
|
||||
'item_id' => $target->id,
|
||||
'created_by' => $actor->id,
|
||||
'action_type' => 'impersonated',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_allowlisted_superuser_cannot_impersonate_deactivated_user()
|
||||
{
|
||||
$actor = User::factory()->superuser()->create();
|
||||
$target = User::factory()->create(['activated' => 0]);
|
||||
$this->allow($actor);
|
||||
|
||||
$this->actingAs($actor)
|
||||
->post(route('users.impersonate.start', $target))
|
||||
->assertRedirect(route('users.show', $target));
|
||||
|
||||
$this->assertSame($actor->id, auth()->id());
|
||||
$this->assertNull(session('impersonator_id'));
|
||||
}
|
||||
|
||||
public function test_allowlisted_superuser_cannot_impersonate_themselves()
|
||||
{
|
||||
$actor = User::factory()->superuser()->create();
|
||||
$this->allow($actor);
|
||||
|
||||
$this->actingAs($actor)
|
||||
->post(route('users.impersonate.start', $actor))
|
||||
->assertRedirect(route('users.show', $actor));
|
||||
|
||||
$this->assertNull(session('impersonator_id'));
|
||||
}
|
||||
|
||||
public function test_stop_impersonation_restores_original_user()
|
||||
{
|
||||
$actor = User::factory()->superuser()->create();
|
||||
$target = User::factory()->create(['activated' => 1]);
|
||||
$this->allow($actor);
|
||||
|
||||
$this->actingAs($actor)
|
||||
->post(route('users.impersonate.start', $target))
|
||||
->assertRedirect(route('home'));
|
||||
|
||||
$this->assertSame($target->id, auth()->id());
|
||||
|
||||
$stop = $this->post(route('users.impersonate.stop'));
|
||||
|
||||
$stop->assertRedirect(route('users.show', $target));
|
||||
$this->assertSame($actor->id, auth()->id());
|
||||
$this->assertNull(session('impersonator_id'));
|
||||
|
||||
$this->assertDatabaseHas('action_logs', [
|
||||
'item_type' => User::class,
|
||||
'item_id' => $target->id,
|
||||
'created_by' => $actor->id,
|
||||
'action_type' => 'stopped impersonating',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_banner_is_visible_after_impersonating_a_non_admin()
|
||||
{
|
||||
$actor = User::factory()->superuser()->create(['first_name' => 'Sooper', 'last_name' => 'Actor']);
|
||||
$target = User::factory()->create(['activated' => 1, 'first_name' => 'Target', 'last_name' => 'User']);
|
||||
$this->allow($actor);
|
||||
|
||||
$this->actingAs($actor)
|
||||
->post(route('users.impersonate.start', $target))
|
||||
->assertRedirect(route('home'));
|
||||
|
||||
$this->assertSame($actor->id, session('impersonator_id'));
|
||||
|
||||
$follow = $this->followingRedirects()->get(route('home'));
|
||||
|
||||
$follow->assertOk()
|
||||
->assertSee(trans('admin/users/general.impersonating_banner_title'))
|
||||
->assertSee(route('users.impersonate.stop'), false);
|
||||
}
|
||||
|
||||
public function test_banner_and_stop_work_across_company_scoping()
|
||||
{
|
||||
$this->settings->enableMultipleFullCompanySupport();
|
||||
|
||||
[$companyA, $companyB] = Company::factory()->count(2)->create();
|
||||
|
||||
$actor = User::factory()->superuser()->create();
|
||||
$actor->companies()->sync([$companyA->id]);
|
||||
|
||||
$target = User::factory()->create(['activated' => 1, 'company_id' => $companyB->id]);
|
||||
$target->companies()->sync([$companyB->id]);
|
||||
|
||||
$this->allow($actor);
|
||||
|
||||
$this->actingAs($actor)
|
||||
->post(route('users.impersonate.start', $target))
|
||||
->assertRedirect(route('home'));
|
||||
|
||||
$this->assertSame($target->id, auth()->id());
|
||||
$this->assertSame($actor->id, session('impersonator_id'));
|
||||
|
||||
$follow = $this->followingRedirects()->get(route('home'));
|
||||
$follow->assertOk()
|
||||
->assertSee(trans('admin/users/general.impersonating_banner_title'))
|
||||
->assertSee(route('users.impersonate.stop'), false);
|
||||
|
||||
$this->post(route('users.impersonate.stop'))
|
||||
->assertRedirect(route('users.show', $target));
|
||||
|
||||
$this->assertSame($actor->id, auth()->id());
|
||||
}
|
||||
|
||||
public function test_stop_impersonation_no_op_when_not_impersonating()
|
||||
{
|
||||
$actor = User::factory()->create();
|
||||
|
||||
$this->actingAs($actor)
|
||||
->post(route('users.impersonate.stop'))
|
||||
->assertRedirect(route('home'));
|
||||
|
||||
$this->assertSame($actor->id, auth()->id());
|
||||
}
|
||||
|
||||
public function test_button_hidden_when_list_is_empty()
|
||||
{
|
||||
config(['app.user_impersonation_usernames' => []]);
|
||||
|
||||
$actor = User::factory()->superuser()->create();
|
||||
$target = User::factory()->create(['activated' => 1]);
|
||||
|
||||
$this->actingAs($actor)
|
||||
->get(route('users.show', $target))
|
||||
->assertOk()
|
||||
->assertDontSee(route('users.impersonate.start', $target));
|
||||
}
|
||||
|
||||
public function test_button_visible_to_allowlisted_superuser()
|
||||
{
|
||||
$actor = User::factory()->superuser()->create();
|
||||
$target = User::factory()->create(['activated' => 1]);
|
||||
$this->allow($actor);
|
||||
|
||||
$this->actingAs($actor)
|
||||
->get(route('users.show', $target))
|
||||
->assertOk()
|
||||
->assertSee(route('users.impersonate.start', $target), false);
|
||||
}
|
||||
|
||||
public function test_button_hidden_from_non_allowlisted_superuser()
|
||||
{
|
||||
$actor = User::factory()->superuser()->create();
|
||||
$someoneElse = User::factory()->superuser()->create();
|
||||
$target = User::factory()->create(['activated' => 1]);
|
||||
$this->allow($someoneElse);
|
||||
|
||||
$this->actingAs($actor)
|
||||
->get(route('users.show', $target))
|
||||
->assertOk()
|
||||
->assertDontSee(route('users.impersonate.start', $target));
|
||||
}
|
||||
|
||||
public function test_button_hidden_from_non_superuser_in_allowlist()
|
||||
{
|
||||
$actor = User::factory()->admin()->create();
|
||||
$target = User::factory()->create(['activated' => 1]);
|
||||
$this->allow($actor);
|
||||
|
||||
$this->actingAs($actor)
|
||||
->get(route('users.show', $target))
|
||||
->assertOk()
|
||||
->assertDontSee(route('users.impersonate.start', $target));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user