1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-08-06 12:04:04 +00:00

Merge branch 'master' into broadcasting-fixes

This commit is contained in:
lukinovec 2026-06-29 13:19:26 +02:00 committed by GitHub
commit f20f8016d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 1586 additions and 210 deletions

View file

@ -1,35 +0,0 @@
<?php
use Illuminate\Support\Facades\Event;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
});
test('database tenancy bootstrapper throws an exception if DATABASE_URL is set', function (string|null $databaseUrl) {
if ($databaseUrl) {
config(['database.connections.central.url' => $databaseUrl]);
pest()->expectException(Exception::class);
}
config(['tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class]]);
$tenant1 = Tenant::create();
pest()->artisan('tenants:migrate');
tenancy()->initialize($tenant1);
expect(true)->toBe(true);
})->with(['abc.us-east-1.rds.amazonaws.com', null]);

View file

@ -0,0 +1,162 @@
<?php
use Illuminate\Support\Facades\Event;
use Stancl\JobPipeline\JobPipeline;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\QueryException;
use Stancl\Tenancy\Database\TenantDatabaseManagers\MySQLDatabaseManager;
use Stancl\Tenancy\Database\TenantDatabaseManagers\SQLiteDatabaseManager;
use Stancl\Tenancy\Database\TenantDatabaseManagers\PostgreSQLDatabaseManager;
use Stancl\Tenancy\Database\TenantDatabaseManagers\PostgreSQLSchemaManager;
use function Stancl\Tenancy\Tests\pest;
afterEach($cleanup = function () {
DatabaseTenancyBootstrapper::$harden = false;
});
beforeEach(function () use ($cleanup) {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
$cleanup();
});
test('harden prevents tenants from using the central database', function (bool $harden, string $connection, string $manager) {
config([
'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class],
"tenancy.database.managers.{$connection}" => $manager,
]);
// Point the central connection at the tested connection's config and migrate it
// (so that the central database/schema contains the tenants table).
$centralConnection = config('tenancy.database.central_connection');
$centralConfig = config("database.connections.{$connection}");
if ($connection === 'sqlite') {
$centralConfig['database'] = database_path($sqliteCentralDb = 'central.sqlite');
}
DB::purge($centralConnection);
config(["database.connections.{$centralConnection}" => $centralConfig]);
pest()->artisan('migrate:fresh', [
'--force' => true,
'--path' => __DIR__ . '/../../assets/migrations',
'--realpath' => true,
]);
DatabaseTenancyBootstrapper::$harden = $harden;
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
// Create the tenant with its own database, then repoint it at the central database/schema
// (which contains the tenants table that the hardening check looks for).
$tenant = Tenant::create(['tenancy_db_connection' => $connection]);
$central = DB::connection($centralConnection);
$centralName = match (true) {
$manager === PostgreSQLSchemaManager::class => $central->selectOne('SELECT current_schema() AS schema')->schema, // Central schema name
$connection === 'sqlite' => $sqliteCentralDb, // Central SQLite DB name
default => $central->getDatabaseName(), // Central DB name
};
$tenant->update(['tenancy_db_name' => $centralName]);
if ($harden) {
// Harden blocks initialization for tenants that use the central database
expect(fn () => tenancy()->initialize($tenant))->toThrow(RuntimeException::class);
// Connection should be reverted back to central
expect(DB::connection()->getName())->toBe($centralConnection);
} else {
expect(fn () => tenancy()->initialize($tenant))->not()->toThrow(Throwable::class);
// Connection not reverted to central
expect(DB::connection()->getName())->toBe('tenant');
}
})->with([
'hardening enabled' => true,
'hardening disabled' => false,
])->with('db_managers');
test('harden prevents tenants from using the database of another tenant', function (bool $harden, string $connection, string $manager) {
config([
'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class],
"tenancy.database.managers.{$connection}" => $manager,
]);
DatabaseTenancyBootstrapper::$harden = $harden;
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
$tenant = Tenant::create(['tenancy_db_connection' => $connection]);
$dbName = Str::random(8) . ($connection === 'sqlite' ? '.sqlite' : '');
Tenant::create(['tenancy_db_name' => $dbName, 'tenancy_db_connection' => $connection]);
$tenant->update(['tenancy_db_name' => $dbName]);
if ($harden) {
// Harden blocks initialization for tenants that use the database of another tenant
expect(fn () => tenancy()->initialize($tenant))->toThrow(RuntimeException::class);
// Connection should be reverted back to central
expect(DB::connection()->getName())->toBe('central');
} else {
expect(fn() => tenancy()->initialize($tenant))->not()->toThrow(Throwable::class);
// Connection not reverted to central
expect(DB::connection()->getName())->toBe('tenant');
}
})->with([
'hardening enabled' => true,
'hardening disabled' => false,
])->with('db_managers');
test('database tenancy bootstrapper throws an exception if DATABASE_URL is set', function (string|null $databaseUrl) {
config(['database.connections.central.url' => $databaseUrl]);
config(['tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class]]);
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
if ($databaseUrl) {
expect(fn() => Tenant::create())->toThrow(QueryException::class);
} else {
expect(function() {
$tenant1 = Tenant::create();
pest()->artisan('tenants:migrate');
tenancy()->initialize($tenant1);
})->not()->toThrow(Throwable::class);
}
})->with(['abc.us-east-1.rds.amazonaws.com', null]);
// Database managers to test with hardening.
// Permission controlled managers omitted as they inherit the non-perm controlled managers (= they share the same code paths),
// each important code path is covered by testing the non-permission controlled manager, so adding permission controlled managers
// would add unnecessary complexity to the tests.
dataset('db_managers', [
'mysql' => ['mysql', MySQLDatabaseManager::class],
'pgsql (database)' => ['pgsql', PostgreSQLDatabaseManager::class],
'pgsql (schema)' => ['pgsql', PostgreSQLSchemaManager::class],
'sqlite' => ['sqlite', SQLiteDatabaseManager::class],
]);

View file

@ -13,7 +13,7 @@ use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Jobs\CreateStorageSymlinks;
use Stancl\Tenancy\Jobs\RemoveStorageSymlinks;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\DeleteTenantStorage;
use Stancl\Tenancy\Jobs\DeleteTenantStorage;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use function Stancl\Tenancy\Tests\pest;
@ -184,21 +184,63 @@ test('create and delete storage symlinks jobs work', function() {
$this->assertDirectoryDoesNotExist(public_path("public-$tenantKey"));
});
test('tenant storage can get deleted after the tenant when DeletingTenant listens to DeleteTenantStorage', function() {
Event::listen(DeletingTenant::class, DeleteTenantStorage::class);
test('tenant storage gets deleted during tenant deletion when the DeletingTenant pipeline contains DeleteTenantStorage', function() {
Event::listen(DeletingTenant::class,
JobPipeline::make([DeleteTenantStorage::class])->send(function (DeletingTenant $event) {
return $event->tenant;
})->shouldBeQueued(false)->toListener()
);
$centralStoragePath = storage_path();
tenancy()->initialize(Tenant::create());
// FilesystemTenancyBootstrapper not enabled,
// tenant and central storage path is the same,
// the storage deletion will be skipped.
$tenantStoragePath = storage_path();
expect($tenantStoragePath)->toBe($centralStoragePath);
expect(File::isDirectory($centralStoragePath))->toBeTrue();
tenant()->delete();
expect(File::isDirectory($centralStoragePath))->toBeTrue();
config([
'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class],
'tenancy.filesystem.suffix_storage_path' => false,
]);
tenancy()->initialize(Tenant::create());
$tenantStoragePath = storage_path();
// FilesystemTenancyBootstrapper enabled,
// but tenant and central storage path is still the same
// because suffix_storage_path is false.
// The storage deletion will be skipped.
expect($tenantStoragePath)->toBe($centralStoragePath);
expect(File::isDirectory($centralStoragePath))->toBeTrue();
tenant()->delete();
expect(File::isDirectory($centralStoragePath))->toBeTrue();
config([
'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class],
'tenancy.filesystem.suffix_storage_path' => true,
]);
tenancy()->initialize(Tenant::create());
$tenantStoragePath = storage_path();
Storage::fake('test');
// FilesystemTenancyBootstrapper enabled,
// suffix_storage_path enabled, so the two paths are distinct.
// Tenant storage will be deleted.
expect($tenantStoragePath)->not()->toBe($centralStoragePath);
expect(File::isDirectory($tenantStoragePath))->toBeTrue();
Storage::put('test.txt', 'testing file');
tenant()->delete();
expect(File::isDirectory($tenantStoragePath))->toBeFalse();
expect(File::isDirectory($centralStoragePath))->toBeTrue();
});
test('the framework/cache directory is created when storage_path is scoped', function (bool $suffixStoragePath) {
@ -256,4 +298,3 @@ test('scoped disks are scoped per tenant', function () {
expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('central2');
expect(file_get_contents(storage_path() . "/tenant{$tenant->id}/app/public/scoped_disk_prefix/foo.txt"))->toBe('tenant');
});

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

@ -165,6 +165,7 @@ test('cache is invalidated when tenant is updated from within the tenant context
['redis', [CacheTenancyBootstrapper::class]],
['redis', [CacheTagsBootstrapper::class]],
['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]],
['database', [DatabaseTenancyBootstrapper::class, CacheTenancyBootstrapper::class]],
]);
test('cache is invalidated when the tenant is deleted', function (string $resolver, bool $configureTenantModelColumn) {

View file

@ -366,6 +366,36 @@ test('migrate fresh command works', function () {
expect(DB::table('users')->exists())->toBeFalse();
});
test('migrate fresh command only shows migration output when run with the verbose option', function () {
$tenant = Tenant::create();
$migratingOutput = 'Migrating tenant ' . $tenant->getTenantKey();
// CI runs pest with --verbose which makes Artisan::call() inherit the verbosity
// so we cannot easily test commands without -v. To work around that, we temporarily
// override $_ENV['SHELL_VERBOSITY'] immediately before executing the command. If this
// ever stops working, try also overriding the value in $_SERVER and putenv().
$emptySentinel = new \stdClass();
$originalVerbosity = $_ENV['SHELL_VERBOSITY'] ?? $emptySentinel;
try {
$_ENV['SHELL_VERBOSITY'] = 0;
Artisan::call('tenants:migrate-fresh');
$defaultOutput = Artisan::output();
} finally {
if ($originalVerbosity === $emptySentinel) {
unset($_ENV['SHELL_VERBOSITY']);
} else {
$_ENV['SHELL_VERBOSITY'] = $originalVerbosity;
}
}
Artisan::call('tenants:migrate-fresh -v');
$verboseOutput = Artisan::output();
// The output is silent by default and only shown with the verbose option
expect($defaultOutput)->not()->toContain($migratingOutput);
expect($verboseOutput)->toContain($migratingOutput);
});
test('migrate fresh command respects force option in production', function () {
// Set environment to production
app()->detectEnvironment(fn() => 'production');
@ -515,3 +545,51 @@ test('migrate fresh command only deletes tenant databases if drop_tenant_databas
expect($tenantHasDatabase($tenant))->toBe($shouldHaveDBAfterMigrateFresh);
}
})->with([true, false]);
test('migrate commands can skip specified tenants', function (string $command) {
$tenant1 = Tenant::create();
$tenant2 = Tenant::create();
$tenant3 = Tenant::create();
pest()->artisan("{$command} --skip-tenants={$tenant1->getTenantKey()} --skip-tenants={$tenant2->getTenantKey()}");
tenancy()->initialize($tenant1);
expect(Schema::hasTable('users'))->toBeFalse();
tenancy()->initialize($tenant2);
expect(Schema::hasTable('users'))->toBeFalse();
tenancy()->initialize($tenant3);
expect(Schema::hasTable('users'))->toBeTrue();
})->with([
'tenants:migrate',
'tenants:migrate-fresh',
]);
test('run command can skip specified tenants', function () {
$tenant1 = Tenant::create()->getTenantKey();
$tenant2 = Tenant::create()->getTenantKey();
$tenant3 = Tenant::create()->getTenantKey();
pest()->artisan("tenants:run --skip-tenants=$tenant1 --skip-tenants=$tenant2 'bar foo foo@bar foobar arg --option=option'")
->doesntExpectOutputToContain("Tenant: $tenant1")
->doesntExpectOutputToContain("Tenant: $tenant2")
->expectsOutputToContain("Tenant: $tenant3")
->assertExitCode(0);
});
test('tenants and skip-tenants options can be used together', function () {
$tenant1 = Tenant::create()->getTenantKey();
$tenant2 = Tenant::create()->getTenantKey();
$tenant3 = Tenant::create()->getTenantKey();
// Scope to tenant1+tenant2, then skip tenant2 — only tenant1 should run
pest()->artisan("tenants:run --tenants=$tenant1 --tenants=$tenant2 --skip-tenants=$tenant2 'bar foo foo@bar foobar arg --option=option'")
->expectsOutputToContain("Tenant: $tenant1")
->doesntExpectOutputToContain("Tenant: $tenant2")
->doesntExpectOutputToContain("Tenant: $tenant3")
->assertExitCode(0);
});

View file

@ -2,17 +2,27 @@
declare(strict_types=1);
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Stancl\JobPipeline\JobPipeline;
use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Events\TenantDeleted;
use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Jobs\DeleteDatabase;
use Stancl\Tenancy\Jobs\MigrateDatabase;
use Stancl\Tenancy\Jobs\SeedDatabase;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Foundation\Auth\User as Authenticable;
use Stancl\Tenancy\Tests\Etc\TestSeeder;
beforeEach($cleanup = function () {
DeleteDatabase::$ignoreFailures = false;
DeleteDatabase::$skipWhenCreateDatabaseIsFalse = true;
});
afterEach($cleanup);
test('database can be created after tenant creation', function () {
config(['tenancy.database.template_tenant_connection' => 'mysql']);
@ -82,6 +92,73 @@ test('custom job can be added to the pipeline', function () {
});
});
test('database can be deleted after tenant deletion', function () {
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
Event::listen(TenantDeleted::class, JobPipeline::make([DeleteDatabase::class])->send(function (TenantDeleted $event) {
return $event->tenant;
})->toListener());
$tenant = Tenant::create();
$manager = $tenant->database()->manager();
expect($manager->databaseExists($tenant->database()->getName()))->toBeTrue();
$tenant->delete();
expect($manager->databaseExists($tenant->database()->getName()))->toBeFalse();
});
test('database deletion is skipped when create_database is false', function (bool $skipWhenCreateDatabaseIsFalse) {
Event::listen(TenantDeleted::class, JobPipeline::make([DeleteDatabase::class])->send(function (TenantDeleted $event) {
return $event->tenant;
})->toListener());
// create_database=false means no DB is created (e.g. tenant uses a pre-existing DB)
// On deletion, DeleteDatabase should skip rather than attempting DROP DATABASE on a non-existent DB
$tenant = Tenant::create(['tenancy_create_database' => false, 'tenancy_db_name' => 'non_existing_db']);
$manager = $tenant->database()->manager();
expect($manager->databaseExists($tenant->database()->getName()))->toBeFalse();
DeleteDatabase::$skipWhenCreateDatabaseIsFalse = $skipWhenCreateDatabaseIsFalse;
if ($skipWhenCreateDatabaseIsFalse) {
$tenant->delete(); // no exception
} else {
expect(fn () => $tenant->delete())->toThrow(QueryException::class, "database doesn't exist");
}
expect($manager->databaseExists($tenant->database()->getName()))->toBeFalse();
})->with([true, false]);
test('database deletion failure is ignored when ignoreFailures is true', function (bool $ignoreFailures) {
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
Event::listen(TenantDeleted::class, JobPipeline::make([DeleteDatabase::class])->send(function (TenantDeleted $event) {
return $event->tenant;
})->toListener());
DeleteDatabase::$ignoreFailures = $ignoreFailures;
$tenant = Tenant::create();
$manager = $tenant->database()->manager();
expect($manager->databaseExists($tenant->database()->getName()))->toBeTrue();
$manager->deleteDatabase($tenant); // manually delete so the job fails
expect($manager->databaseExists($tenant->database()->getName()))->toBeFalse();
if ($ignoreFailures) {
$tenant->delete(); // no exception
} else {
expect(fn () => $tenant->delete())->toThrow(QueryException::class, "database doesn't exist");
}
})->with([true, false]);
class User extends Authenticable
{
protected $guarded = [];

View file

@ -300,7 +300,7 @@ test('using different default route modes works with global domain identificatio
$exception = match ($middleware) {
InitializeTenancyByDomain::class => TenantCouldNotBeIdentifiedOnDomainException::class,
InitializeTenancyBySubdomain::class => NotASubdomainException::class,
InitializeTenancyByDomainOrSubdomain::class => NotASubdomainException::class,
InitializeTenancyByDomainOrSubdomain::class => TenantCouldNotBeIdentifiedOnDomainException::class,
};
expect(fn () => $this->withoutExceptionHandling()->get('http://localhost/central-route'))->toThrow($exception);

View file

@ -165,6 +165,7 @@ test('global cache is always central', function (string $store, array $bootstrap
['redis', [CacheTagsBootstrapper::class]],
['redis', [CacheTenancyBootstrapper::class]],
['database', [DatabaseTenancyBootstrapper::class, DatabaseCacheBootstrapper::class]],
['database', [DatabaseTenancyBootstrapper::class, CacheTenancyBootstrapper::class]],
])->with([
'helper',
'facade',

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
use Illuminate\Database\QueryException;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
@ -16,10 +17,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);
@ -111,6 +127,64 @@ test('a new tenant gets created while pulling a pending tenant if the pending po
expect(Tenant::withPending()->get()->count())->toBe(1); // All tenants
});
test('pulling a pending tenant retries when the tenant is claimed concurrently', function () {
Tenant::createPending();
Tenant::createPending();
$stolenId = null;
Event::listen(PullingPendingTenant::class, function (PullingPendingTenant $event) use (&$stolenId) {
if ($stolenId !== null) {
return;
}
$stolenId = $event->tenant->id;
// Steal the tenant like a concurrent process would
Tenant::onlyPending()
->whereKey($event->tenant->id)
->update([$event->tenant->getColumnForQuery('pending_since') => null]);
});
$pulled = Tenant::pullPendingFromPool();
expect($pulled)->not()->toBeNull();
expect($pulled->id)->not()->toBe($stolenId); // Stolen tenant was skipped, the next one was claimed by the pull
expect(Tenant::onlyPending()->count())->toBe(0); // Both tenants claimed
});
test('the pull is rolled back and the tenant stays in the pool if setting attributes fails', function () {
// Pulling a tenant and setting its attributes happen in one transaction,
// so if setting the attributes fails, the whole pull rolls back and the tenant stays in the pool.
Schema::table('tenants', function (Blueprint $table) {
$table->string('slug')->nullable()->unique();
});
Tenant::$extraCustomColumns = ['slug'];
Tenant::create(['slug' => 'taken']);
Tenant::createPending();
// During the pull, set slug to 'taken', which is already used by another tenant to make the attribute update throw
expect(fn () => Tenant::pullPendingFromPool(false, ['slug' => 'taken']))
->toThrow(QueryException::class);
// The pull rolled back, so the tenant is still pending
expect(Tenant::onlyPending()->count())->toBe(1);
});
test('withoutPending chained with where clauses returns correct results', function () {
$tenant = Tenant::create();
$pendingTenant = Tenant::createPending();
// The query returned the correct tenant
expect(Tenant::withoutPending()->where('id', $tenant->id)->first()->id)->toBe($tenant->id);
// No tenant with this ID exists, the query returns null
expect(Tenant::withoutPending()->where('id', Str::random(8) . 'nonexistent-id')->first())->toBeNull();
// withoutPending() correctly excludes the pending tenant from the query
expect(Tenant::withoutPending()->where('id', $pendingTenant->id)->first())->toBeNull();
});
test('pending tenants are included in all queries based on the include_in_queries config', function () {
Tenant::createPending();
@ -142,8 +216,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(),
@ -152,21 +226,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'");
$tenants->each(function ($tenant) use ($command, $includeInQueries) {
if ($tenant->pending() && ! $includeInQueries) {
$command->doesntExpectOutputToContain("Tenant: {$tenant->getTenantKey()}");
} else {
$command->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}");
}
});
$pendingTenants = $tenants->filter->pending();
$readyTenants = $tenants->reject->pending();
$command->assertSuccessful();
})->with([true, false]);
$pendingTenants->each(fn ($tenant) => $artisan->doesntExpectOutputToContain("Tenant: {$tenant->getTenantKey()}"));
$readyTenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"));
$artisan->assertExitCode(0);
});
test('commands run for pending tenants too if tenancy.pending.include_in_queries is true', function() {
config(['tenancy.pending.include_in_queries' => true]);
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(),
@ -175,17 +249,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(),
@ -194,14 +273,25 @@ 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(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()}");
}
});
$tenants->each(fn ($tenant) => $artisan->expectsOutputToContain("Tenant: {$tenant->getTenantKey()}"));
$artisan->assertExitCode(0);
});
$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) {
@ -224,3 +314,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

@ -7,6 +7,7 @@ use Stancl\Tenancy\Database\Concerns\HasDomains;
use Stancl\Tenancy\Exceptions\NotASubdomainException;
use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain;
use Stancl\Tenancy\Database\Models;
use Stancl\Tenancy\Resolvers\DomainTenantResolver;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
@ -108,6 +109,14 @@ test('we cant use a subdomain that doesnt belong to our central domains', functi
->get('http://foo.localhost/foo/abc/xyz');
});
test('domain resolver correctly determines if string is a subdomain', function() {
config(['tenancy.identification.central_domains' => ['site.com', 'blog.site.com']]);
expect(DomainTenantResolver::isSubdomain('blog.site.com'))->toBeFalse();
expect(DomainTenantResolver::isSubdomain('tenant.site.com'))->toBeTrue();
expect(DomainTenantResolver::isSubdomain('tenantsite.com'))->toBeFalse();
});
class SubdomainTenant extends Models\Tenant
{
use HasDomains;

View file

@ -17,6 +17,7 @@ use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Database\Contracts\ManagesDatabaseUsers;
use Stancl\Tenancy\Database\Contracts\StatefulTenantDatabaseManager;
use Stancl\Tenancy\Database\TenantDatabaseManagers\MySQLDatabaseManager;
use Stancl\Tenancy\Database\TenantDatabaseManagers\SQLiteDatabaseManager;
@ -36,6 +37,10 @@ beforeEach(function () {
SQLiteDatabaseManager::$path = null;
});
afterEach(function () {
SQLiteDatabaseManager::$path = null;
});
test('databases can be created and deleted', function ($driver, $databaseManager) {
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
@ -539,6 +544,237 @@ test('partial tenant connection templates get merged into the central connection
expect($manager->connection()->getConfig('url'))->toBeNull();
});
test('database managers validate parameters used in raw sql statements', function ($driver, $databaseManager) {
config()->set([
"tenancy.database.template_tenant_connection" => $driver,
]);
$manager = app($databaseManager);
if ($manager instanceof StatefulTenantDatabaseManager) {
$manager->setConnection($driver);
}
$invalidDatabaseName = "\"database_with_quotes\"";
if (! ($manager instanceof ManagesDatabaseUsers)) {
// Only test createDatabase() and deleteDatabase() with non-permission controlled managers here
// since permission controlled managers override these methods to e.g. delete users before
// calling parent::deleteDatabase(), and with invalid DB name, the user deletion will already
// fail before we even get to actual deleteDatabase() logic.
$tenant = Tenant::make([
'tenancy_db_name' => $invalidDatabaseName,
]);
expect(fn () => $manager->createDatabase($tenant))
->toThrow(InvalidArgumentException::class, 'Forbidden character');
expect(fn () => $manager->deleteDatabase($tenant))
->toThrow(InvalidArgumentException::class, 'Forbidden character');
} else {
// Invalid username, createUser() and deleteUser() should
// throw an invalid argument exception.
$tenantWithInvalidUsername = Tenant::make([
'tenancy_db_name' => 'valid_database_name890',
'tenancy_db_username' => "username with spaces",
]);
expect(fn () => $manager->createUser($tenantWithInvalidUsername->database()))
->toThrow(InvalidArgumentException::class, 'Forbidden character');
expect(fn () => $manager->deleteUser($tenantWithInvalidUsername->database()))
->toThrow(InvalidArgumentException::class, 'Forbidden character');
// Invalid database name, createUser() should throw
// an invalid argument exception. deleteUser() doesn't
// validate the DB name (it only validates the username).
$tenantWithInvalidDatabase = Tenant::make([
'tenancy_db_name' => $invalidDatabaseName,
'tenancy_db_username' => 'valid_USERNAME',
'tenancy_db_password' => 'valid_password',
]);
expect(fn () => $manager->createUser($tenantWithInvalidDatabase->database()))
->toThrow(InvalidArgumentException::class, 'Forbidden character');
$tenantWithInvalidPassword = Tenant::make([
'tenancy_db_name' => 'valid_database_name890',
'tenancy_db_username' => 'valid_USERNAME',
'tenancy_db_password' => "p'ssword",
]);
expect(fn () => $manager->createUser($tenantWithInvalidPassword->database()))
->toThrow(InvalidArgumentException::class, 'Forbidden character');
// Special characters are allowed in passwords
$tenantWithValidPassword = Tenant::make([
'tenancy_db_name' => 'valid_database_name890' . Str::random(8),
'tenancy_db_username' => 'valid_USERNAME' . Str::random(8),
'tenancy_db_password' => "]pa$$ ;word",
]);
expect(fn () => $manager->createUser($tenantWithValidPassword->database()))
->not()->toThrow(InvalidArgumentException::class, 'Forbidden character');
$tenantWithNullCredentials = Tenant::make([
'tenancy_db_name' => 'valid_db_name',
'tenancy_db_username' => null,
'tenancy_db_password' => null,
]);
expect(fn () => $manager->createUser($tenantWithNullCredentials->database()))
->toThrow(InvalidArgumentException::class, 'Parameter cannot be null.');
}
})->with('database_managers');
test('mysql database manager validates charset and collation correctly', function (string $param) {
$manager = app(MySQLDatabaseManager::class);
$manager->setConnection('mysql');
// using a non-string value (empty array) which is invalid
config(["database.connections.mysql.$param" => []]);
DB::purge('mysql');
$tenant = Tenant::make([
'tenancy_db_name' => 'valid_db_name',
]);
expect(fn () => $manager->createDatabase($tenant))
->toThrow(InvalidArgumentException::class, 'Parameter has to be a string.');
})->with(['charset', 'collation']);
test('sqlite database manager validates database names correctly', function () {
$manager = app(SQLiteDatabaseManager::class);
// Dots are allowed in database names
expect(fn () => $manager->databaseExists('valid-db_name.sqlite'))
->not()->toThrow(InvalidArgumentException::class);
// Directory names are considered invalid input for database names
expect(fn () => $manager->databaseExists('..'))
->toThrow(InvalidArgumentException::class);
// Empty strings are considered invalid input for database names
expect(fn () => $manager->databaseExists(''))
->toThrow(InvalidArgumentException::class);
});
test('sqlite database manager recognizes inmemory databases correctly', function () {
$manager = app(SQLiteDatabaseManager::class);
expect($manager->isInMemory('file:_tenancy_inmemory_123?mode=memory&cache=shared'))->toBeTrue();
expect($manager->isInMemory(':memory:'))->toBeTrue();
// Missing the '?mode=memory&cache=shared' suffix
expect($manager->isInMemory('file:_tenancy_inmemory_456'))->toBeFalse();
// Doesn't start with 'file:_tenancy_inmemory_'
expect($manager->isInMemory('_tenancy_inmemory_123?mode=memory&cache=shared'))->toBeFalse();
// In-memory DB name is validated correctly in makeConnectionConfig()
expect(fn () => $manager->makeConnectionConfig([], 'file:_tenancy_inmemory_12"3?mode=memory&cache=shared'))
->toThrow(InvalidArgumentException::class, 'Forbidden character');
expect(fn () => $manager->makeConnectionConfig([], 'file:_tenancy_inmemory_123?mode=memory&cache=shared'))
->not()->toThrow(InvalidArgumentException::class);
expect(fn () => $manager->makeConnectionConfig([], ':memory:'))
->not()->toThrow(InvalidArgumentException::class);
});
test('sqlite database manager respects the configured path while making the database config', function () {
config()->set([
'tenancy.database.template_tenant_connection' => 'sqlite',
]);
$tenant = Tenant::make([
'tenancy_db_name' => 'tenant.sqlite',
]);
// SQLiteDatabaseManager::$path is null, the database path is built using database_path()
expect($tenant->database()->connection()['database'])->toBe(database_path('tenant.sqlite'));
SQLiteDatabaseManager::$path = '/custom/path/';
expect($tenant->database()->connection()['database'])->toBe('/custom/path/tenant.sqlite');
});
test('newly created tenant databases use the correct charset and collation with mysql', function () {
config([
'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class],
'database.connections.mysql.charset' => 'utf8mb4',
'database.connections.mysql.collation' => 'utf8mb4_unicode_ci',
]);
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
withBootstrapping();
$serverDefaultCharset = DB::selectOne('SELECT @@character_set_server AS charset')->charset;
$serverDefaultCollation = DB::selectOne('SELECT @@collation_server AS collation')->collation;
$databaseCharset = fn () => DB::selectOne('SELECT DEFAULT_CHARACTER_SET_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = DATABASE()')->DEFAULT_CHARACTER_SET_NAME;
$databaseCollation = fn () => DB::selectOne('SELECT DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = DATABASE()')->DEFAULT_COLLATION_NAME;
$defaultTenant = Tenant::create();
tenancy()->initialize($defaultTenant);
// No charset or collation specified,
// defaults from the MySQL config used.
expect($databaseCharset())->toBe('utf8mb4');
expect($databaseCollation())->toBe('utf8mb4_unicode_ci');
$tenantWithCharsetAndCollation = Tenant::create([
'tenancy_db_charset' => 'latin1',
'tenancy_db_collation' => 'latin1_swedish_ci',
]);
tenancy()->initialize($tenantWithCharsetAndCollation);
// Custom charset and collation from tenant config
expect($databaseCharset())->toBe('latin1');
expect($databaseCollation())->toBe('latin1_swedish_ci');
$tenantWithNullCharsetAndCollation = Tenant::create([
'tenancy_db_charset' => null,
'tenancy_db_collation' => null,
]);
tenancy()->initialize($tenantWithNullCharsetAndCollation);
// Default MySQL server charset and collation
// (e.g. charset = utf8mb4, collation = utf8mb4_0900_ai_ci)
expect($databaseCharset())->toBe($serverDefaultCharset);
expect($databaseCollation())->toBe($serverDefaultCollation);
$tenantWithCharsetAndNullCollation = Tenant::create([
'tenancy_db_charset' => 'binary',
'tenancy_db_collation' => null,
]);
tenancy()->initialize($tenantWithCharsetAndNullCollation);
// Charset specified, collation is null,
// MySQL will choose a default collation for the specified charset.
expect($databaseCharset())->toBe('binary');
expect($databaseCollation())->toBe('binary');
// Collation specified, charset is null,
// MySQL will choose a default charset for the specified collation.
$tenantWithCollationAndNullCharset = Tenant::create([
'tenancy_db_charset' => null,
'tenancy_db_collation' => 'latin1_swedish_ci',
]);
tenancy()->initialize($tenantWithCollationAndNullCharset);
expect($databaseCharset())->toBe('latin1');
expect($databaseCollation())->toBe('latin1_swedish_ci');
});
// Datasets
dataset('database_managers', [
['mysql', MySQLDatabaseManager::class],

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