1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-08-07 09:04:03 +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=');
});