1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-06-20 22:54:05 +00:00

Merge branch 'master' into fix-cache-invalidation

This commit is contained in:
Samuel Štancl 2026-06-06 15:03:57 -07:00 committed by GitHub
commit c6ba6a574c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 413 additions and 88 deletions

View file

@ -16,7 +16,7 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver;
/**
* Makes the app use TenancyUrlGenerator (instead of Illuminate\Routing\UrlGenerator) which:
* - prefixes route names with the tenant route name prefix (PathTenantResolver::tenantRouteNamePrefix() by default)
* - passes the tenant parameter to the link generated by route() and temporarySignedRoute() (PathTenantResolver::tenantParameterName() by default).
* - passes the tenant parameter (PathTenantResolver::tenantParameterName() by default) to the link generated by the affected methods like route() and temporarySignedRoute().
*
* Used with path and query string identification.
*

View file

@ -18,7 +18,7 @@ trait HasTenantOptions
{
return array_merge([
new InputOption('tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, 'The tenants to run this command for. Leave empty for all tenants', null),
new InputOption('with-pending', null, InputOption::VALUE_NONE, 'Include pending tenants in query'), // todo@pending should we also offer without-pending? if we add this, mention in docs
new InputOption('with-pending', null, InputOption::VALUE_OPTIONAL, 'Include pending tenants in query if true/1, exclude if false/0. Defaults to the tenancy.pending.include_in_queries config value.'),
], parent::getOptions());
}
@ -43,7 +43,11 @@ trait HasTenantOptions
$query->whereIn(tenancy()->model()->getTenantKeyName(), $this->option('tenants'));
})
->when(tenancy()->model()::hasGlobalScope(PendingScope::class), function ($query) {
$query->withPending(config('tenancy.pending.include_in_queries') ?: $this->option('with-pending'));
$includePending = $this->input->hasParameterOption('--with-pending')
? filter_var($this->option('with-pending') ?? true, FILTER_VALIDATE_BOOLEAN)
: config('tenancy.pending.include_in_queries');
$query->withPending($includePending);
});
}

View file

@ -28,6 +28,18 @@ trait HasPending
public static function bootHasPending(): void
{
static::addGlobalScope(new PendingScope());
static::creating(function (self $tenant): void {
if ($tenant->pending()) {
event(new CreatingPendingTenant($tenant));
}
});
static::created(function (self $tenant): void {
if ($tenant->pending()) {
event(new PendingTenantCreated($tenant));
}
});
}
/** Initialize the trait. */
@ -49,22 +61,11 @@ trait HasPending
*/
public static function createPending(array $attributes = []): Model&Tenant
{
$tenant = null;
try {
$tenant = static::create(array_merge(static::getPendingAttributes($attributes), $attributes));
event(new CreatingPendingTenant($tenant));
} finally {
// Update the pending_since value only after the tenant is created so it's
// not marked as pending until after migrations, seeders, etc are run.
$tenant?->update([
'pending_since' => now()->timestamp,
]);
}
event(new PendingTenantCreated($tenant));
return $tenant;
return static::create(array_merge(
static::getPendingAttributes($attributes),
$attributes,
['pending_since' => now()->timestamp],
));
}
/**

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Features;
use Exception;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
@ -61,9 +62,9 @@ class UserImpersonation implements Feature
Auth::guard($token->auth_guard)->loginUsingId($token->user_id, $token->remember);
$token->delete();
session()->put('tenancy_impersonation_guard', $token->auth_guard);
session()->put('tenancy_impersonating', true);
$token->delete();
return redirect($token->redirect_url);
}
@ -76,16 +77,30 @@ class UserImpersonation implements Feature
public static function isImpersonating(): bool
{
return session()->has('tenancy_impersonating');
return session()->has('tenancy_impersonation_guard');
}
/**
* Logout from the current domain and forget impersonation session.
* Stop user impersonation by forgetting the impersonation session.
*
* When $logout is true, the user will also be logged out
* from the impersonation guard stored in the session.
*
* Throws an exception if impersonation is not active
* (= the impersonation guard is not in the session).
*/
public static function stopImpersonating(): void
public static function stopImpersonating(bool $logout = true): void
{
auth()->logout();
if (! static::isImpersonating()) {
throw new Exception('Not currently impersonating any user.');
}
session()->forget('tenancy_impersonating');
if ($logout) {
$guard = session()->get('tenancy_impersonation_guard');
auth($guard)->logout();
}
session()->forget('tenancy_impersonation_guard');
}
}

View file

@ -17,6 +17,16 @@ class MigrateDatabase implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Should pending tenants be included while migrating,
* regardless of the tenancy.pending.include_in_queries config value.
*
* If false, pending tenants will be specifically excluded.
*
* If null, default to tenancy.pending.include_in_queries config.
*/
public static ?bool $includePending = true;
public function __construct(
protected TenantWithDatabase&Model $tenant,
) {}
@ -25,6 +35,7 @@ class MigrateDatabase implements ShouldQueue
{
Artisan::call('tenants:migrate', [
'--tenants' => [$this->tenant->getTenantKey()],
'--with-pending' => static::$includePending ?? config('tenancy.pending.include_in_queries'),
]);
}
}

View file

@ -17,6 +17,16 @@ class SeedDatabase implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Should pending tenants be included while seeding,
* regardless of the tenancy.pending.include_in_queries config value.
*
* If false, pending tenants will be specifically excluded.
*
* If null, default to tenancy.pending.include_in_queries config.
*/
public static ?bool $includePending = true;
public function __construct(
protected TenantWithDatabase&Model $tenant,
) {}
@ -25,6 +35,7 @@ class SeedDatabase implements ShouldQueue
{
Artisan::call('tenants:seed', [
'--tenants' => [$this->tenant->getTenantKey()],
'--with-pending' => static::$includePending ?? config('tenancy.pending.include_in_queries'),
]);
}
}

View file

@ -22,16 +22,18 @@ use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
* - Automatically passing ['tenant' => ...] to each route() call -- if TenancyUrlGenerator::$passTenantParameterToRoutes is enabled
* This is a more universal solution since it supports both path identification and query parameter identification.
*
* - Prepends route names passed to route() and URL::temporarySignedRoute()
* with `tenant.` (or the configured prefix) if $prefixRouteNames is enabled.
* - Prepends route names with the tenant route name prefix ('tenant.' by default,
* configurable at tenant_route_name_prefix under PathTenantResolver) if $prefixRouteNames is enabled.
* This is primarily useful when using route cloning with path identification.
*
* To bypass this behavior on any single route() call, pass the $bypassParameter as true (['central' => true] by default).
* Affected methods: route(), toRoute(), temporarySignedRoute(), signedRoute() (the last two via the route() override).
*
* To bypass this behavior on any single affected method call, pass the $bypassParameter as true (['central' => true] by default).
*/
class TenancyUrlGenerator extends UrlGenerator
{
/**
* Parameter which works as a flag for bypassing the behavior modification of route() and temporarySignedRoute().
* Parameter which works as a flag for bypassing the behavior modification of the affected methods.
*
* For example, in tenant context:
* Route::get('/', ...)->name('home');
@ -44,12 +46,12 @@ class TenancyUrlGenerator extends UrlGenerator
* Note: UrlGeneratorBootstrapper::$addTenantParameterToDefaults is not affected by this, though
* it doesn't matter since it doesn't pass any extra parameters when not needed.
*
* @see UrlGeneratorBootstrapper
* @see Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper
*/
public static string $bypassParameter = 'central';
/**
* Should route names passed to route() or temporarySignedRoute()
* Should route names passed to the affected methods
* get prefixed with the tenant route name prefix.
*
* This is useful when using e.g. path identification with third-party packages
@ -59,12 +61,12 @@ class TenancyUrlGenerator extends UrlGenerator
public static bool $prefixRouteNames = false;
/**
* Should the tenant parameter be passed to route() or temporarySignedRoute() calls.
* Should the tenant parameter be passed to the affected methods.
*
* This is useful with path or query parameter identification. The former can be handled
* more elegantly using UrlGeneratorBootstrapper::$addTenantParameterToDefaults.
*
* @see UrlGeneratorBootstrapper
* @see Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper
*/
public static bool $passTenantParameterToRoutes = false;
@ -105,8 +107,18 @@ class TenancyUrlGenerator extends UrlGenerator
public static bool $passQueryParameter = true;
/**
* Override the route() method so that the route name gets prefixed
* and the tenant parameter gets added when in tenant context.
* Override the route() method to prefix the route name before $this->routes->getByName($name) is called
* in the parent route() call.
*
* This is necessary because $this->routes->getByName($name) is called to retrieve the route
* before passing it to toRoute(). If only the prefixed route (e.g. 'tenant.foo') is registered
* and the original ('foo') isn't, route() would throw a RouteNotFoundException.
* So route() has to be overridden to prefix the passed route name, even though toRoute() is overridden already.
*
* Only the name is taken from prepareRouteInputs() here parameter handling
* (adding tenant parameter, removing bypass parameter) is delegated to toRoute().
*
* Affects temporarySignedRoute() and signedRoute() as well since they call route() under the hood.
*/
public function route($name, $parameters = [], $absolute = true)
{
@ -114,32 +126,28 @@ class TenancyUrlGenerator extends UrlGenerator
throw new InvalidArgumentException('Attribute [name] expects a string backed enum.');
}
[$name, $parameters] = $this->prepareRouteInputs($name, Arr::wrap($parameters)); // @phpstan-ignore argument.type
[$name] = $this->prepareRouteInputs(Arr::wrap($parameters), $name); // @phpstan-ignore argument.type
return parent::route($name, $parameters, $absolute);
}
/**
* Override the temporarySignedRoute() method so that the route name gets prefixed
* and the tenant parameter gets added when in tenant context.
* Override the toRoute() to prefix the route name
* and add the tenant parameter when in tenant context.
*
* Also affects route(). Even though route() is overridden separately, it delegates parameter handling to toRoute().
*/
public function temporarySignedRoute($name, $expiration, $parameters = [], $absolute = true)
public function toRoute($route, $parameters, $absolute)
{
if ($name instanceof BackedEnum && ! is_string($name = $name->value)) {
throw new InvalidArgumentException('Attribute [name] expects a string backed enum.');
$name = $route->getName();
[$prefixedName, $parameters] = $this->prepareRouteInputs(Arr::wrap($parameters), $name);
if ($name && $prefixedName !== $name && $tenantRoute = $this->routes->getByName($prefixedName)) {
$route = $tenantRoute;
}
$wrappedParameters = Arr::wrap($parameters);
[$name, $parameters] = $this->prepareRouteInputs($name, $wrappedParameters); // @phpstan-ignore argument.type
if (isset($wrappedParameters[static::$bypassParameter])) {
// If the bypass parameter was passed, we need to add it back to the parameters after prepareRouteInputs() removes it,
// so that the underlying route() call in parent::temporarySignedRoute() can bypass the behavior modification as well.
$parameters[static::$bypassParameter] = $wrappedParameters[static::$bypassParameter];
}
return parent::temporarySignedRoute($name, $expiration, $parameters, $absolute);
return parent::toRoute($route, $parameters, $absolute);
}
/**
@ -155,16 +163,19 @@ class TenancyUrlGenerator extends UrlGenerator
}
/**
* Takes a route name and an array of parameters to return the prefixed route name
* Takes an array of parameters and a route name to return the prefixed route name
* and the route parameters with the tenant parameter added.
*
* To skip these modifications, pass the bypass parameter in route parameters.
* Before returning the modified route inputs, the bypass parameter is removed from the parameters.
*/
protected function prepareRouteInputs(string $name, array $parameters): array
protected function prepareRouteInputs(array $parameters, string|null $name): array
{
if (! $this->routeBehaviorModificationBypassed($parameters)) {
if (! is_null($name)) {
$name = $this->routeNameOverride($name) ?? $this->prefixRouteName($name);
}
$parameters = $this->addTenantParameter($parameters);
}

View file

@ -401,3 +401,47 @@ test('the bypass parameter works correctly with temporarySignedRoute', function(
->toContain('localhost/foo')
->not()->toContain('central='); // Bypass parameter gets removed from the generated URL
});
test('toRoute can automatically prefix the passed route name', function () {
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
Route::get('/central/home', fn () => 'central')->name('home');
Route::get('/tenant/home', fn () => 'tenant')->name('tenant.home');
TenancyUrlGenerator::$prefixRouteNames = true;
$tenant = Tenant::create();
tenancy()->initialize($tenant);
$centralRoute = Route::getRoutes()->getByName('home');
// url()->toRoute() prefixes the name of the passed route ('home') with the tenant prefix
// and generates the URL for the tenant route (as if the 'tenant.home' route was passed to the method)
expect(url()->toRoute($centralRoute, [], true))->toBe('http://localhost/tenant/home');
// Passing the bypass parameter skips the name prefixing, so the method returns the central route URL
expect(url()->toRoute($centralRoute, ['central' => true], true))->toBe('http://localhost/central/home');
});
test('toRoute modifies parameters even when the route has no name', function () {
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
TenancyUrlGenerator::$passTenantParameterToRoutes = true;
$unnamedRoute = Route::get('/unnamed', fn () => 'unnamed');
$tenant = Tenant::create();
tenancy()->initialize($tenant);
// The tenant parameter is added to the URL even for unnamed routes
expect(url()->toRoute($unnamedRoute, [], true))
->toBe("http://localhost/unnamed?tenant={$tenant->getTenantKey()}");
// The bypass parameter prevents passing the tenant parameter and is stripped from the URL
expect(url()->toRoute($unnamedRoute, ['central' => true], true))
->toBe("http://localhost/unnamed")
->not()->toContain('tenant=')
->not()->toContain('central=');
});

View file

@ -16,10 +16,25 @@ use Stancl\Tenancy\Events\PendingTenantPulled;
use Stancl\Tenancy\Events\PullingPendingTenant;
use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
use Stancl\Tenancy\Events\TenantCreated;
use Stancl\JobPipeline\JobPipeline;
use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Jobs\MigrateDatabase;
use Stancl\Tenancy\Jobs\SeedDatabase;
use Stancl\Tenancy\Tests\Etc\User;
use Stancl\Tenancy\Tests\Etc\TestSeeder;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
beforeEach($cleanup = function () {
Tenant::$extraCustomColumns = [];
Tenant::$getPendingAttributesUsing = null;
MigrateDatabase::$includePending = true;
SeedDatabase::$includePending = true;
});
afterEach($cleanup);
@ -154,8 +169,8 @@ test('pending events are dispatched', function () {
Event::assertDispatched(PendingTenantPulled::class);
});
test('commands do not run for pending tenants if tenancy.pending.include_in_queries is false and the with pending option does not get passed', function() {
config(['tenancy.pending.include_in_queries' => false]);
test('commands include tenants based on the include_in_queries config when --with-pending is not passed', function (bool $includeInQueries) {
config(['tenancy.pending.include_in_queries' => $includeInQueries]);
$tenants = collect([
Tenant::create(),
@ -164,21 +179,21 @@ test('commands do not run for pending tenants if tenancy.pending.include_in_quer
Tenant::createPending(),
]);
pest()->artisan('tenants:migrate --with-pending');
$command = pest()->artisan("tenants:run 'bar testing testing@test.test password foo'");
$artisan = pest()->artisan("tenants:run 'foo foo --b=bar --c=xyz'");
$pendingTenants = $tenants->filter->pending();
$readyTenants = $tenants->reject->pending();
$pendingTenants->each(fn ($tenant) => $artisan->doesntExpectOutputToContain("Tenant: {$tenant->getTenantKey()}"));
$readyTenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"));
$artisan->assertExitCode(0);
$tenants->each(function ($tenant) use ($command, $includeInQueries) {
if ($tenant->pending() && ! $includeInQueries) {
$command->doesntExpectOutputToContain("Tenant: {$tenant->getTenantKey()}");
} else {
$command->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}");
}
});
test('commands run for pending tenants too if tenancy.pending.include_in_queries is true', function() {
config(['tenancy.pending.include_in_queries' => true]);
$command->assertSuccessful();
})->with([true, false]);
test('commands include pending tenants when truthy --with-pending is passed', function (bool $includeInQueries) {
config(['tenancy.pending.include_in_queries' => $includeInQueries]);
$tenants = collect([
Tenant::create(),
@ -187,17 +202,22 @@ test('commands run for pending tenants too if tenancy.pending.include_in_queries
Tenant::createPending(),
]);
pest()->artisan('tenants:migrate --with-pending');
foreach ([
'--with-pending',
'--with-pending=true',
'--with-pending=1'
] as $option) {
$command = pest()->artisan("tenants:run 'bar testing testing@test.test password foo' {$option}");
$artisan = pest()->artisan("tenants:run 'foo foo --b=bar --c=xyz'");
// Pending tenants are included regardless of tenancy.pending.include_in_queries
$tenants->each(fn ($tenant) => $command->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"));
$tenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"));
$command->assertSuccessful();
}
})->with([true, false]);
$artisan->assertExitCode(0);
});
test('commands run for pending tenants too if the with pending option is passed', function() {
config(['tenancy.pending.include_in_queries' => false]);
test('commands exclude pending tenants when falsy --with-pending is passed', function (bool $includeInQueries) {
config(['tenancy.pending.include_in_queries' => $includeInQueries]);
$tenants = collect([
Tenant::create(),
@ -206,15 +226,26 @@ test('commands run for pending tenants too if the with pending option is passed'
Tenant::createPending(),
]);
pest()->artisan('tenants:migrate --with-pending');
foreach ([
'--with-pending=false',
'--with-pending=0',
'--with-pending=foo' // Invalid values are treated as false
] as $option) {
$command = pest()->artisan("tenants:run 'bar testing testing@test.test password foo' {$option}");
$artisan = pest()->artisan("tenants:run 'foo foo --b=bar --c=xyz' --with-pending");
$tenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"));
$artisan->assertExitCode(0);
$tenants->each(function ($tenant) use ($command) {
if ($tenant->pending()) {
// Pending tenants are excluded regardless of tenancy.pending.include_in_queries
$command->doesntExpectOutputToContain("Tenant: {$tenant->getTenantKey()}");
} else {
$command->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}");
}
});
$command->assertSuccessful();
}
})->with([true, false]);
test('pending tenants can have default attributes for non-nullable columns', function (bool $withPendingAttributes) {
Schema::table('tenants', function (Blueprint $table) {
$table->string('slug')->unique();
@ -236,3 +267,105 @@ test('pending tenants can have default attributes for non-nullable columns', fun
else
expect($fn)->toThrow(QueryException::class);
})->with([true, false]);
test('pending tenant databases can be migrated using a job unless configured otherwise', function (bool $includeInQueries, ?bool $migrateWithPending) {
config([
'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class],
'tenancy.pending.include_in_queries' => $includeInQueries,
]);
MigrateDatabase::$includePending = $migrateWithPending;
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
Event::listen(TenantCreated::class, JobPipeline::make([
CreateDatabase::class,
MigrateDatabase::class,
])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
$pendingTenant = Tenant::createPending();
expect(Schema::hasTable('users'))->toBeFalse();
tenancy()->initialize($pendingTenant);
// MigrateDatabase includes/excludes pending tenants based on its $includePending property,
// regardless of the tenancy.pending.include_in_queries config.
expect(Schema::hasTable('users'))->toBe($migrateWithPending ?? $includeInQueries);
})->with([
'include pending in queries' => [true],
'exclude pending from queries' => [false],
])->with([
'migrate with pending' => [true],
'migrate without pending' => [false],
'default to config' => [null],
]);
test('pending tenant databases can be seeded using a job unless configured otherwise', function (bool $includeInQueries, ?bool $seedWithPending) {
config([
'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class],
'tenancy.pending.include_in_queries' => $includeInQueries,
'tenancy.seeder_parameters.--class' => TestSeeder::class,
]);
MigrateDatabase::$includePending = true;
SeedDatabase::$includePending = $seedWithPending;
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
Event::listen(TenantCreated::class, JobPipeline::make([
CreateDatabase::class,
MigrateDatabase::class,
SeedDatabase::class,
])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
$pendingTenant = Tenant::createPending();
tenancy()->initialize($pendingTenant);
// SeedDatabase includes/excludes pending tenants based on its $includePending property,
// regardless of the tenancy.pending.include_in_queries config.
expect(User::where('email', 'seeded@user')->exists())->toBe($seedWithPending ?? $includeInQueries);
})->with([
'include pending in queries' => [true],
'exclude pending from queries' => [false],
])->with([
'seed with pending' => [true],
'seed without pending' => [false],
'default to config' => [null],
]);
test('jobs that run before tenants get fully created recognize pending tenants', function () {
config([
'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class],
]);
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
Event::listen(TenantCreated::class, JobPipeline::make([
CreateDatabase::class,
PendingTenantJob::class,
])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
Tenant::createPending();
expect(app('tenant_is_pending'))->toBeTrue();
});
class PendingTenantJob
{
public function __construct(
public Tenant $tenant,
) {}
public function handle()
{
app()->instance('tenant_is_pending', $this->tenant->pending());
}
}

View file

@ -89,13 +89,14 @@ test('tenant user can be impersonated on a tenant domain', function () {
->assertSee('You are logged in as Joe');
expect(UserImpersonation::isImpersonating())->toBeTrue();
expect(session('tenancy_impersonating'))->toBeTrue();
expect(session('tenancy_impersonation_guard'))->toBe('web');
expect($token->auth_guard)->toBe('web');
// Leave impersonation
UserImpersonation::stopImpersonating();
expect(UserImpersonation::isImpersonating())->toBeFalse();
expect(session('tenancy_impersonating'))->toBeNull();
expect(session('tenancy_impersonation_guard'))->toBeNull();
// Assert can't access the tenant dashboard
pest()->get('http://foo.localhost/dashboard')
@ -135,19 +136,113 @@ test('tenant user can be impersonated on a tenant path', function () {
->assertSee('You are logged in as Joe');
expect(UserImpersonation::isImpersonating())->toBeTrue();
expect(session('tenancy_impersonating'))->toBeTrue();
expect(session('tenancy_impersonation_guard'))->toBe('web');
expect($token->auth_guard)->toBe('web');
// Leave impersonation
UserImpersonation::stopImpersonating();
expect(UserImpersonation::isImpersonating())->toBeFalse();
expect(session('tenancy_impersonating'))->toBeNull();
expect(session('tenancy_impersonation_guard'))->toBeNull();
// Assert can't access the tenant dashboard
pest()->get('/acme/dashboard')
->assertRedirect('/login');
});
test('stopImpersonating can keep the user authenticated', function () {
makeLoginRoute();
Route::middleware(InitializeTenancyByPath::class)->prefix('/{tenant}')->group(getRoutes(false));
$tenant = Tenant::create([
'id' => 'acme',
'tenancy_db_name' => 'db' . Str::random(16),
]);
migrateTenants();
$user = $tenant->run(function () {
return ImpersonationUser::create([
'name' => 'Joe',
'email' => 'joe@local',
'password' => bcrypt('secret'),
]);
});
// Impersonate the user
$token = tenancy()->impersonate($tenant, $user->id, '/acme/dashboard');
pest()->get('/acme/impersonate/' . $token->token)
->assertRedirect('/acme/dashboard');
expect(UserImpersonation::isImpersonating())->toBeTrue();
// Stop impersonating without logging out
UserImpersonation::stopImpersonating(false);
// The impersonation session key should be cleared
expect(UserImpersonation::isImpersonating())->toBeFalse();
expect(session('tenancy_impersonation_guard'))->toBeNull();
// The user should still be authenticated
pest()->get('/acme/dashboard')
->assertSuccessful()
->assertSee('You are logged in as Joe');
});
test('stopImpersonating logs out the user from the impersonation guard stored in session', function () {
Route::middleware(InitializeTenancyByPath::class)->prefix('/{tenant}')->group(getRoutes(false));
$tenant = Tenant::create([
'id' => 'acme',
'tenancy_db_name' => 'db' . Str::random(16),
]);
migrateTenants();
$user = $tenant->run(function () {
return ImpersonationUser::create([
'name' => 'Joe',
'email' => 'joe@local',
'password' => bcrypt('secret'),
]);
});
// Impersonate the user
$token = tenancy()->impersonate($tenant, $user->id, '/acme/dashboard');
pest()->get('/acme/impersonate/' . $token->token)
->assertRedirect('/acme/dashboard');
expect(session('tenancy_impersonation_guard'))->toBe('web');
// Impersonation logged in the user using the current guard ('web')
expect(auth('web')->check())->toBeTrue();
config(['auth.guards.test' => [
'driver' => 'session',
'provider' => 'users',
]]);
// Manually log the user in through the 'test' guard
auth('test')->loginUsingId($user->id);
// Should log the user out from the guard used for impersonation ('web')
UserImpersonation::stopImpersonating();
expect(auth('web')->check())->toBeFalse();
expect(auth('test')->check())->toBeTrue();
expect(UserImpersonation::isImpersonating())->toBeFalse();
// tenancy_impersonation_guard isn't in the session anymore,
// stopImpersonating should throw an exception instead of logging out
expect(fn() => UserImpersonation::stopImpersonating())->toThrow(Exception::class);
expect(auth('test')->check())->toBeTrue();
});
test('tokens have a limited ttl', function () {
Route::middleware(InitializeTenancyByDomain::class)->group(getRoutes());