1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2025-12-13 02:04:03 +00:00

Merge branch 'master' into database-cache-bootstrapper

This commit is contained in:
lukinovec 2025-07-22 19:32:57 +02:00
commit 2cfa8831a3
113 changed files with 3035 additions and 1373 deletions

View file

@ -11,6 +11,7 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Actions\CreateStorageSymlinksAction;
use Stancl\Tenancy\Actions\RemoveStorageSymlinksAction;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Illuminate\Support\Facades\File;
beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
@ -35,11 +36,15 @@ test('create storage symlinks action works', function() {
tenancy()->initialize($tenant);
$this->assertDirectoryDoesNotExist($publicPath = public_path("public-$tenantKey"));
// The symlink doesn't exist
expect(is_link($publicPath = public_path("public-$tenantKey")))->toBeFalse();
expect(file_exists($publicPath))->toBeFalse();
(new CreateStorageSymlinksAction)($tenant);
$this->assertDirectoryExists($publicPath);
// The symlink exists and is valid
expect(is_link($publicPath = public_path("public-$tenantKey")))->toBeTrue();
expect(file_exists($publicPath))->toBeTrue();
$this->assertEquals(storage_path("app/public/"), readlink($publicPath));
});
@ -61,9 +66,48 @@ test('remove storage symlinks action works', function() {
(new CreateStorageSymlinksAction)($tenant);
$this->assertDirectoryExists($publicPath = public_path("public-$tenantKey"));
// The symlink exists and is valid
expect(is_link($publicPath = public_path("public-$tenantKey")))->toBeTrue();
expect(file_exists($publicPath))->toBeTrue();
(new RemoveStorageSymlinksAction)($tenant);
$this->assertDirectoryDoesNotExist($publicPath);
// The symlink doesn't exist
expect(is_link($publicPath))->toBeFalse();
expect(file_exists($publicPath))->toBeFalse();
});
test('removing tenant symlinks works even if the symlinks are invalid', function() {
config([
'tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
],
'tenancy.filesystem.suffix_base' => 'tenant-',
'tenancy.filesystem.root_override.public' => '%storage_path%/app/public/',
'tenancy.filesystem.url_override.public' => 'public-%tenant%'
]);
/** @var Tenant $tenant */
$tenant = Tenant::create();
$tenantKey = $tenant->getTenantKey();
tenancy()->initialize($tenant);
(new CreateStorageSymlinksAction)($tenant);
// The symlink exists and is valid
expect(is_link($publicPath = public_path("public-$tenantKey")))->toBeTrue();
expect(file_exists($publicPath))->toBeTrue();
// Make the symlink invalid by deleting the tenant storage directory
$storagePath = storage_path();
File::deleteDirectory($storagePath);
// The symlink still exists, but isn't valid
expect(is_link($publicPath))->toBeTrue();
expect(file_exists($publicPath))->toBeFalse();
(new RemoveStorageSymlinksAction)($tenant);
expect(is_link($publicPath))->toBeFalse();
});

View file

@ -9,6 +9,8 @@ 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;
use function Stancl\Tenancy\Tests\withTenantDatabases;
beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);

View file

@ -23,6 +23,7 @@ use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
$this->mockConsoleOutput = false;

View file

@ -15,6 +15,7 @@ use Stancl\Tenancy\Tests\Etc\TestingBroadcaster;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);

View file

@ -9,6 +9,7 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Bootstrappers\CacheTagsBootstrapper;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
config(['tenancy.bootstrappers' => [CacheTagsBootstrapper::class]]);

View file

@ -13,6 +13,7 @@ use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Listeners;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
/**
* This collection of regression tests verifies that SessionTenancyBootstrapper

View file

@ -6,6 +6,9 @@ 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);

View file

@ -16,6 +16,7 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\DeleteTenantStorage;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);

View file

@ -13,6 +13,14 @@ use Stancl\Tenancy\Bootstrappers\Integrations\FortifyRouteBootstrapper;
beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
FortifyRouteBootstrapper::$passTenantParameter = true;
});
afterEach(function () {
FortifyRouteBootstrapper::$passTenantParameter = true;
FortifyRouteBootstrapper::$fortifyRedirectMap = [];
FortifyRouteBootstrapper::$fortifyHome = 'tenant.dashboard';
FortifyRouteBootstrapper::$passQueryParameter = false;
});
test('fortify route tenancy bootstrapper updates fortify config correctly', function() {
@ -25,53 +33,31 @@ test('fortify route tenancy bootstrapper updates fortify config correctly', func
return true;
})->name($homeRouteName = 'home');
Route::get('/{tenant}/home', function () {
return true;
})->name($pathIdHomeRouteName = 'tenant.home');
Route::get('/welcome', function () {
return true;
})->name($welcomeRouteName = 'welcome');
Route::get('/{tenant}/welcome', function () {
return true;
})->name($pathIdWelcomeRouteName = 'path.welcome');
FortifyRouteBootstrapper::$fortifyHome = $homeRouteName;
FortifyRouteBootstrapper::$fortifyRedirectMap['login'] = $welcomeRouteName;
// Make login redirect to the central welcome route
FortifyRouteBootstrapper::$fortifyRedirectMap['login'] = [
'route_name' => $welcomeRouteName,
'context' => Context::CENTRAL,
];
expect(config('fortify.home'))->toBe($originalFortifyHome);
expect(config('fortify.redirects'))->toBe($originalFortifyRedirects);
FortifyRouteBootstrapper::$passTenantParameter = true;
tenancy()->initialize($tenant = Tenant::create());
// The bootstraper makes fortify.home always receive the tenant parameter
expect(config('fortify.home'))->toBe('http://localhost/home?tenant=' . $tenant->getTenantKey());
// The login redirect route has the central context specified, so it doesn't receive the tenant parameter
expect(config('fortify.redirects'))->toEqual(['login' => 'http://localhost/welcome']);
expect(config('fortify.redirects'))->toEqual(['login' => 'http://localhost/welcome?tenant=' . $tenant->getTenantKey()]);
tenancy()->end();
expect(config('fortify.home'))->toBe($originalFortifyHome);
expect(config('fortify.redirects'))->toBe($originalFortifyRedirects);
// Making a route's context will pass the tenant parameter to the route
FortifyRouteBootstrapper::$fortifyRedirectMap['login']['context'] = Context::TENANT;
FortifyRouteBootstrapper::$passTenantParameter = false;
tenancy()->initialize($tenant);
expect(config('fortify.redirects'))->toEqual(['login' => 'http://localhost/welcome?tenant=' . $tenant->getTenantKey()]);
// Make the home and login route accept the tenant as a route parameter
// To confirm that tenant route parameter gets filled automatically too (path identification works as well as query string)
FortifyRouteBootstrapper::$fortifyHome = $pathIdHomeRouteName;
FortifyRouteBootstrapper::$fortifyRedirectMap['login']['route_name'] = $pathIdWelcomeRouteName;
expect(config('fortify.home'))->toBe('http://localhost/home');
expect(config('fortify.redirects'))->toEqual(['login' => 'http://localhost/welcome']);
tenancy()->end();
tenancy()->initialize($tenant);
expect(config('fortify.home'))->toBe("http://localhost/{$tenant->getTenantKey()}/home");
expect(config('fortify.redirects'))->toEqual(['login' => "http://localhost/{$tenant->getTenantKey()}/welcome"]);
expect(config('fortify.home'))->toBe($originalFortifyHome);
expect(config('fortify.redirects'))->toBe($originalFortifyRedirects);
});

View file

@ -10,18 +10,23 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\RootUrlBootstrapper;
use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain;
use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Overrides\TenancyUrlGenerator;
beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
RootUrlBootstrapper::$rootUrlOverride = null;
RootUrlBootstrapper::$rootUrlOverrideInTests = true;
});
afterEach(function () {
RootUrlBootstrapper::$rootUrlOverride = null;
RootUrlBootstrapper::$rootUrlOverrideInTests = false;
});
test('root url bootstrapper overrides the root url when tenancy gets initialized and reverts the url to the central one after tenancy ends', function() {
test('root url bootstrapper overrides the root url when tenancy gets initialized and reverts the url to the central one when ending tenancy', function() {
config(['tenancy.bootstrappers' => [RootUrlBootstrapper::class]]);
Route::group([

View file

@ -1,5 +1,6 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Routing\UrlGenerator;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Facades\Event;
@ -12,19 +13,26 @@ use Stancl\Tenancy\Overrides\TenancyUrlGenerator;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Illuminate\Routing\Exceptions\UrlGenerationException;
use Illuminate\Support\Facades\Schema;
use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException;
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
TenancyUrlGenerator::$prefixRouteNames = false;
TenancyUrlGenerator::$passTenantParameterToRoutes = true;
TenancyUrlGenerator::$passTenantParameterToRoutes = false;
UrlGeneratorBootstrapper::$addTenantParameterToDefaults = false;
});
afterEach(function () {
TenancyUrlGenerator::$prefixRouteNames = false;
TenancyUrlGenerator::$passTenantParameterToRoutes = true;
TenancyUrlGenerator::$passTenantParameterToRoutes = false;
UrlGeneratorBootstrapper::$addTenantParameterToDefaults = false;
});
test('url generator bootstrapper swaps the url generator instance correctly', function() {
@ -41,42 +49,246 @@ test('url generator bootstrapper swaps the url generator instance correctly', fu
->not()->toBeInstanceOf(TenancyUrlGenerator::class);
});
test('url generator bootstrapper can prefix route names passed to the route helper', function() {
Route::get('/central/home', fn () => route('home'))->name('home');
// Tenant route name prefix is 'tenant.' by default
Route::get('/{tenant}/home', fn () => route('tenant.home'))->name('tenant.home')->middleware(['tenant', InitializeTenancyByPath::class]);
test('tenancy url generator can prefix route names passed to the route helper', function() {
config([
'tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_route_name_prefix' => 'custom_prefix.',
]);
Route::get('/central/home', fn () => '')->name('home');
Route::get('/tenant/home', fn () => '')->name('custom_prefix.home');
$tenant = Tenant::create();
$tenantKey = $tenant->getTenantKey();
$centralRouteUrl = route('home');
$tenantRouteUrl = route('tenant.home', ['tenant' => $tenantKey]);
TenancyUrlGenerator::$bypassParameter = 'bypassParameter';
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
tenancy()->initialize($tenant);
// Route names don't get prefixed when TenancyUrlGenerator::$prefixRouteNames is false
expect(route('home'))->not()->toBe($centralRouteUrl);
// When TenancyUrlGenerator::$passTenantParameterToRoutes is true (default)
// The route helper receives the tenant parameter
// So in order to generate central URL, we have to pass the bypass parameter
expect(route('home', ['bypassParameter' => true]))->toBe($centralRouteUrl);
// Route names don't get prefixed when TenancyUrlGenerator::$prefixRouteNames is false (default)
expect(route('home'))->toBe('http://localhost/central/home');
// When $prefixRouteNames is true, the route name passed to the route() helper ('home') gets prefixed automatically.
TenancyUrlGenerator::$prefixRouteNames = true;
// The $prefixRouteNames property is true
// The route name passed to the route() helper ('home') gets prefixed prefixed with 'tenant.' automatically
expect(route('home'))->toBe($tenantRouteUrl);
// The 'tenant.home' route name doesn't get prefixed because it is already prefixed with 'tenant.'
// Also, the route receives the tenant parameter automatically
expect(route('tenant.home'))->toBe($tenantRouteUrl);
expect(route('home'))->toBe('http://localhost/tenant/home');
// The 'custom_prefix.home' route name doesn't get prefixed -- it is already prefixed with 'custom_prefix.'
expect(route('custom_prefix.home'))->toBe('http://localhost/tenant/home');
// Ending tenancy reverts route() behavior changes
tenancy()->end();
expect(route('home'))->toBe($centralRouteUrl);
expect(route('home'))->toBe('http://localhost/central/home');
});
test('path identification route helper behavior', function (bool $addTenantParameterToDefaults, bool $passTenantParameterToRoutes) {
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
UrlGeneratorBootstrapper::$addTenantParameterToDefaults = $addTenantParameterToDefaults;
TenancyUrlGenerator::$passTenantParameterToRoutes = $passTenantParameterToRoutes;
$tenant = Tenant::create();
Route::get('/{tenant}/home', fn () => tenant('id'))
->name('tenant.home')
->middleware([InitializeTenancyByPath::class]);
tenancy()->initialize($tenant);
if (! $addTenantParameterToDefaults && ! $passTenantParameterToRoutes) {
expect(fn () => route('tenant.home'))->toThrow(UrlGenerationException::class, 'Missing parameter: tenant');
} else {
// If at least *one* of the approaches was used, the parameter will make its way to the route
expect(route('tenant.home'))->toBe("http://localhost/{$tenant->id}/home");
pest()->get(route('tenant.home'))->assertSee($tenant->id);
}
})->with([true, false]) // UrlGeneratorBootstrapper::$addTenantParameterToDefaults
->with([true, false]); // TenancyUrlGenerator::$passTenantParameterToRoutes
test('request data identification route helper behavior', function (bool $addTenantParameterToDefaults, bool $passTenantParameterToRoutes) {
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
UrlGeneratorBootstrapper::$addTenantParameterToDefaults = $addTenantParameterToDefaults;
TenancyUrlGenerator::$passTenantParameterToRoutes = $passTenantParameterToRoutes;
$tenant = Tenant::create();
Route::get('/tenant/home', fn () => tenant('id'))
->name('tenant.home')
->middleware([InitializeTenancyByRequestData::class]);
tenancy()->initialize($tenant);
if ($passTenantParameterToRoutes) {
// Only $passTenantParameterToRoutes has an effect, defaults do not affect request data URL generation
expect(route('tenant.home'))->toBe("http://localhost/tenant/home?tenant={$tenant->id}");
pest()->get(route('tenant.home'))->assertSee($tenant->id);
} else {
expect(route('tenant.home'))->toBe("http://localhost/tenant/home");
expect(fn () => $this->withoutExceptionHandling()->get(route('tenant.home')))->toThrow(TenantCouldNotBeIdentifiedByRequestDataException::class);
}
})->with([true, false]) // UrlGeneratorBootstrapper::$addTenantParameterToDefaults
->with([true, false]); // TenancyUrlGenerator::$passTenantParameterToRoutes
test('changing request data query parameter and model column is respected by the url generator', function () {
config([
'tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class],
'tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.query_parameter' => 'team',
'tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.tenant_model_column' => 'slug',
]);
Tenant::$extraCustomColumns = ['slug'];
Schema::table('tenants', function (Blueprint $table) {
$table->string('slug')->unique();
});
TenancyUrlGenerator::$passTenantParameterToRoutes = true;
$tenant = Tenant::create(['slug' => 'acme']);
Route::get('/tenant/home', fn () => tenant('id'))
->name('tenant.home')
->middleware([InitializeTenancyByRequestData::class]);
tenancy()->initialize($tenant);
expect(route('tenant.home'))->toBe("http://localhost/tenant/home?team=acme");
pest()->get(route('tenant.home'))->assertSee($tenant->id);
});
test('setting extra model columns sets additional URL defaults', function () {
Tenant::$extraCustomColumns = ['slug'];
TenancyUrlGenerator::$passTenantParameterToRoutes = false;
UrlGeneratorBootstrapper::$addTenantParameterToDefaults = true;
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.allowed_extra_model_columns' => ['slug']]);
Schema::table('tenants', function (Blueprint $table) {
$table->string('slug')->unique();
});
Route::get('/{tenant}/foo/{user}', function (string $user) {
return tenant()->getTenantKey() . " $user";
})->middleware([InitializeTenancyByPath::class, 'web'])->name('foo');
Route::get('/{tenant:slug}/fooslug/{user}', function (string $user) {
return tenant()->getTenantKey() . " $user";
})->middleware([InitializeTenancyByPath::class, 'web'])->name('fooslug');
$tenant = Tenant::create(['slug' => 'acme']);
// In central context, no URL defaults are applied
expect(route('foo', [$tenant->getTenantKey(), 'bar']))->toBe("http://localhost/{$tenant->getTenantKey()}/foo/bar");
pest()->get(route('foo', [$tenant->getTenantKey(), 'bar']))->assertSee(tenant()->getTenantKey() . ' bar');
tenancy()->end();
expect(route('fooslug', ['acme', 'bar']))->toBe('http://localhost/acme/fooslug/bar');
pest()->get(route('fooslug', ['acme', 'bar']))->assertSee(tenant()->getTenantKey() . ' bar');
tenancy()->end();
// In tenant context, URL defaults are applied
tenancy()->initialize($tenant);
expect(route('foo', ['bar']))->toBe("http://localhost/{$tenant->getTenantKey()}/foo/bar");
pest()->get(route('foo', ['bar']))->assertSee(tenant()->getTenantKey() . ' bar');
expect(route('fooslug', ['bar']))->toBe('http://localhost/acme/fooslug/bar');
pest()->get(route('fooslug', ['bar']))->assertSee(tenant()->getTenantKey() . ' bar');
});
test('changing the tenant model column changes the default value for the tenant parameter', function () {
Tenant::$extraCustomColumns = ['slug'];
TenancyUrlGenerator::$passTenantParameterToRoutes = false;
UrlGeneratorBootstrapper::$addTenantParameterToDefaults = true;
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_model_column' => 'slug']);
Schema::table('tenants', function (Blueprint $table) {
$table->string('slug')->unique();
});
Route::get('/{tenant}/foo/{user}', function (string $user) {
return tenant()->getTenantKey() . " $user";
})->middleware([InitializeTenancyByPath::class, 'web'])->name('foo');
$tenant = Tenant::create(['slug' => 'acme']);
// In central context, no URL defaults are applied
expect(route('foo', ['acme', 'bar']))->toBe("http://localhost/acme/foo/bar");
pest()->get(route('foo', ['acme', 'bar']))->assertSee(tenant()->getTenantKey() . ' bar');
tenancy()->end();
// In tenant context, URL defaults are applied
tenancy()->initialize($tenant);
expect(route('foo', ['bar']))->toBe("http://localhost/acme/foo/bar");
pest()->get(route('foo', ['bar']))->assertSee(tenant()->getTenantKey() . ' bar');
});
test('changing the tenant parameter name is respected by the url generator', function () {
Tenant::$extraCustomColumns = ['slug', 'slug2'];
TenancyUrlGenerator::$passTenantParameterToRoutes = false;
UrlGeneratorBootstrapper::$addTenantParameterToDefaults = true;
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'team']);
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_model_column' => 'slug']);
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.allowed_extra_model_columns' => ['slug2']]);
Schema::table('tenants', function (Blueprint $table) {
$table->string('slug')->unique();
$table->string('slug2')->unique();
});
Route::get('/{team}/foo/{user}', function (string $user) {
return tenant()->getTenantKey() . " $user";
})->middleware([InitializeTenancyByPath::class, 'web'])->name('foo');
Route::get('/{team:slug2}/fooslug2/{user}', function (string $user) {
return tenant()->getTenantKey() . " $user";
})->middleware([InitializeTenancyByPath::class, 'web'])->name('fooslug2');
$tenant = Tenant::create(['slug' => 'acme', 'slug2' => 'acme2']);
// In central context, no URL defaults are applied
expect(route('foo', ['acme', 'bar']))->toBe("http://localhost/acme/foo/bar");
pest()->get(route('foo', ['acme', 'bar']))->assertSee(tenant()->getTenantKey() . ' bar');
tenancy()->end();
expect(route('fooslug2', ['acme2', 'bar']))->toBe("http://localhost/acme2/fooslug2/bar");
pest()->get(route('fooslug2', ['acme2', 'bar']))->assertSee(tenant()->getTenantKey() . ' bar');
tenancy()->end();
// In tenant context, URL defaults are applied
tenancy()->initialize($tenant);
expect(route('foo', ['bar']))->toBe("http://localhost/acme/foo/bar");
pest()->get(route('foo', ['bar']))->assertSee(tenant()->getTenantKey() . ' bar');
expect(route('fooslug2', ['bar']))->toBe("http://localhost/acme2/fooslug2/bar");
pest()->get(route('fooslug2', ['bar']))->assertSee(tenant()->getTenantKey() . ' bar');
});
test('url generator can override specific route names', function() {
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
Route::get('/foo', fn () => 'foo')->name('foo');
Route::get('/bar', fn () => 'bar')->name('bar');
Route::get('/baz', fn () => 'baz')->name('baz'); // Not overridden
TenancyUrlGenerator::$overrides = ['foo' => 'bar'];
expect(route('foo'))->toBe(url('/foo'));
expect(route('bar'))->toBe(url('/bar'));
expect(route('baz'))->toBe(url('/baz'));
tenancy()->initialize(Tenant::create());
expect(route('foo'))->toBe(url('/bar'));
expect(route('bar'))->toBe(url('/bar')); // not overridden
expect(route('baz'))->toBe(url('/baz')); // not overridden
// Bypass the override
expect(route('foo', ['central' => true]))->toBe(url('/foo'));
});
test('both the name prefixing and the tenant parameter logic gets skipped when bypass parameter is used', function () {
@ -105,54 +317,8 @@ test('both the name prefixing and the tenant parameter logic gets skipped when b
->not()->toContain('bypassParameter');
// When the bypass parameter is false, the generated route URL points to the prefixed route ('tenant.home')
expect(route('home', ['bypassParameter' => false]))->toBe($tenantRouteUrl)
// The tenant parameter is not passed automatically since both
// UrlGeneratorBootstrapper::$addTenantParameterToDefaults and TenancyUrlGenerator::$passTenantParameterToRoutes are false by default
expect(route('home', ['bypassParameter' => false, 'tenant' => $tenant->getTenantKey()]))->toBe($tenantRouteUrl)
->not()->toContain('bypassParameter');
});
test('url generator bootstrapper can make route helper generate links with the tenant parameter', function() {
Route::get('/query_string', fn () => route('query_string'))->name('query_string')->middleware(['universal', InitializeTenancyByRequestData::class]);
Route::get('/path', fn () => route('path'))->name('path');
Route::get('/{tenant}/path', fn () => route('tenant.path'))->name('tenant.path')->middleware([InitializeTenancyByPath::class]);
$tenant = Tenant::create();
$tenantKey = $tenant->getTenantKey();
$queryStringCentralUrl = route('query_string');
$queryStringTenantUrl = route('query_string', ['tenant' => $tenantKey]);
$pathCentralUrl = route('path');
$pathTenantUrl = route('tenant.path', ['tenant' => $tenantKey]);
// Makes the route helper receive the tenant parameter whenever available
// Unless the bypass parameter is true
TenancyUrlGenerator::$passTenantParameterToRoutes = true;
TenancyUrlGenerator::$bypassParameter = 'bypassParameter';
config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]);
expect(route('path'))->toBe($pathCentralUrl);
// Tenant parameter required, but not passed since tenancy wasn't initialized
expect(fn () => route('tenant.path'))->toThrow(UrlGenerationException::class);
tenancy()->initialize($tenant);
// Tenant parameter is passed automatically
expect(route('path'))->not()->toBe($pathCentralUrl); // Parameter added as query string bypassParameter needed
expect(route('path', ['bypassParameter' => true]))->toBe($pathCentralUrl);
expect(route('tenant.path'))->toBe($pathTenantUrl);
expect(route('query_string'))->toBe($queryStringTenantUrl)->toContain('tenant=');
expect(route('query_string', ['bypassParameter' => 'true']))->toBe($queryStringCentralUrl)->not()->toContain('tenant=');
tenancy()->end();
expect(route('query_string'))->toBe($queryStringCentralUrl);
// Tenant parameter required, but shouldn't be passed since tenancy isn't initialized
expect(fn () => route('tenant.path'))->toThrow(UrlGenerationException::class);
// Route-level identification
pest()->get("http://localhost/query_string")->assertSee($queryStringCentralUrl);
pest()->get("http://localhost/query_string?tenant=$tenantKey")->assertSee($queryStringTenantUrl);
pest()->get("http://localhost/path")->assertSee($pathCentralUrl);
pest()->get("http://localhost/$tenantKey/path")->assertSee($pathTenantUrl);
});

View file

@ -19,6 +19,7 @@ use Illuminate\Broadcasting\Broadcasters\NullBroadcaster;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper;
use Illuminate\Contracts\Broadcasting\Broadcaster as BroadcasterContract;
use function Stancl\Tenancy\Tests\withTenantDatabases;
beforeEach(function () {
withTenantDatabases();

View file

@ -11,9 +11,11 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver;
use Stancl\Tenancy\Resolvers\DomainTenantResolver;
use Illuminate\Support\Facades\Route as RouteFacade;
use Illuminate\Support\Facades\Schema;
use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\PathIdentificationManager;
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
use function Stancl\Tenancy\Tests\pest;
test('tenants can be resolved using cached resolvers', function (string $resolver) {
$tenant = Tenant::create(['id' => $tenantKey = 'acme']);
@ -84,6 +86,34 @@ test('cache is invalidated when the tenant is updated', function (string $resolv
RequestDataTenantResolver::class,
]);
test('cache is invalidated when the tenant is deleted', function (string $resolver) {
DB::statement('SET FOREIGN_KEY_CHECKS=0;'); // allow deleting the tenant
$tenant = Tenant::create(['id' => $tenantKey = 'acme']);
$tenant->createDomain($tenantKey);
DB::enableQueryLog();
config(['tenancy.identification.resolvers.' . $resolver . '.cache' => true]);
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenantKey))))->toBeTrue();
expect(DB::getQueryLog())->not()->toBeEmpty();
DB::flushQueryLog();
expect($tenant->is(app($resolver)->resolve(getResolverArgument($resolver, $tenantKey))))->toBeTrue();
expect(DB::getQueryLog())->toBeEmpty();
$tenant->delete();
DB::flushQueryLog();
expect(fn () => app($resolver)->resolve(getResolverArgument($resolver, $tenantKey)))->toThrow(TenantCouldNotBeIdentifiedException::class);
expect(DB::getQueryLog())->not()->toBeEmpty(); // Cache was invalidated, so the DB was queried
})->with([
DomainTenantResolver::class,
PathTenantResolver::class,
RequestDataTenantResolver::class,
]);
test('cache is invalidated when a tenants domain is changed', function () {
$tenant = Tenant::create(['id' => $tenantKey = 'acme']);
$tenant->createDomain($tenantKey);
@ -110,6 +140,26 @@ test('cache is invalidated when a tenants domain is changed', function () {
pest()->assertNotEmpty(DB::getQueryLog()); // not empty
});
test('cache is invalidated when a tenants domain is deleted', function () {
$tenant = Tenant::create(['id' => $tenantKey = 'acme']);
$tenant->createDomain($tenantKey);
DB::enableQueryLog();
config(['tenancy.identification.resolvers.' . DomainTenantResolver::class . '.cache' => true]);
expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue();
DB::flushQueryLog();
expect($tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toBeTrue();
expect(DB::getQueryLog())->toBeEmpty(); // empty
$tenant->domains->first()->delete();
DB::flushQueryLog();
expect(fn () => $tenant->is(app(DomainTenantResolver::class)->resolve('acme')))->toThrow(TenantCouldNotBeIdentifiedOnDomainException::class);
expect(DB::getQueryLog())->not()->toBeEmpty(); // Cache was invalidated, so the DB was queried
});
test('PathTenantResolver forgets the tenant route parameter when the tenant is resolved from cache', function() {
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.cache' => true]);
DB::enableQueryLog();

View file

@ -1,269 +1,195 @@
<?php
use Illuminate\Routing\Route;
use Stancl\Tenancy\Enums\RouteMode;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Contracts\Http\Kernel;
use Stancl\Tenancy\Actions\CloneRoutesAsTenant;
use Stancl\Tenancy\Resolvers\PathTenantResolver;
use Illuminate\Support\Facades\Route as RouteFacade;
use Stancl\Tenancy\Tests\Etc\HasMiddlewareController;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use function Stancl\Tenancy\Tests\pest;
test('a route can be universal using path identification', function (array $routeMiddleware, array $globalMiddleware) {
foreach ($globalMiddleware as $middleware) {
if ($middleware === 'universal') {
config(['tenancy.default_route_mode' => RouteMode::UNIVERSAL]);
} else {
app(Kernel::class)->pushMiddleware($middleware);
}
}
test('CloneRoutesAsTenant action clones routes with clone middleware by default', function () {
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'team']);
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_route_name_prefix' => 'team-route.']);
RouteFacade::get('/foo', function () {
return tenancy()->initialized
? 'Tenancy is initialized.'
: 'Tenancy is not initialized.';
})->middleware($routeMiddleware);
// Should not be cloned
RouteFacade::get('/central', fn () => true)->name('central');
config(['tenancy._tests.static_identification_middleware' => $routeMiddleware]);
// Should be cloned since no specific routes are passed to the action using cloneRoute() and the route has the 'clone' middleware
RouteFacade::get('/foo', fn () => true)->middleware(['clone'])->name('foo');
RouteFacade::get('/bar', [HasMiddlewareController::class, 'index']);
$originalRoutes = RouteFacade::getRoutes()->get();
/** @var CloneRoutesAsTenant $cloneRoutesAction */
$cloneRoutesAction = app(CloneRoutesAsTenant::class);
$cloneRoutesAction->handle();
$tenantKey = Tenant::create()->getTenantKey();
$newRoutes = collect(RouteFacade::getRoutes()->get())->filter(fn ($route) => ! in_array($route, $originalRoutes));
pest()->get("http://localhost/foo")
->assertSuccessful()
->assertSee('Tenancy is not initialized.');
expect($newRoutes->count())->toEqual(1);
pest()->get("http://localhost/{$tenantKey}/foo")
->assertSuccessful()
->assertSee('Tenancy is initialized.');
tenancy()->end();
pest()->get("http://localhost/bar")
->assertSuccessful()
->assertSee('Tenancy is not initialized.');
pest()->get("http://localhost/{$tenantKey}/bar")
->assertSuccessful()
->assertSee('Tenancy is initialized.');
})->with('path identification types');
test('CloneRoutesAsTenant registers prefixed duplicates of universal routes correctly', function (bool $kernelIdentification, bool $useController, string $tenantMiddleware) {
$routeMiddleware = ['universal'];
config(['tenancy.identification.path_identification_middleware' => [$tenantMiddleware]]);
if ($kernelIdentification) {
app(Kernel::class)->pushMiddleware($tenantMiddleware);
} else {
$routeMiddleware[] = $tenantMiddleware;
}
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => $tenantParameterName = 'team']);
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_route_name_prefix' => $tenantRouteNamePrefix = 'team-route.']);
// Test that routes with controllers as well as routes with closure actions get cloned correctly
$universalRoute = RouteFacade::get('/home', $useController ? Controller::class : fn () => tenant() ? 'Tenancy is initialized.' : 'Tenancy is not initialized.')->middleware($routeMiddleware)->name('home');
$centralRoute = RouteFacade::get('/central', fn () => true)->name('central');
config(['tenancy._tests.static_identification_middleware' => $routeMiddleware]);
$universalRoute2 = RouteFacade::get('/bar', [HasMiddlewareController::class, 'index'])->name('second-home');
expect($routes = RouteFacade::getRoutes()->get())->toContain($universalRoute)
->toContain($universalRoute2)
->toContain($centralRoute);
/** @var CloneRoutesAsTenant $cloneRoutesAction */
$cloneRoutesAction = app(CloneRoutesAsTenant::class);
$cloneRoutesAction->handle();
expect($routesAfterRegisteringDuplicates = RouteFacade::getRoutes()->get())
->toContain($universalRoute)
->toContain($centralRoute);
$newRoutes = collect($routesAfterRegisteringDuplicates)->filter(fn ($route) => ! in_array($route, $routes));
expect($newRoutes->first()->uri())->toBe('{' . $tenantParameterName . '}' . '/' . $universalRoute->uri());
expect($newRoutes->last()->uri())->toBe('{' . $tenantParameterName . '}' . '/' . $universalRoute2->uri());
// Universal flag is excluded from the route middleware
expect(tenancy()->getRouteMiddleware($newRoutes->first()))
->toEqualCanonicalizing(
array_values(array_filter(array_merge(tenancy()->getRouteMiddleware($universalRoute), ['tenant']),
fn($middleware) => $middleware !== 'universal'))
);
// Universal flag is provided statically in the route's controller, so we cannot exclude it
expect(tenancy()->getRouteMiddleware($newRoutes->last()))
->toEqualCanonicalizing(
array_values(array_merge(tenancy()->getRouteMiddleware($universalRoute2), ['tenant']))
);
$newRoute = $newRoutes->first();
expect($newRoute->uri())->toBe('{team}/foo');
$tenant = Tenant::create();
pest()->get(route($centralRouteName = $universalRoute->getName()))->assertSee('Tenancy is not initialized.');
pest()->get(route($centralRouteName2 = $universalRoute2->getName()))->assertSee('Tenancy is not initialized.');
pest()->get(route($tenantRouteName = $newRoutes->first()->getName(), [$tenantParameterName => $tenant->getTenantKey()]))->assertSee('Tenancy is initialized.');
tenancy()->end();
pest()->get(route($tenantRouteName2 = $newRoutes->last()->getName(), [$tenantParameterName => $tenant->getTenantKey()]))->assertSee('Tenancy is initialized.');
expect($newRoute->getName())->toBe('team-route.foo');
pest()->get(route('team-route.foo', ['team' => $tenant->id]))->assertOk();
expect(tenancy()->getRouteMiddleware($newRoute))
->toContain('tenant')
->not()->toContain('clone');
});
expect($tenantRouteName)->toBe($tenantRouteNamePrefix . $universalRoute->getName());
expect($tenantRouteName2)->toBe($tenantRouteNamePrefix . $universalRoute2->getName());
expect($centralRouteName)->toBe($universalRoute->getName());
expect($centralRouteName2)->toBe($universalRoute2->getName());
})->with([
'kernel identification' => true,
'route-level identification' => false,
// Creates a matrix (multiple with())
])->with([
'use controller' => true,
'use closure' => false
])->with([
'path identification middleware' => InitializeTenancyByPath::class,
'custom path identification middleware' => CustomInitializeTenancyByPath::class,
]);
test('CloneRoutesAsTenant action clones only specified routes when using cloneRoute()', function () {
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'team']);
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_route_name_prefix' => 'team-route.']);
test('CloneRoutesAsTenant only clones routes with path identification by default', function () {
app(Kernel::class)->pushMiddleware(InitializeTenancyByPath::class);
// Should not be cloned
RouteFacade::get('/central', fn () => true)->name('central');
$currentRouteCount = fn () => count(RouteFacade::getRoutes()->get());
// Should not be cloned despite having clone middleware because cloneRoute() is used
RouteFacade::get('/foo', fn () => true)->middleware(['clone'])->name('foo');
$initialRouteCount = $currentRouteCount();
// The only route that should be cloned
$routeToClone = RouteFacade::get('/home', fn () => true)->name('home');
// Path identification is used globally, and this route doesn't use a specific identification middleware, meaning path identification is used and the route should get cloned
RouteFacade::get('/home', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')->middleware('universal')->name('home');
// The route uses a specific identification middleware other than InitializeTenancyByPath the route shouldn't get cloned
RouteFacade::get('/home-domain-id', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')->middleware(['universal', InitializeTenancyByDomain::class])->name('home-domain-id');
expect($currentRouteCount())->toBe($newRouteCount = $initialRouteCount + 2);
$originalRoutes = RouteFacade::getRoutes()->get();
/** @var CloneRoutesAsTenant $cloneRoutesAction */
$cloneRoutesAction = app(CloneRoutesAsTenant::class);
// If a specific route is passed to the action, clone only that route (cloneRoute() can be chained as many times as needed)
$cloneRoutesAction->cloneRoute($routeToClone);
$cloneRoutesAction->handle();
// Only one of the two routes gets cloned
expect($currentRouteCount())->toBe($newRouteCount + 1);
$newRoutes = collect(RouteFacade::getRoutes()->get())->filter(fn ($route) => ! in_array($route, $originalRoutes));
expect($newRoutes->count())->toEqual(1);
$newRoute = $newRoutes->first();
expect($newRoute->uri())->toBe('{team}/home');
$tenant = Tenant::create();
expect($newRoute->getName())->toBe('team-route.home');
pest()->get(route('team-route.home', ['team' => $tenant->id]))->assertOk();
expect(tenancy()->getRouteMiddleware($newRoute))
->toContain('tenant')
->not()->toContain('clone');
// Verify that the route with clone middleware was NOT cloned
expect(RouteFacade::getRoutes()->getByName('team-route.foo'))->toBeNull();
});
test('custom callbacks can be used for cloning universal routes', function () {
RouteFacade::get('/home', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')->middleware(['universal', InitializeTenancyByPath::class])->name($routeName = 'home');
test('all routes with any of the middleware specified in cloneRoutesWithMiddleware will be cloned by default', function (array $cloneRoutesWithMiddleware) {
RouteFacade::get('/foo', fn () => true)->name('foo');
RouteFacade::get('/bar', fn () => true)->name('bar')->middleware(['clone']);
RouteFacade::get('/baz', fn () => true)->name('baz')->middleware(['duplicate']);
$currentRouteCount = fn () => count(RouteFacade::getRoutes()->get());
$initialRouteCount = $currentRouteCount();
/** @var CloneRoutesAsTenant $cloneRoutesAction */
$cloneRoutesAction = app(CloneRoutesAsTenant::class);
$cloneRoutesAction
->cloneRoutesWithMiddleware($cloneRoutesWithMiddleware)
->handle();
// Each middleware is only used on a single route so we assert that the count of new routes matches the count of used middleware flags
expect($currentRouteCount())->toEqual($initialRouteCount + count($cloneRoutesWithMiddleware));
})->with([
[[]],
[['duplicate']],
[['clone', 'duplicate']],
]);
test('custom callback can be used for specifying if a route should be cloned', function () {
RouteFacade::get('/home', fn () => true)->name('home');
/** @var CloneRoutesAsTenant $cloneRoutesAction */
$cloneRoutesAction = app(CloneRoutesAsTenant::class);
$currentRouteCount = fn () => count(RouteFacade::getRoutes()->get());
$initialRouteCount = $currentRouteCount();
$cloneRoutesAction;
// No routes should be cloned
$cloneRoutesAction
->shouldClone(fn (Route $route) => false)
->handle();
// Skip cloning the 'home' route
$cloneRoutesAction->cloneUsing($routeName, function (Route $route) {
return;
})->handle();
// Expect route count to stay the same because the 'home' route cloning gets skipped
// Expect route count to stay the same because cloning essentially gets turned off
expect($initialRouteCount)->toEqual($currentRouteCount());
// Modify the 'home' route cloning so that a different route is cloned
$cloneRoutesAction->cloneUsing($routeName, function (Route $route) {
RouteFacade::get('/cloned-route', fn () => true)->name('new.home');
})->handle();
// Only the 'home' route should be cloned
$cloneRoutesAction
->shouldClone(fn (Route $route) => $route->getName() === 'home')
->handle();
expect($currentRouteCount())->toEqual($initialRouteCount + 1);
});
test('cloning of specific routes can get skipped', function () {
RouteFacade::get('/home', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')->middleware('universal')->name($routeName = 'home');
test('custom callbacks can be used for customizing the creation of the cloned routes', function () {
RouteFacade::get('/foo', fn () => true)->name('foo')->middleware(['clone']);
RouteFacade::get('/bar', fn () => true)->name('bar')->middleware(['clone']);
/** @var CloneRoutesAsTenant $cloneRoutesAction */
$cloneRoutesAction = app(CloneRoutesAsTenant::class);
$cloneRoutesAction
->cloneUsing(function (Route $route) {
RouteFacade::get('/cloned/' . $route->uri(), fn () => 'cloned route')->name('cloned.' . $route->getName());
})->handle();
expect(route('cloned.foo', absolute: false))->toBe('/cloned/foo');
expect(route('cloned.bar', absolute: false))->toBe('/cloned/bar');
pest()->get(route('cloned.foo'))->assertSee('cloned route');
pest()->get(route('cloned.bar'))->assertSee('cloned route');
});
test('the clone action can clone specific routes either using name or route instance', function (bool $cloneRouteByName) {
RouteFacade::get('/foo', fn () => true)->name('foo');
$barRoute = RouteFacade::get('/bar', fn () => true)->name('bar');
RouteFacade::get('/baz', fn () => true)->name('baz');
$currentRouteCount = fn () => count(RouteFacade::getRoutes()->get());
$initialRouteCount = $currentRouteCount();
// Skip cloning the 'home' route
$cloneRoutesAction->skipRoute($routeName);
/** @var CloneRoutesAsTenant $cloneRoutesAction */
$cloneRoutesAction = app(CloneRoutesAsTenant::class);
$cloneRoutesAction->handle();
// A route instance or a route name can be passed to cloneRoute()
$cloneRoutesAction->cloneRoute($cloneRouteByName ? $barRoute->getName() : $barRoute)->handle();
// Expect route count to stay the same because the 'home' route cloning gets skipped
expect($initialRouteCount)->toEqual($currentRouteCount());
});
// Exactly one route should be cloned
expect($currentRouteCount())->toEqual($initialRouteCount + 1);
test('routes except nonuniversal routes with path id mw are given the tenant flag after cloning', function (array $routeMiddleware, array $globalMiddleware) {
foreach ($globalMiddleware as $middleware) {
if ($middleware === 'universal') {
config(['tenancy.default_route_mode' => RouteMode::UNIVERSAL]);
} else {
app(Kernel::class)->pushMiddleware($middleware);
}
}
$route = RouteFacade::get('/home', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')
->middleware($routeMiddleware)
->name($routeName = 'home');
app(CloneRoutesAsTenant::class)->handle();
$clonedRoute = RouteFacade::getRoutes()->getByName('tenant.' . $routeName);
// Non-universal routes with identification middleware are already considered tenant, so they don't get the tenant flag
if (! tenancy()->routeIsUniversal($route) && tenancy()->routeHasIdentificationMiddleware($clonedRoute)) {
expect($clonedRoute->middleware())->not()->toContain('tenant');
} else {
expect($clonedRoute->middleware())->toContain('tenant');
}
})->with('path identification types');
test('routes with the clone flag get cloned without making the routes universal', function ($identificationMiddleware) {
config(['tenancy.identification.path_identification_middleware' => [$identificationMiddleware]]);
RouteFacade::get('/home', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')
->middleware(['clone', $identificationMiddleware])
->name($routeName = 'home');
$tenant = Tenant::create();
app(CloneRoutesAsTenant::class)->handle();
$clonedRoute = RouteFacade::getRoutes()->getByName('tenant.' . $routeName);
expect(array_values($clonedRoute->middleware()))->toEqualCanonicalizing(['tenant', $identificationMiddleware]);
// The original route is not accessible
pest()->get(route($routeName))->assertServerError();
pest()->get(route($routeName, ['tenant' => $tenant]))->assertServerError();
// The cloned route is a tenant route
pest()->get(route('tenant.' . $routeName, ['tenant' => $tenant]))->assertSee('Tenancy initialized.');
})->with([InitializeTenancyByPath::class, CustomInitializeTenancyByPath::class]);
expect(RouteFacade::getRoutes()->getByName('tenant.bar'))->not()->toBeNull();
})->with([
true,
false,
]);
test('the clone action prefixes already prefixed routes correctly', function () {
$routes = [
RouteFacade::get('/home', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')
->middleware(['universal', InitializeTenancyByPath::class])
RouteFacade::get('/home', fn () => true)
->middleware(['clone'])
->name('home')
->prefix('prefix'),
RouteFacade::get('/leadingAndTrailingSlash', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')
->middleware(['universal', InitializeTenancyByPath::class])
RouteFacade::get('/leadingAndTrailingSlash', fn () => true)
->middleware(['clone'])
->name('leadingAndTrailingSlash')
->prefix('/prefix/'),
RouteFacade::get('/leadingSlash', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')
->middleware(['universal', InitializeTenancyByPath::class])
RouteFacade::get('/leadingSlash', fn () => true)
->middleware(['clone'])
->name('leadingSlash')
->prefix('/prefix'),
RouteFacade::get('/trailingSlash', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')
->middleware(['universal', InitializeTenancyByPath::class])
RouteFacade::get('/trailingSlash', fn () => true)
->middleware(['clone'])
->name('trailingSlash')
->prefix('prefix/'),
];
@ -285,14 +211,14 @@ test('the clone action prefixes already prefixed routes correctly', function ()
expect($clonedRouteUrl)
// Original prefix does not occur in the cloned route's URL
->not()->toContain("prefix/{$tenant->getTenantKey()}/prefix")
->not()->toContain("prefix/{$tenant->id}/prefix")
->not()->toContain("//prefix")
->not()->toContain("prefix//")
// Route is prefixed correctly
->toBe("http://localhost/prefix/{$tenant->getTenantKey()}/{$routes[$key]->getName()}");
// Instead, the route is prefixed correctly
->toBe("http://localhost/prefix/{$tenant->id}/{$routes[$key]->getName()}");
// The cloned route is accessible
pest()->get($clonedRouteUrl)->assertSee('Tenancy initialized.');
pest()->get($clonedRouteUrl)->assertOk();
}
});
@ -300,12 +226,12 @@ test('clone action trims trailing slashes from prefixes given to nested route gr
RouteFacade::prefix('prefix')->group(function () {
RouteFacade::prefix('')->group(function () {
// This issue seems to only happen when there's a group with a prefix, then a group with an empty prefix, and then a / route
RouteFacade::get('/', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')
->middleware(['universal', InitializeTenancyByPath::class])
RouteFacade::get('/', fn () => true)
->middleware(['clone'])
->name('landing');
RouteFacade::get('/home', fn () => tenant() ? 'Tenancy initialized.' : 'Tenancy not initialized.')
->middleware(['universal', InitializeTenancyByPath::class])
RouteFacade::get('/home', fn () => true)
->middleware(['clone'])
->name('home');
});
});
@ -315,35 +241,99 @@ test('clone action trims trailing slashes from prefixes given to nested route gr
$clonedLandingUrl = route('tenant.landing', ['tenant' => $tenant = Tenant::create()]);
$clonedHomeRouteUrl = route('tenant.home', ['tenant' => $tenant]);
$landingRoute = RouteFacade::getRoutes()->getByName('tenant.landing');
$homeRoute = RouteFacade::getRoutes()->getByName('tenant.home');
expect($landingRoute->uri())->toBe('prefix/{tenant}');
expect($homeRoute->uri())->toBe('prefix/{tenant}/home');
expect($clonedLandingUrl)
->not()->toContain("prefix//")
->toBe("http://localhost/prefix/{$tenant->getTenantKey()}");
->toBe("http://localhost/prefix/{$tenant->id}");
expect($clonedHomeRouteUrl)
->not()->toContain("prefix//")
->toBe("http://localhost/prefix/{$tenant->getTenantKey()}/home");
->toBe("http://localhost/prefix/{$tenant->id}/home");
});
class CustomInitializeTenancyByPath extends InitializeTenancyByPath
{
test('tenant routes are ignored from cloning and clone middleware in groups causes no issues', function () {
// Should NOT be cloned, already has tenant parameter
RouteFacade::get("/{tenant}/route-with-tenant-parameter", fn () => true)
->middleware(['clone'])
->name("tenant.route-with-tenant-parameter");
}
// Should NOT be cloned, already has tenant name prefix
RouteFacade::get("/route-with-tenant-name-prefix", fn () => true)
->middleware(['clone'])
->name("tenant.route-with-tenant-name-prefix");
dataset('path identification types', [
'kernel identification' => [
['universal'], // Route middleware
[InitializeTenancyByPath::class], // Global Global middleware
],
'route-level identification' => [
['universal', InitializeTenancyByPath::class], // Route middleware
[], // Global middleware
],
'kernel identification + defaulting to universal routes' => [
[], // Route middleware
['universal', InitializeTenancyByPath::class], // Global middleware
],
'route-level identification + defaulting to universal routes' => [
[InitializeTenancyByPath::class], // Route middleware
['universal'], // Global middleware
],
]);
// Should NOT be cloned, already has tenant parameter + 'clone' middleware in group
// 'clone' MW in groups won't be removed (this doesn't cause any issues)
RouteFacade::middlewareGroup('group', ['auth', 'clone']);
RouteFacade::get("/{tenant}/route-with-clone-in-mw-group", fn () => true)
->middleware('group')
->name("tenant.route-with-clone-in-mw-group");
// SHOULD be cloned (has clone middleware)
RouteFacade::get('/foo', fn () => true)
->middleware(['clone'])
->name('foo');
// SHOULD be cloned (has nested clone middleware)
RouteFacade::get('/bar', fn () => true)
->middleware(['group'])
->name('bar');
$cloneAction = app(CloneRoutesAsTenant::class);
$initialRouteCount = count(RouteFacade::getRoutes()->get());
// Run clone action multiple times
$cloneAction->handle();
$firstRunCount = count(RouteFacade::getRoutes()->get());
$cloneAction->handle();
$secondRunCount = count(RouteFacade::getRoutes()->get());
$cloneAction->handle();
$thirdRunCount = count(RouteFacade::getRoutes()->get());
// Two route should have been cloned, and only once
expect($firstRunCount)->toBe($initialRouteCount + 2);
// No new routes on subsequent runs
expect($secondRunCount)->toBe($firstRunCount);
expect($thirdRunCount)->toBe($firstRunCount);
// Verify the correct routes were cloned
expect(RouteFacade::getRoutes()->getByName('tenant.foo'))->toBeInstanceOf(Route::class);
expect(RouteFacade::getRoutes()->getByName('tenant.bar'))->toBeInstanceOf(Route::class);
// Tenant routes were not duplicated
$allRouteNames = collect(RouteFacade::getRoutes()->get())->map->getName()->filter();
expect($allRouteNames->filter(fn($name) => str_contains($name, 'route-with-tenant-parameter'))->count())->toBe(1);
expect($allRouteNames->filter(fn($name) => str_contains($name, 'route-with-tenant-name-prefix'))->count())->toBe(1);
expect($allRouteNames->filter(fn($name) => str_contains($name, 'route-with-clone-in-mw-group'))->count())->toBe(1);
});
test('clone action can be used fluently', function() {
RouteFacade::get('/foo', fn () => true)->name('foo')->middleware('clone');
RouteFacade::get('/bar', fn () => true)->name('bar')->middleware('universal');
$cloneAction = app(CloneRoutesAsTenant::class);
// Clone foo route
$cloneAction->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())
->toContain('tenant.foo');
// Clone bar route
$cloneAction->cloneRoutesWithMiddleware(['universal'])->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())
->toContain('tenant.foo', 'tenant.bar');
RouteFacade::get('/baz', fn () => true)->name('baz');
// Clone baz route
$cloneAction->cloneRoute('baz')->handle();
expect(collect(RouteFacade::getRoutes()->get())->map->getName())
->toContain('tenant.foo', 'tenant.bar', 'tenant.baz');
});

View file

@ -3,10 +3,9 @@
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Database\Concerns\HasDomains;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain;
use Stancl\Tenancy\Database\Models;
use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
Route::group([

View file

@ -26,6 +26,7 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
if (file_exists($schemaPath = 'tests/Etc/tenant-schema-test.dump')) {

View file

@ -24,6 +24,7 @@ use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMySQLData
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLSchemaManager;
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLDatabaseManager;
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
config([

View file

@ -10,6 +10,7 @@ use Stancl\Tenancy\Exceptions\DomainOccupiedByOtherTenantException;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Resolvers\DomainTenantResolver;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
InitializeTenancyByDomain::$onFail = null;

View file

@ -24,6 +24,7 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByOriginHeader;
use Stancl\Tenancy\Tests\Etc\EarlyIdentification\ControllerWithMiddleware;
use Stancl\Tenancy\Tests\Etc\EarlyIdentification\ControllerWithRouteMiddleware;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
config()->set([
@ -343,9 +344,9 @@ test('the tenant parameter is only removed from tenant routes when using path id
->middleware('tenant')
->name('tenant-route');
RouteFacade::get($pathIdentification ? '/universal-route' : '/universal-route/{tenant?}', [ControllerWithMiddleware::class, 'routeHasTenantParameter'])
->middleware('universal')
->name('universal-route');
RouteFacade::get($pathIdentification ? '/cloned-route' : '/cloned-route/{tenant?}', [ControllerWithMiddleware::class, 'routeHasTenantParameter'])
->middleware('clone')
->name('cloned-route');
/** @var CloneRoutesAsTenant */
$cloneRoutesAction = app(CloneRoutesAsTenant::class);
@ -363,8 +364,8 @@ test('the tenant parameter is only removed from tenant routes when using path id
$response = pest()->get($tenantKey . '/tenant-route')->assertOk();
expect((bool) $response->getContent())->toBeFalse();
// The tenant parameter gets removed from the cloned universal route
$response = pest()->get($tenantKey . '/universal-route')->assertOk();
// The tenant parameter gets removed from the cloned route
$response = pest()->get($tenantKey . '/cloned-route')->assertOk();
expect((bool) $response->getContent())->toBeFalse();
} else {
// Tenant parameter is not removed from tenant routes using other kernel identification MW
@ -373,12 +374,12 @@ test('the tenant parameter is only removed from tenant routes when using path id
$response = pest()->get("http://{$domain}/{$tenantKey}/tenant-route")->assertOk();
expect((bool) $response->getContent())->toBeTrue();
// The tenant parameter does not get removed from the universal route when accessing it through the central domain
$response = pest()->get("http://localhost/universal-route/$tenantKey")->assertOk();
// The tenant parameter does not get removed from the cloned route when accessing it through the central domain
$response = pest()->get("http://localhost/cloned-route/$tenantKey")->assertOk();
expect((bool) $response->getContent())->toBeTrue();
// The tenant parameter gets removed from the universal route when accessing it through the tenant domain
$response = pest()->get("http://{$domain}/universal-route")->assertOk();
// The tenant parameter gets removed from the cloned route when accessing it through the tenant domain
$response = pest()->get("http://{$domain}/cloned-route")->assertOk();
expect((bool) $response->getContent())->toBeFalse();
}
} else {

View file

@ -19,6 +19,7 @@ use Stancl\Tenancy\Events\BootstrappingTenancy;
use Stancl\Tenancy\Listeners\QueueableListener;
use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
FooListener::$shouldQueue = false;

View file

@ -18,14 +18,9 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
test('sqlite ATTACH statements can be blocked', function (bool $disallow) {
try {
readlink(base_path('vendor'));
} catch (\Throwable) {
symlink(base_path('vendor'), '/var/www/html/vendor');
}
if (php_uname('m') == 'aarch64') {
// Escape testbench prison. Can't hardcode /var/www/html/extensions/... here
// since GHA doesn't mount the filesystem on the container's workdir

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Features\CrossDomainRedirect;
use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
test('tenant redirect macro replaces only the hostname', function () {
config([

View file

@ -9,6 +9,7 @@ use Stancl\Tenancy\Features\TenantConfig;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
afterEach(function () {
TenantConfig::$storageToConfigMap = [];

View file

@ -10,6 +10,8 @@ use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\MailConfigBootstrapper;
use function Stancl\Tenancy\Tests\pest;
use function Stancl\Tenancy\Tests\withTenantDatabases;
beforeEach(function() {
config(['mail.default' => 'smtp']);

View file

@ -8,6 +8,7 @@ use Stancl\Tenancy\Database\Concerns\MaintenanceMode;
use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Middleware\CheckTenantForMaintenanceMode;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use function Stancl\Tenancy\Tests\pest;
use Stancl\Tenancy\Tests\Etc\Tenant;
beforeEach(function () {

View file

@ -11,7 +11,8 @@ use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Listeners\CreateTenantConnection;
use Stancl\Tenancy\Listeners\UseCentralConnection;
use Stancl\Tenancy\Listeners\UseTenantConnection;
use \Stancl\Tenancy\Tests\Etc\Tenant;
use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
test('manual tenancy initialization works', function () {
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Middleware\InitializeTenancyByOriginHeader;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
InitializeTenancyByOriginHeader::$onFail = null;
@ -35,6 +36,12 @@ test('origin identification works', function () {
->withHeader('Origin', 'foo.localhost')
->post('home')
->assertSee($tenant->id);
// Test with a full URL - not just a hostname
pest()
->withHeader('Origin', 'https://foo.localhost')
->post('home')
->assertSee($tenant->id);
});
test('tenant routes are not accessible on central domains while using origin identification', function () {

View file

@ -3,15 +3,30 @@
declare(strict_types=1);
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Database\QueryException;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Event;
use Stancl\Tenancy\Exceptions\RouteIsMissingTenantParameterException;
use Stancl\Tenancy\Exceptions\TenantColumnNotWhitelistedException;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByPathException;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Resolvers\PathTenantResolver;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Jobs\MigrateDatabase;
use Stancl\JobPipeline\JobPipeline;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Tests\Etc\User;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Events\TenancyEnded;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
// Make sure the tenant parameter is set to 'tenant'
@ -34,6 +49,11 @@ beforeEach(function () {
});
});
afterEach(function () {
InitializeTenancyByPath::$onFail = null;
Tenant::$extraCustomColumns = [];
});
test('tenant can be identified by path', function () {
Tenant::create([
'id' => 'acme',
@ -149,6 +169,7 @@ test('central route can have a parameter with the same name as the tenant parame
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.tenant_parameter_name' => 'team']);
$tenantKey = Tenant::create()->getTenantKey();
// The route is flagged as central (while using kernel identification) so the {team} parameter should not be used for tenancy initialization
Route::get('/central/route/{team}/{a}/{b}', function ($team, $a, $b) {
return "$a + $b + $team";
})->middleware('central')->name('central-route');
@ -184,8 +205,6 @@ test('the tenant model column can be customized in the config', function () {
$this->withoutExceptionHandling();
pest()->get('/acme/foo')->assertSee($tenant->getTenantKey());
expect(fn () => pest()->get($tenant->id . '/foo'))->toThrow(TenantCouldNotBeIdentifiedByPathException::class);
Tenant::$extraCustomColumns = []; // static property reset
});
test('the tenant model column can be customized in the route definition', function () {
@ -217,8 +236,6 @@ test('the tenant model column can be customized in the route definition', functi
// Binding field defined
pest()->get('/acme/bar')->assertSee($tenant->getTenantKey());
expect(fn () => pest()->get($tenant->id . '/bar'))->toThrow(TenantCouldNotBeIdentifiedByPathException::class);
Tenant::$extraCustomColumns = []; // static property reset
});
test('any extra model column needs to be whitelisted', function () {
@ -242,6 +259,39 @@ test('any extra model column needs to be whitelisted', function () {
// After whitelisting the column it works
config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.allowed_extra_model_columns' => ['slug']]);
pest()->get('/acme/foo')->assertSee($tenant->getTenantKey());
Tenant::$extraCustomColumns = []; // static property reset
});
test('route model binding works with path identification', function() {
config(['tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class]]);
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenantCreated::class, JobPipeline::make([
CreateDatabase::class, MigrateDatabase::class,
])->send(fn (TenantCreated $event) => $event->tenant)->toListener());
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
$tenant = Tenant::create();
$this->withoutExceptionHandling();
// Importantly, the route must have the 'web' middleware group, or SubstituteBindings directly
Route::get('/{tenant}/foo/{user}', fn (User $user) => $user->name)->middleware([InitializeTenancyByPath::class, 'web']);
Route::get('/{tenant}/bar/{user}', fn (User $user) => $user->name)->middleware([InitializeTenancyByPath::class, SubstituteBindings::class]);
$user = $tenant->run(fn () => User::create(['name' => 'John Doe', 'email' => 'john@doe.com', 'password' => 'foobar']));
pest()->get("/{$tenant->id}/foo/{$user->id}")->assertSee("John Doe");
tenancy()->end();
pest()->get("/{$tenant->id}/bar/{$user->id}")->assertSee("John Doe");
tenancy()->end();
// If SubstituteBindings comes BEFORE tenancy middleware and middleware priority is not set, route model binding is NOT expected to work correctly
// Since SubstituteBindings runs first, it tries to query the central database instead of the tenant database (which fails with a QueryException in this case)
Route::get('/{tenant}/baz/{user}', fn (User $user) => $user->name ?: 'No user')->middleware([SubstituteBindings::class, InitializeTenancyByPath::class]);
expect(fn () => pest()->get("/{$tenant->id}/baz/{$user->id}"))->toThrow(QueryException::class);
tenancy()->end();
// If SubstituteBindings is NOT USED AT ALL, we simply get an empty User instance
Route::get('/{tenant}/xyz/{user}', fn (User $user) => $user->name ?: 'No user')->middleware([InitializeTenancyByPath::class]);
pest()->get("/{$tenant->id}/xyz/{$user->id}")->assertSee('No user');
});

View file

@ -11,6 +11,7 @@ use Stancl\Tenancy\Events\PendingTenantCreated;
use Stancl\Tenancy\Events\PendingTenantPulled;
use Stancl\Tenancy\Events\PullingPendingTenant;
use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
test('tenants are correctly identified as pending', function (){
Tenant::createPending();

View file

@ -1,5 +1,7 @@
<?php
namespace Stancl\Tenancy\Tests;
use Stancl\Tenancy\Tests\TestCase;
use Stancl\JobPipeline\JobPipeline;
use Illuminate\Support\Facades\Event;
@ -8,14 +10,14 @@ use Stancl\Tenancy\Events\TenantCreated;
uses(TestCase::class)->in(__DIR__);
function pest(): TestCase
{
return Pest\TestSuite::getInstance()->test;
}
function withTenantDatabases()
{
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
}
function pest(): TestCase
{
return \Pest\TestSuite::getInstance()->test;
}

View file

@ -11,6 +11,7 @@ use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain;
use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain;
use Stancl\Tenancy\Tests\Etc\EarlyIdentification\ControllerWithMiddleware;
use function Stancl\Tenancy\Tests\pest;
test('correct routes are accessible in route-level identification', function (RouteMode $defaultRouteMode) {
config()->set([

View file

@ -22,16 +22,14 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\PersistentQueueTenancyBootstrapper;
use Stancl\Tenancy\Listeners\QueueableListener;
use function Stancl\Tenancy\Tests\pest;
use function Stancl\Tenancy\Tests\withTenantDatabases;
beforeEach(function () {
$this->mockConsoleOutput = false;
config([
'tenancy.bootstrappers' => [
QueueTenancyBootstrapper::class,
DatabaseTenancyBootstrapper::class,
],
'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class],
'queue.default' => 'redis',
]);
@ -45,7 +43,22 @@ afterEach(function () {
pest()->valuestore->flush();
});
test('tenant id is passed to tenant queues', function () {
dataset('queue_bootstrappers', [
QueueTenancyBootstrapper::class,
PersistentQueueTenancyBootstrapper::class,
]);
function withQueueBootstrapper(string $class) {
config(['tenancy.bootstrappers' => [
DatabaseTenancyBootstrapper::class,
$class,
]]);
$class::__constructStatic(app());
}
test('tenant id is passed to tenant queues', function (string $bootstrapper) {
withQueueBootstrapper($bootstrapper);
withTenantDatabases();
config(['queue.default' => 'sync']);
@ -61,9 +74,10 @@ test('tenant id is passed to tenant queues', function () {
Event::assertDispatched(JobProcessing::class, function ($event) {
return $event->job->payload()['tenant_id'] === tenant('id');
});
});
})->with('queue_bootstrappers');
test('tenant id is not passed to central queues', function () {
test('tenant id is not passed to central queues', function (string $bootstrapper) {
withQueueBootstrapper($bootstrapper);
withTenantDatabases();
$tenant = Tenant::create();
@ -82,9 +96,10 @@ test('tenant id is not passed to central queues', function () {
Event::assertDispatched(JobProcessing::class, function ($event) {
return ! isset($event->job->payload()['tenant_id']);
});
});
})->with('queue_bootstrappers');
test('tenancy is initialized inside queues', function (bool $shouldEndTenancy) {
test('tenancy is initialized inside queues', function (bool $shouldEndTenancy, string $bootstrapper) {
withQueueBootstrapper($bootstrapper);
withTenantDatabases();
withFailedJobs();
@ -117,7 +132,7 @@ test('tenancy is initialized inside queues', function (bool $shouldEndTenancy) {
$tenant->run(function () use ($user) {
expect($user->fresh()->name)->toBe('Bar');
});
})->with([true, false]);
})->with([true, false])->with('queue_bootstrappers');
test('changing the shouldQueue static property in parent class affects child classes unless the property is redefined', function () {
// Parent $shouldQueue is true
@ -142,7 +157,8 @@ test('changing the shouldQueue static property in parent class affects child cla
expect(app(ShouldNotQueueListener::class)->shouldQueue(new stdClass()))->toBeFalse();
});
test('tenancy is initialized when retrying jobs', function (bool $shouldEndTenancy) {
test('tenancy is initialized when retrying jobs', function (bool $shouldEndTenancy, string $bootstrapper) {
withQueueBootstrapper($bootstrapper);
withFailedJobs();
withTenantDatabases();
@ -189,9 +205,10 @@ test('tenancy is initialized when retrying jobs', function (bool $shouldEndTenan
$tenant->run(function () use ($user) {
expect($user->fresh()->name)->toBe('Bar');
});
})->with([true, false]);
})->with([true, false])->with('queue_bootstrappers');
test('the tenant used by the job doesnt change when the current tenant changes', function () {
test('the tenant used by the job doesnt change when the current tenant changes', function (string $bootstrapper) {
withQueueBootstrapper($bootstrapper);
withTenantDatabases();
$tenant1 = Tenant::create();
@ -208,26 +225,11 @@ test('the tenant used by the job doesnt change when the current tenant changes',
pest()->artisan('queue:work --once');
expect(pest()->valuestore->get('tenant_id'))->toBe('The current tenant id is: ' . $tenant1->getTenantKey());
});
test('tenant connections do not persist after tenant jobs get processed', function() {
withTenantDatabases();
$tenant = Tenant::create();
tenancy()->initialize($tenant);
dispatch(new TestJob(pest()->valuestore));
tenancy()->end();
pest()->artisan('queue:work --once');
expect(collect(DB::select('SHOW FULL PROCESSLIST'))->pluck('db'))->not()->toContain($tenant->database()->getName());
});
})->with('queue_bootstrappers');
// Regression test for #1277
test('dispatching a job from a tenant run arrow function dispatches it immediately', function () {
test('dispatching a job from a tenant run arrow function dispatches it immediately', function (string $bootstrapper) {
withQueueBootstrapper($bootstrapper);
withTenantDatabases();
$tenant = Tenant::create();
@ -241,7 +243,7 @@ test('dispatching a job from a tenant run arrow function dispatches it immediate
expect(tenant())->toBe(null);
expect(pest()->valuestore->get('tenant_id'))->toBe('The current tenant id is: ' . $tenant->getTenantKey());
});
})->with('queue_bootstrappers');
function createValueStore(): void
{

View file

@ -17,8 +17,10 @@ use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies;
use Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager;
use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager;
use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
CreateUserWithRLSPolicies::$forceRls = true;
TraitRLSManager::$excludedModels = [Article::class];
TraitRLSManager::$modelDirectories = [__DIR__ . '/Etc'];
@ -78,6 +80,10 @@ beforeEach(function () {
});
});
afterEach(function () {
CreateUserWithRLSPolicies::$forceRls = true;
});
// Regression test for https://github.com/archtechx/tenancy/pull/1280
test('rls command doesnt fail when a view is in the database', function (string $manager) {
DB::statement("
@ -183,7 +189,9 @@ test('rls command recreates policies if the force option is passed', function (s
TraitRLSManager::class,
]);
test('queries will stop working when the tenant session variable is not set', function(string $manager) {
test('queries will stop working when the tenant session variable is not set', function(string $manager, bool $forceRls) {
CreateUserWithRLSPolicies::$forceRls = $forceRls;
config(['tenancy.rls.manager' => $manager]);
$sessionVariableName = config('tenancy.rls.session_variable_name');
@ -215,7 +223,4 @@ test('queries will stop working when the tenant session variable is not set', fu
INSERT INTO posts (text, tenant_id, author_id)
VALUES ('post2', ?, ?)
SQL, [$tenant->id, $authorId]))->toThrow(QueryException::class);
})->with([
TableRLSManager::class,
TraitRLSManager::class,
]);
})->with([TableRLSManager::class, TraitRLSManager::class])->with([true, false]);

View file

@ -15,12 +15,16 @@ use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Schema\ForeignIdColumnDefinition;
use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies;
use Stancl\Tenancy\RLS\PolicyManagers\TableRLSManager;
use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper;
use Stancl\Tenancy\Database\Exceptions\RecursiveRelationshipException;
use function Stancl\Tenancy\Tests\pest;
use Stancl\Tenancy\RLS\Exceptions\RLSCommentConstraintException;
beforeEach(function () {
CreateUserWithRLSPolicies::$forceRls = true;
TableRLSManager::$scopeByDefault = true;
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
@ -106,6 +110,10 @@ beforeEach(function () {
});
});
afterEach(function () {
CreateUserWithRLSPolicies::$forceRls = true;
});
test('correct rls policies get created with the correct hash using table manager', function() {
$manager = app(config('tenancy.rls.manager'));
@ -158,13 +166,22 @@ test('correct rls policies get created with the correct hash using table manager
}
});
test('queries are correctly scoped using RLS', function() {
test('queries are correctly scoped using RLS', function (
bool $forceRls,
bool $commentConstraint,
) {
CreateUserWithRLSPolicies::$forceRls = $forceRls;
// 3-levels deep relationship
Schema::create('notes', function (Blueprint $table) {
Schema::create('notes', function (Blueprint $table) use ($commentConstraint) {
$table->id();
$table->string('text')->default('foo');
// no rls comment needed, $scopeByDefault is set to true
$table->foreignId('comment_id')->onUpdate('cascade')->onDelete('cascade')->constrained('comments');
if ($commentConstraint) {
$table->foreignId('comment_id')->comment('rls comments.id');
} else {
$table->foreignId('comment_id')->constrained('comments');
}
$table->timestamps();
});
@ -180,9 +197,9 @@ test('queries are correctly scoped using RLS', function() {
$post1 = Post::create([
'text' => 'first post',
'tenant_id' => $tenant1->getTenantKey(),
'author_id' => Author::create(['name' => 'author1', 'tenant_id' => $tenant1->getTenantKey()])->id,
'category_id' => Category::create(['name' => 'category1', 'tenant_id' => $tenant1->getTenantKey()])->id,
'tenant_id' => $tenant1->id,
'author_id' => Author::create(['name' => 'author1', 'tenant_id' => $tenant1->id])->id,
'category_id' => Category::create(['name' => 'category1', 'tenant_id' => $tenant1->id])->id,
]);
$post1Comment = Comment::create(['text' => 'first comment', 'post_id' => $post1->id]);
@ -193,9 +210,9 @@ test('queries are correctly scoped using RLS', function() {
$post2 = Post::create([
'text' => 'second post',
'tenant_id' => $tenant2->getTenantKey(),
'author_id' => Author::create(['name' => 'author2', 'tenant_id' => $tenant2->getTenantKey()])->id,
'category_id' => Category::create(['name' => 'category2', 'tenant_id' => $tenant2->getTenantKey()])->id
'tenant_id' => $tenant2->id,
'author_id' => Author::create(['name' => 'author2', 'tenant_id' => $tenant2->id])->id,
'category_id' => Category::create(['name' => 'category2', 'tenant_id' => $tenant2->id])->id
]);
$post2Comment = Comment::create(['text' => 'second comment', 'post_id' => $post2->id]);
@ -311,7 +328,7 @@ test('queries are correctly scoped using RLS', function() {
expect(Note::count())->toBe(1);
// Directly inserting records to other tenant's tables should fail (insufficient privilege error new row violates row-level security policy)
expect(fn () => DB::statement("INSERT INTO posts (text, author_id, category_id, tenant_id) VALUES ('third post', 1, 1, '{$tenant1->getTenantKey()}')"))
expect(fn () => DB::statement("INSERT INTO posts (text, author_id, category_id, tenant_id) VALUES ('third post', 1, 1, '{$tenant1->id}')"))
->toThrow(QueryException::class);
expect(fn () => DB::statement("INSERT INTO comments (text, post_id) VALUES ('third comment', {$post1->id})"))
@ -319,95 +336,90 @@ test('queries are correctly scoped using RLS', function() {
expect(fn () => DB::statement("INSERT INTO notes (text, comment_id) VALUES ('baz', {$post1Comment->id})"))
->toThrow(QueryException::class);
});
})->with(['forceRls is true' => true, 'forceRls is false' => false])
->with(['comment constraint' => true, 'foreign key constraint' => false]);
test('table rls manager generates relationship trees with tables related to the tenants table', function (bool $scopeByDefault) {
test('table rls manager generates shortest paths that lead to the tenants table correctly', function (bool $scopeByDefault) {
TableRLSManager::$scopeByDefault = $scopeByDefault;
// Only related to the tenants table through nullable columns (directly through tenant_id and indirectly through post_id)
Schema::create('ratings', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->nullable()->comment('rls')->constrained();
// No 'rls' comment should get excluded from path generation when using explicit scoping
$table->string('tenant_id')->nullable();
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
$table->timestamps();
});
/** @var TableRLSManager $manager */
$manager = app(TableRLSManager::class);
$expectedTrees = [
$expectedShortestPaths = [
'authors' => [
// Directly related to tenants
'tenant_id' => [
[
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
'nullable' => false,
]
],
],
],
'comments' => [
// Tree starting from the post_id foreign key
'post_id' => [
[
[
'foreignKey' => 'post_id',
'foreignTable' => 'posts',
'foreignId' => 'id',
'nullable' => false,
],
[
'foreignKey' => 'author_id',
'foreignTable' => 'authors',
'foreignId' => 'id',
'nullable' => false,
],
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
'nullable' => false,
],
],
[
[
'foreignKey' => 'post_id',
'foreignTable' => 'posts',
'foreignId' => 'id',
'nullable' => false,
],
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
'nullable' => true,
],
],
[
'localColumn' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignColumn' => 'id',
],
],
'posts' => [
// Category tree gets excluded because the category table is related to the tenant table
// only through a column with the 'no-rls' comment
'author_id' => [
[
[
'foreignKey' => 'author_id',
'foreignTable' => 'authors',
'foreignId' => 'id',
'nullable' => false,
],
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
'nullable' => false,
]
],
[
'localColumn' => 'author_id',
'foreignTable' => 'authors',
'foreignColumn' => 'id',
],
'tenant_id' => [
[
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
'nullable' => true,
]
]
[
'localColumn' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignColumn' => 'id',
],
],
'comments' => [
[
'localColumn' => 'post_id',
'foreignTable' => 'posts',
'foreignColumn' => 'id',
],
[
'localColumn' => 'author_id',
'foreignTable' => 'authors',
'foreignColumn' => 'id',
],
[
'localColumn' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignColumn' => 'id',
],
],
// When scoping by default is enabled (implicit scoping),
// the shortest path from the ratings table leads directly through tenant_id.
// When scoping by default is disabled (explicit scoping),
// the shortest path leads through post_id.
'ratings' => $scopeByDefault ? [
[
'localColumn' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignColumn' => 'id',
],
] : [
[
'localColumn' => 'post_id',
'foreignTable' => 'posts',
'foreignColumn' => 'id',
],
[
'localColumn' => 'author_id',
'foreignTable' => 'authors',
'foreignColumn' => 'id',
],
[
'localColumn' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignColumn' => 'id',
],
],
// Articles table is ignored because it's not related to the tenant table in any way
@ -415,124 +427,156 @@ test('table rls manager generates relationship trees with tables related to the
// Categories table is ignored because of the 'no-rls' comment on the tenant_id column
];
expect($manager->generateTrees())->toEqual($expectedTrees);
expect($manager->shortestPaths())->toEqual($expectedShortestPaths);
$expectedShortestPaths = [
'authors' => [
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
],
// Add non-nullable comment_id comment constraint
Schema::table('ratings', function (Blueprint $table) {
$table->string('comment_id')->comment('rls comments.id');
// Nullable constraint with a non-RLS comment.
// Skipped when scopeByDefault is false,
// not ignored when scopeByDefault is true, but still,
// not preferred since comment_id is valid and non-nullable.
$table->foreignId('author_id')->nullable()->comment('random comment')->constrained('authors');
});
// Non-nullable paths are preferred over nullable paths
$expectedShortestPaths['ratings'] = [
[
'localColumn' => 'comment_id',
'foreignTable' => 'comments',
'foreignColumn' => 'id',
],
'posts' => [
[
'foreignKey' => 'author_id',
'foreignTable' => 'authors',
'foreignId' => 'id',
],
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
],
[
'localColumn' => 'post_id',
'foreignTable' => 'posts',
'foreignColumn' => 'id',
],
'comments' => [
[
'foreignKey' => 'post_id',
'foreignTable' => 'posts',
'foreignId' => 'id',
],
[
'foreignKey' => 'author_id',
'foreignTable' => 'authors',
'foreignId' => 'id',
],
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
],
[
// Importantly, the best path goes through authors
// since ratings -> posts is nullable, as well as
// posts -> tenants directly (without going through
// authors first).
'localColumn' => 'author_id',
'foreignTable' => 'authors',
'foreignColumn' => 'id',
],
[
'localColumn' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignColumn' => 'id',
],
];
// The shortest paths should now include a path for the ratings table
// that leads through comment_id instead of tenant_id since comment_id
// is not nullable (and therefore preferable) unlike path_id or tenant_id
// even if the latter paths are shorter.
expect($manager->shortestPaths())->toEqual($expectedShortestPaths);
})->with([true, false]);
// Only related to the tenants table through nullable columns tenant_id and indirectly through post_id
Schema::create('ratings', function (Blueprint $table) {
// https://github.com/archtechx/tenancy/pull/1293
test('forceRls prevents even the table owner from querying his own tables if he doesnt have a BYPASSRLS permission', function (bool $forceRls) {
CreateUserWithRLSPolicies::$forceRls = $forceRls;
// Drop all tables created in beforeEach
DB::statement("DROP TABLE authors, categories, posts, comments, reactions, articles;");
// Create a new user so we have full control over the permissions.
// We explicitly set bypassRls to false.
[$username, $password] = createPostgresUser('administrator', bypassRls: false);
config(['database.connections.central' => array_merge(config('database.connections.pgsql'), [
'username' => $username,
'password' => $password,
])]);
DB::reconnect();
// This table is owned by the newly created 'administrator' user
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->integer('stars')->default(0);
$table->string('name');
$table->unsignedBigInteger('post_id')->nullable()->comment('rls');
$table->foreign('post_id')->references('id')->on('posts')->onUpdate('cascade')->onDelete('cascade');
// No 'rls' comment should get excluded from full trees when using explicit scoping
$table->string('tenant_id')->nullable();
$table->string('tenant_id')->comment('rls');
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
$table->timestamps();
});
// The shortest paths should include a path for the ratings table
// That leads through tenant_id when scoping by default is enabled, that's the shortest path
// When scoping by default is disabled, the shortest path leads through post_id
// This behavior is handled by the manager's generateTrees() method, which is called by shortestPaths()
$shortestPaths = $manager->shortestPaths();
$tenant1 = Tenant::create();
$expectedShortestPath = $scopeByDefault ? [
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
],
] : [
[
'foreignKey' => 'post_id',
'foreignTable' => 'posts',
'foreignId' => 'id',
],
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
],
];
// Create RLS policy for the orders table
pest()->artisan('tenants:rls');
expect($shortestPaths['ratings'])->toBe($expectedShortestPath);
$tenant1->run(fn () => Order::create(['name' => 'order1', 'tenant_id' => $tenant1->id]));
// Add non-nullable comment_id foreign key
Schema::table('ratings', function (Blueprint $table) {
$table->foreignId('comment_id')->onUpdate('cascade')->onDelete('cascade')->comment('rls')->constrained('comments');
// We are still using the 'administrator' user - owner of the orders table
if ($forceRls) {
// RLS is forced, so by default, not even the table owner should be able to query the table protected by the RLS policy.
// The RLS policy is not being bypassed, 'unrecognized configuration parameter' means
// that the my.current_tenant session variable isn't set -- the RLS policy is *still* being enforced.
expect(fn () => Order::first())->toThrow(QueryException::class, 'unrecognized configuration parameter "my.current_tenant"');
} else {
// RLS is not forced, so the table owner should be able to query the table, bypassing the RLS policy
expect(Order::first())->not()->toBeNull();
}
})->with([true, false]);
test('users with BYPASSRLS privilege can bypass RLS regardless of forceRls setting', function (bool $forceRls, bool $bypassRls) {
CreateUserWithRLSPolicies::$forceRls = $forceRls;
// Drop all tables created in beforeEach
DB::statement("DROP TABLE authors, categories, posts, comments, reactions, articles;");
// Create a new user so we have control over his BYPASSRLS permission
// and use that as the new central connection user
[$username, $password] = createPostgresUser('administrator', 'password', $bypassRls);
config(['database.connections.central' => array_merge(config('database.connections.pgsql'), [
'username' => $username,
'password' => $password,
])]);
DB::reconnect();
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('tenant_id')->comment('rls');
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
$table->timestamps();
});
// Non-nullable paths are preferred over nullable paths
// The shortest paths should include a path for the ratings table
// That leads through comment_id instead of tenant_id
$shortestPaths = $manager->shortestPaths();
$tenant1 = Tenant::create();
expect($shortestPaths['ratings'])->toBe([
[
'foreignKey' => 'comment_id',
'foreignTable' => 'comments',
'foreignId' => 'id',
],
[
'foreignKey' => 'post_id',
'foreignTable' => 'posts',
'foreignId' => 'id',
],
[
'foreignKey' => 'author_id',
'foreignTable' => 'authors',
'foreignId' => 'id',
],
[
'foreignKey' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
],
]);
})->with([true, false]);
// Create RLS policy for the orders table
pest()->artisan('tenants:rls');
$tenant1->run(fn () => Order::create(['name' => 'order1', 'tenant_id' => $tenant1->id]));
// We are still using the 'administrator' user
if ($bypassRls) {
// Users with BYPASSRLS can always query tables regardless of forceRls setting
expect(Order::count())->toBe(1);
expect(Order::first()->name)->toBe('order1');
} else {
// Users without BYPASSRLS are subject to RLS policies even if they're table owners when forceRls is true
// OR they can bypass as table owners (when forceRls=false)
if ($forceRls) {
// Even table owners need session variable -- this means RLS was NOT bypassed
expect(fn () => Order::first())->toThrow(QueryException::class, 'unrecognized configuration parameter "my.current_tenant"');
} else {
// Table owners can bypass RLS automatically when forceRls is false
expect(Order::count())->toBe(1);
expect(Order::first()->name)->toBe('order1');
}
}
})->with([true, false])->with([true, false]);
test('table rls manager generates queries correctly', function() {
expect(array_values(app(TableRLSManager::class)->generateQueries()))->toEqualCanonicalizing([
@ -569,38 +613,38 @@ test('table rls manager generates queries correctly', function() {
$paths = [
'primaries' => [
[
'foreignKey' => 'tenant_id',
'localColumn' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
'foreignColumn' => 'id',
],
],
'secondaries' => [
[
'foreignKey' => 'primary_id',
'localColumn' => 'primary_id',
'foreignTable' => 'primaries',
'foreignId' => 'id',
'foreignColumn' => 'id',
],
[
'foreignKey' => 'tenant_id',
'localColumn' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
'foreignColumn' => 'id',
],
],
'foo' => [
[
'foreignKey' => 'secondary_id',
'localColumn' => 'secondary_id',
'foreignTable' => 'secondaries',
'foreignId' => 'id',
'foreignColumn' => 'id',
],
[
'foreignKey' => 'primary_id',
'localColumn' => 'primary_id',
'foreignTable' => 'primaries',
'foreignId' => 'id',
'foreignColumn' => 'id',
],
[
'foreignKey' => 'tenant_id',
'localColumn' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignId' => 'id',
'foreignColumn' => 'id',
],
],
];
@ -636,19 +680,180 @@ test('table rls manager generates queries correctly', function() {
);
});
test('table manager throws an exception when encountering a recursive relationship', function() {
test('table manager throws an exception when the only available paths lead through recursive relationships', function (bool $useCommentConstraints) {
// We test recursive relations using both foreign key constraints and comment constraints
$makeConstraint = function (ForeignIdColumnDefinition $relation, $table, $column) use ($useCommentConstraints) {
if ($useCommentConstraints) {
$relation->comment("rls $table.$column");
} else {
$relation->constrained($table, $column);
}
};
Schema::create('recursive_posts', function (Blueprint $table) {
$table->id();
$table->foreignId('highlighted_comment_id')->constrained('comments')->nullable()->comment('rls');
});
Schema::create('recursive_comments', function (Blueprint $table) {
$table->id();
});
Schema::table('recursive_posts', function (Blueprint $table) use ($makeConstraint) {
$makeConstraint($table->foreignId('highlighted_comment_id')->nullable(), 'recursive_comments', 'id');
});
Schema::table('recursive_comments', function (Blueprint $table) use ($makeConstraint) {
$makeConstraint($table->foreignId('recursive_post_id'), 'recursive_posts', 'id');
});
expect(fn () => app(TableRLSManager::class)->shortestPaths())->toThrow(RecursiveRelationshipException::class);
Schema::table('recursive_comments', function (Blueprint $table) use ($makeConstraint, $useCommentConstraints) {
// Add another recursive relationship to demonstrate a more complex case
$makeConstraint($table->foreignId('related_post_id'), 'recursive_posts', 'id');
// Add a foreign key to the current table (= self-referencing constraint)
$makeConstraint($table->foreignId('parent_comment_id'), 'recursive_comments', 'id');
// Add tenant_id to break the recursion - RecursiveRelationshipException should not be thrown
// We cannot use $makeConstraint() here since tenant_id is a string column
if ($useCommentConstraints) {
$table->string('tenant_id')->comment('rls tenants.id');
} else {
$table->string('tenant_id')->comment('rls')->nullable();
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
}
});
// Doesn't throw an exception anymore
$shortestPaths = app(TableRLSManager::class)->shortestPaths();
// Generated paths include both the recursive_posts and the recursive_comments tables
// because they actually lead to the tenants table now.
//
// recursive_comments has a direct path to tenants, recursive_posts has a path
// to tenants through recursive_comments
expect(array_keys($shortestPaths))->toContain('recursive_posts', 'recursive_comments');
})->with([true, false]);
test('table manager ignores recursive relationship if the foreign key responsible for the recursion has no-rls comment', function() {
Schema::create('recursive_posts', function (Blueprint $table) {
$table->id();
$table->foreignId('highlighted_comment_id')->nullable()->comment('no-rls')->constrained('comments');
});
// Add a foreign key constraint to the comments table to introduce a recursive relationship
// Note that the comments table still has the post_id foreign key that leads to the tenants table
Schema::table('comments', function (Blueprint $table) {
$table->foreignId('recursive_post_id')->constrained('recursive_posts')->comment('rls');
$table->foreignId('recursive_post_id')->comment('rls')->constrained('recursive_posts');
});
expect(fn () => app(TableRLSManager::class)->generateTrees())->toThrow(RecursiveRelationshipException::class);
// No exception thrown because
// the highlighted_comment_id foreign key has a no-rls comment
$shortestPaths = app(TableRLSManager::class)->shortestPaths();
expect(array_keys($shortestPaths))
->toContain('posts', 'comments')
// Shortest paths do not include the recursive_posts table
// because it has a 'no-rls' comment on its only foreign key
->not()->toContain('recursive_posts');
});
test('table manager can generate paths leading through comment constraint columns', function() {
// Drop extra tables created in beforeEach
Schema::dropIfExists('reactions');
Schema::dropIfExists('comments');
Schema::dropIfExists('posts');
Schema::dropIfExists('authors');
Schema::create('non_constrained_users', function (Blueprint $table) {
$table->id();
$table->string('tenant_id')->comment('rls tenants.id'); // Comment constraint
});
Schema::create('non_constrained_posts', function (Blueprint $table) {
$table->id();
$table->foreignId('author_id')->comment('rls non_constrained_users.id'); // Comment constraint
});
/** @var TableRLSManager $manager */
$manager = app(TableRLSManager::class);
$expectedPaths = [
'non_constrained_posts' => [
[
'localColumn' => 'author_id',
'foreignTable' => 'non_constrained_users',
'foreignColumn' => 'id',
],
[
'localColumn' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignColumn' => 'id',
],
],
'non_constrained_users' => [
[
'localColumn' => 'tenant_id',
'foreignTable' => 'tenants',
'foreignColumn' => 'id',
],
],
];
expect($manager->shortestPaths())->toEqual($expectedPaths);
});
test('table manager throws an exception when comment constraint is incorrect', function(string $comment, string $exceptionMessage) {
Schema::create('non_constrained_users', function (Blueprint $table) use ($comment) {
$table->id();
$table->string('tenant_id')->comment($comment); // Invalid comment constraint
});
/** @var TableRLSManager $manager */
$manager = app(TableRLSManager::class);
expect(fn () => $manager->shortestPaths())->toThrow(
RLSCommentConstraintException::class,
$exceptionMessage
);
})->with([
['rls ', 'Malformed comment constraint on non_constrained_users'], // Missing table.column
['rls tenants', 'Malformed comment constraint on non_constrained_users'], // Missing column part
['rls tenants.', 'Malformed comment constraint on non_constrained_users'], // Missing column part
['rls .id', 'Malformed comment constraint on non_constrained_users'], // Missing table part
['rls tenants.foreign.id', 'Malformed comment constraint on non_constrained_users'], // Too many parts
['rls nonexistent-table.id', 'references non-existent table'],
['rls tenants.nonexistent-column', 'references non-existent column'],
]);
function createPostgresUser(string $username, string $password = 'password', bool $bypassRls = false): array
{
try {
DB::statement("DROP OWNED BY {$username};");
} catch (\Throwable) {}
DB::statement("DROP USER IF EXISTS {$username};");
DB::statement("CREATE USER {$username} WITH ENCRYPTED PASSWORD '{$password}'");
DB::statement("ALTER USER {$username} CREATEDB");
DB::statement("ALTER USER {$username} CREATEROLE");
// Grant BYPASSRLS privilege if requested
if ($bypassRls) {
DB::statement("ALTER USER {$username} BYPASSRLS");
}
// Grant privileges to the new central user
DB::statement("GRANT ALL PRIVILEGES ON DATABASE main to {$username}");
DB::statement("GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO {$username}");
DB::statement("GRANT ALL ON SCHEMA public TO {$username}");
DB::statement("ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL PRIVILEGES ON TABLES TO {$username}");
DB::statement("GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO {$username}");
return [$username, $password];
}
class Post extends Model
{
protected $guarded = [];
@ -701,3 +906,8 @@ class Author extends Model
{
protected $guarded = [];
}
class Order extends Model
{
protected $guarded = [];
}

View file

@ -25,8 +25,10 @@ use Stancl\Tenancy\Commands\CreateUserWithRLSPolicies;
use Stancl\Tenancy\RLS\PolicyManagers\TraitRLSManager;
use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper;
use Stancl\Tenancy\Database\Concerns\BelongsToPrimaryModel;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
CreateUserWithRLSPolicies::$forceRls = true;
TraitRLSManager::$implicitRLS = true;
TraitRLSManager::$modelDirectories = [__DIR__ . '/Etc'];
TraitRLSManager::$excludedModels = [Article::class];
@ -77,6 +79,10 @@ beforeEach(function () {
});
});
afterEach(function () {
CreateUserWithRLSPolicies::$forceRls = true;
});
test('correct rls policies get created with the correct hash using trait manager', function () {
$manager = app(TraitRLSManager::class);
@ -148,7 +154,8 @@ test('global scope is not applied when using rls with single db traits', functio
expect(NonRLSComment::make()->hasGlobalScope(ParentModelScope::class))->toBeFalse();
});
test('queries are correctly scoped using RLS with trait rls manager', function (bool $implicitRLS) {
test('queries are correctly scoped using RLS with trait rls manager', function (bool $implicitRLS, bool $forceRls) {
CreateUserWithRLSPolicies::$forceRls = $forceRls;
TraitRLSManager::$implicitRLS = $implicitRLS;
$postModel = $implicitRLS ? NonRLSPost::class : Post::class;
@ -262,10 +269,7 @@ test('queries are correctly scoped using RLS with trait rls manager', function (
expect(fn () => DB::statement("INSERT INTO comments (text, post_id) VALUES ('third comment', {$post1->id})"))
->toThrow(QueryException::class);
})->with([
true,
false
]);
})->with([true, false])->with([true, false]);
test('trait rls manager generates queries correctly', function() {
/** @var TraitRLSManager $manager */

View file

@ -2,10 +2,13 @@
declare(strict_types=1);
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException;
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
config([
@ -14,45 +17,90 @@ beforeEach(function () {
],
]);
InitializeTenancyByRequestData::$header = 'X-Tenant';
InitializeTenancyByRequestData::$cookie = 'X-Tenant';
InitializeTenancyByRequestData::$queryParameter = 'tenant';
Route::middleware(['tenant', InitializeTenancyByRequestData::class])->get('/test', function () {
Route::middleware([InitializeTenancyByRequestData::class])->get('/test', function () {
return 'Tenant id: ' . tenant('id');
});
});
test('header identification works', function () {
$tenant = Tenant::create();
test('header identification works', function (string|null $tenantModelColumn) {
if ($tenantModelColumn) {
Schema::table('tenants', function (Blueprint $table) use ($tenantModelColumn) {
$table->string($tenantModelColumn)->unique();
});
Tenant::$extraCustomColumns = [$tenantModelColumn];
}
$this
->withoutExceptionHandling()
->withHeader('X-Tenant', $tenant->id)
->get('test')
->assertSee($tenant->id);
});
config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.tenant_model_column' => $tenantModelColumn]);
test('query parameter identification works', function () {
$tenant = Tenant::create();
$tenant = Tenant::create($tenantModelColumn ? [$tenantModelColumn => 'acme'] : []);
$payload = $tenantModelColumn ? 'acme' : $tenant->id;
$this
->withoutExceptionHandling()
->get('test?tenant=' . $tenant->id)
->assertSee($tenant->id);
});
// Default header name
$this->withoutExceptionHandling()->withHeader('X-Tenant', $payload)->get('test')->assertSee($tenant->id);
test('cookie identification works', function () {
$tenant = Tenant::create();
// Custom header name
config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.header' => 'X-Custom-Tenant']);
$this->withoutExceptionHandling()->withHeader('X-Custom-Tenant', $payload)->get('test')->assertSee($tenant->id);
$this
->withoutExceptionHandling()
->withUnencryptedCookie('X-Tenant', $tenant->id)
->get('test')
->assertSee($tenant->id);
});
// Setting the header to null disables header identification
config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.header' => null]);
expect(fn () => $this->withoutExceptionHandling()->withHeader('X-Tenant', $payload)->get('test'))->toThrow(TenantCouldNotBeIdentifiedByRequestDataException::class);
})->with([null, 'slug']);
test('middleware throws exception when tenant data is not provided in the request', function () {
test('query parameter identification works', function (string|null $tenantModelColumn) {
if ($tenantModelColumn) {
Schema::table('tenants', function (Blueprint $table) use ($tenantModelColumn) {
$table->string($tenantModelColumn)->unique();
});
Tenant::$extraCustomColumns = [$tenantModelColumn];
}
config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.tenant_model_column' => $tenantModelColumn]);
$tenant = Tenant::create($tenantModelColumn ? [$tenantModelColumn => 'acme'] : []);
$payload = $tenantModelColumn ? 'acme' : $tenant->id;
// Default query parameter name
$this->withoutExceptionHandling()->get('test?tenant=' . $payload)->assertSee($tenant->id);
// Custom query parameter name
config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.query_parameter' => 'custom_tenant']);
$this->withoutExceptionHandling()->get('test?custom_tenant=' . $payload)->assertSee($tenant->id);
// Setting the query parameter to null disables query parameter identification
config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.query_parameter' => null]);
expect(fn () => $this->withoutExceptionHandling()->get('test?tenant=' . $payload))->toThrow(TenantCouldNotBeIdentifiedByRequestDataException::class);
})->with([null, 'slug']);
test('cookie identification works', function (string|null $tenantModelColumn) {
if ($tenantModelColumn) {
Schema::table('tenants', function (Blueprint $table) use ($tenantModelColumn) {
$table->string($tenantModelColumn)->unique();
});
Tenant::$extraCustomColumns = [$tenantModelColumn];
}
config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.tenant_model_column' => $tenantModelColumn]);
$tenant = Tenant::create($tenantModelColumn ? [$tenantModelColumn => 'acme'] : []);
$payload = $tenantModelColumn ? 'acme' : $tenant->id;
// Default cookie name
$this->withoutExceptionHandling()->withUnencryptedCookie('tenant', $payload)->get('test')->assertSee($tenant->id);
// Custom cookie name
config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.cookie' => 'custom_tenant_id']);
$this->withoutExceptionHandling()->withUnencryptedCookie('custom_tenant_id', $payload)->get('test')->assertSee($tenant->id);
// Setting the cookie to null disables cookie identification
config(['tenancy.identification.resolvers.' . RequestDataTenantResolver::class . '.cookie' => null]);
expect(fn () => $this->withoutExceptionHandling()->withUnencryptedCookie('tenant', $payload)->get('test'))->toThrow(TenantCouldNotBeIdentifiedByRequestDataException::class);
})->with([null, 'slug']);
// todo@tests encrypted cookie
test('an exception is thrown when no tenant data is provided in the request', function () {
pest()->expectException(TenantCouldNotBeIdentifiedByRequestDataException::class);
$this->withoutExceptionHandling()->get('test');
});

View file

@ -43,6 +43,9 @@ use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceDetachedFromTenant;
use Stancl\Tenancy\Tests\Etc\ResourceSyncing\CentralUser as BaseCentralUser;
use Stancl\Tenancy\ResourceSyncing\CentralResourceNotAvailableInPivotException;
use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Database\QueryException;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
config(['tenancy.bootstrappers' => [
@ -67,6 +70,9 @@ beforeEach(function () {
DeleteResourceInTenant::$shouldQueue = false;
UpdateOrCreateSyncedResource::$scopeGetModelQuery = null;
// Reset global scopes on models (should happen automatically but to make this more explicit)
Model::clearBootedModels();
$syncedAttributes = [
'global_id',
'name',
@ -105,6 +111,30 @@ beforeEach(function () {
afterEach(function () {
UpdateOrCreateSyncedResource::$scopeGetModelQuery = null;
// Reset global scopes on models (should happen automatically but to make this more explicit)
Model::clearBootedModels();
});
test('resources created with the same global id in different tenant dbs will be synced to a single central resource', function () {
$tenants = [Tenant::create(), Tenant::create(), Tenant::create()];
migrateUsersTableForTenants();
// Only a single central user is created since the same global_id is used for each tenant user
// Therefore all of these tenant users are synced to a single global user
tenancy()->runForMultiple($tenants, function () {
// Create a user with the same global_id in each tenant DB
TenantUser::create([
'global_id' => 'acme',
'name' => Str::random(),
'email' => 'john@localhost',
'password' => 'secret',
'role' => 'commenter',
]);
});
expect(CentralUser::all())->toHaveCount(1);
expect(CentralUser::first()->global_id)->toBe('acme');
});
test('SyncedResourceSaved event gets triggered when resource gets created or when its synced attributes get updated', function () {
@ -1172,6 +1202,69 @@ test('resource creation works correctly when central resource provides defaults
expect($centralUser->foo)->toBe('bar');
});
test('global scopes on syncable models can break resource syncing', function () {
[$tenant1, $tenant2] = createTenantsAndRunMigrations();
$centralUser = CentralUser::create([
'global_id' => 'foo',
'name' => 'foo',
'email' => 'foo@bar.com',
'password' => '*****',
'role' => 'admin', // not 'visible'
]);
// Create a tenant resource. The global id matches that of the central user created above,
// so the synced columns of the central record will be updated.
$tenant1->run(fn () => TenantUser::create([
'global_id' => 'foo',
'name' => 'tenant1 user',
'email' => 'tenant1@user.com',
'password' => 'tenant1_password',
'role' => 'user1',
]));
expect($centralUser->refresh()->name)->toBe('tenant1 user');
// While syncing a tenant resource with the same global id,
// the central resource will not be found due to this scope,
// leading to the syncing logic trying to create a new central resource with that same global id,
// triggering a unique constraint violation exception.
CentralUser::addGlobalScope(new VisibleScope());
expect(function () use ($tenant1) {
$tenant1->run(fn () => TenantUser::create([
'global_id' => 'foo',
'name' => 'tenant1new user',
'email' => 'tenant1new@user.com',
'password' => 'tenant1new_password',
'role' => 'user1new',
]));
})->toThrow(QueryException::class, "Duplicate entry 'foo' for key 'users.users_global_id_unique'");
// The central resource stays the same
expect($centralUser->refresh()->name)->toBe('tenant1 user');
// Use UpdateOrCreateSyncedResource::$scopeGetModelQuery to bypass the global scope.
UpdateOrCreateSyncedResource::$scopeGetModelQuery = function (Builder $query) {
$query->withoutGlobalScope(VisibleScope::class);
};
// Now, the central resource IS found, and no exception is thrown
$tenant2->run(fn () => TenantUser::create([
'global_id' => 'foo',
'name' => 'tenant2 user',
'email' => 'tenant2@user.com',
'password' => 'tenant2_password',
'role' => 'user2',
]));
// The central resource was updated
expect($centralUser->refresh()->name)->toBe('tenant2 user');
// The change was also synced to tenant1
expect($tenant1->run(fn () => TenantUser::first()->name))->toBe('tenant2 user');
});
/**
* Create two tenants and run migrations for those tenants.
*
@ -1243,6 +1336,14 @@ class TenantUser extends BaseTenantUser
}
}
class VisibleScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('role', 'visible');
}
}
class TenantPivot extends BasePivot
{
public $table = 'tenant_users';
@ -1320,6 +1421,7 @@ class CentralCompany extends Model implements SyncMaster
];
}
}
class TenantCompany extends Model implements Syncable
{
use ResourceSyncing;

View file

@ -69,6 +69,18 @@ test('tenancy detects presence of route middleware correctly', function (string
InitializeTenancyByDomainOrSubdomain::class,
]);
test('getRouteMiddleware properly unpacks all mw groups on a route', function() {
$route = Route::get('/foo', fn () => true)->middleware(['foo', 'bar']);
Route::middlewareGroup('foo', [PreventAccessFromUnwantedDomains::class]);
Route::middlewareGroup('bar', [InitializeTenancyByDomain::class]);
expect(tenancy()->getRouteMiddleware($route))->toContain(
PreventAccessFromUnwantedDomains::class,
InitializeTenancyByDomain::class
);
});
test('domain identification middleware is configurable', function() {
$route = Route::get('/welcome-route', fn () => 'welcome')->middleware([InitializeTenancyByDomain::class]);

View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Support\Facades\Event;
use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Jobs\MigrateDatabase;
use Stancl\JobPipeline\JobPipeline;
use Stancl\Tenancy\Tests\Etc\User;
use Illuminate\Support\Str;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
beforeEach(function () {
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenantCreated::class, JobPipeline::make([
CreateDatabase::class,
MigrateDatabase::class,
])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
config(['tenancy.bootstrappers' => [
DatabaseTenancyBootstrapper::class,
]]);
});
test('runForMultiple runs the passed closure for the right tenants', function() {
$tenants = [Tenant::create(), Tenant::create(), Tenant::create()];
$createUser = fn ($username) => function () use ($username) {
User::create(['name' => $username, 'email' => Str::random(8) . '@example.com', 'password' => bcrypt('password')]);
};
// tenancy()->runForMultiple([], ...) shouldn't do anything
// No users should be created -- the closure should not run at all
tenancy()->runForMultiple([], $createUser('none'));
// Try the same with an empty collection -- the result should be the same for any traversable
tenancy()->runForMultiple(collect(), $createUser('none'));
foreach ($tenants as $tenant) {
$tenant->run(function() {
expect(User::count())->toBe(0);
});
}
// tenancy()->runForMultiple(['foo', 'bar'], ...) should run the closure only for the passed tenants
tenancy()->runForMultiple([$tenants[0]->getTenantKey(), $tenants[1]->getTenantKey()], $createUser('user'));
// User should be created for tenants[0] and tenants[1], but not for tenants[2]
foreach ($tenants as $tenant) {
$tenant->run(function() use ($tenants) {
if (tenant()->getTenantKey() !== $tenants[2]->getTenantKey()) {
expect(User::first()->name)->toBe('user');
} else {
expect(User::count())->toBe(0);
}
});
}
// tenancy()->runForMultiple(null, ...) should run the closure for all tenants
tenancy()->runForMultiple(null, $createUser('new_user'));
foreach ($tenants as $tenant) {
$tenant->run(function() {
expect(User::all()->pluck('name'))->toContain('new_user');
});
}
});

View file

@ -8,6 +8,7 @@ use Stancl\Tenancy\Exceptions\TenancyNotInitializedException;
use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain;
use Stancl\Tenancy\Middleware\ScopeSessions;
use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
Route::group([
@ -54,3 +55,15 @@ test('an exception is thrown when the middleware is executed before tenancy is i
pest()->expectException(TenancyNotInitializedException::class);
$this->withoutExceptionHandling()->get('http://acme.localhost/bar');
});
test('scope sessions mw can be used on universal routes', function() {
Route::get('/universal', function () {
return true;
})->middleware(['universal', InitializeTenancyBySubdomain::class, ScopeSessions::class]);
Tenant::create([
'id' => 'acme',
])->createDomain('acme');
pest()->withoutExceptionHandling()->get('http://localhost/universal')->assertSuccessful();
});

View file

@ -23,6 +23,7 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains;
use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
// todo@tests write similar low-level tests for the cache bootstrapper? including the database driver in a single-db setup

View file

@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Stancl\Tenancy\Database\Concerns\BelongsToTenant;
use Stancl\Tenancy\Database\Concerns\BelongsToPrimaryModel;
use Stancl\Tenancy\Database\Concerns\HasScopedValidationRules;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
Schema::create('posts', function (Blueprint $table) {

View file

@ -11,6 +11,7 @@ use Illuminate\Database\UniqueConstraintViolationException;
use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
config(['tenancy.models.tenant' => SingleDomainTenant::class]);

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 function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
// Global state cleanup after some tests

View file

@ -19,6 +19,7 @@ use Stancl\Tenancy\Controllers\TenantAssetController;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Overrides\TenancyUrlGenerator;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
config(['tenancy.bootstrappers' => [
@ -65,7 +66,7 @@ test('asset can be accessed using the url returned by the tenant asset helper',
test('asset helper returns a link to tenant asset controller when asset url is null', function () {
config(['app.asset_url' => null]);
config(['tenancy.filesystem.asset_helper_tenancy' => true]);
config(['tenancy.filesystem.asset_helper_override' => true]);
$tenant = Tenant::create();
tenancy()->initialize($tenant);
@ -78,7 +79,7 @@ test('asset helper returns a link to tenant asset controller when asset url is n
test('asset helper returns a link to an external url when asset url is not null', function () {
config(['app.asset_url' => 'https://an-s3-bucket']);
config(['tenancy.filesystem.asset_helper_tenancy' => true]);
config(['tenancy.filesystem.asset_helper_override' => true]);
$tenant = Tenant::create();
tenancy()->initialize($tenant);
@ -93,7 +94,7 @@ test('asset helper works correctly with path identification', function (bool $ke
TenancyUrlGenerator::$prefixRouteNames = true;
TenancyUrlGenerator::$passTenantParameterToRoutes = true;
config(['tenancy.filesystem.asset_helper_tenancy' => true]);
config(['tenancy.filesystem.asset_helper_override' => true]);
config(['tenancy.identification.default_middleware' => InitializeTenancyByPath::class]);
config(['tenancy.bootstrappers' => array_merge([UrlGeneratorBootstrapper::class], config('tenancy.bootstrappers'))]);
@ -165,7 +166,7 @@ test('asset helper tenancy can be disabled', function () {
config([
'app.asset_url' => null,
'tenancy.filesystem.asset_helper_tenancy' => false,
'tenancy.filesystem.asset_helper_override' => false,
]);
$tenant = Tenant::create();

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
test('commands run globally are tenant aware and return valid exit code', function () {
$tenant1 = Tenant::create();

View file

@ -28,6 +28,7 @@ use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMySQLData
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLSchemaManager;
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLDatabaseManager;
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
SQLiteDatabaseManager::$path = null;
@ -405,6 +406,42 @@ test('tenant database can be created by using the username and password from ten
expect($manager->databaseExists($name))->toBeTrue();
});
test('decrypted password can be used to connect to a tenant db while the password is saved as encrypted', function (string|null $tenantDbPassword) {
config([
'tenancy.database.managers.mysql' => PermissionControlledMySQLDatabaseManager::class,
'tenancy.database.template_tenant_connection' => 'mysql',
]);
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
// Create a tenant, either with a specific password, or with a password generated by the DB manager
$tenant = TenantWithEncryptedPassword::create([
'tenancy_db_name' => $name = 'foo' . Str::random(8),
'tenancy_db_username' => 'user' . Str::random(4),
'tenancy_db_password' => $tenantDbPassword,
]);
$decryptedPassword = $tenant->tenancy_db_password;
$encryptedPassword = $tenant->getAttributes()['tenancy_db_password']; // Password encrypted using the TenantWithEncryptedPassword model's encrypted cast
expect($decryptedPassword)->not()->toBe($encryptedPassword);
$passwordSavedInDatabase = json_decode(DB::select('SELECT data FROM tenants LIMIT 1')[0]->data)->tenancy_db_password;
expect($encryptedPassword)->toBe($passwordSavedInDatabase);
app(DatabaseManager::class)->connectToTenant($tenant);
// Check if we got connected to the tenant DB
expect(config('database.default'))->toBe('tenant');
expect(config('database.connections.tenant.database'))->toBe($name);
// Check if the decrypted password is used to connect to the tenant DB
expect(config('database.connections.tenant.password'))->toBe($decryptedPassword);
})->with([
'decrypted' . Str::random(8), // Use this password as the tenant DB password
null, // Let the DB manager generate the tenant DB password
]);
test('path used by sqlite manager can be customized', function () {
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
@ -529,3 +566,13 @@ function createUsersTable()
$table->timestamps();
});
}
class TenantWithEncryptedPassword extends Tenant
{
protected function casts(): array
{
return [
'tenancy_db_password' => 'encrypted',
];
}
}

View file

@ -20,7 +20,16 @@ use Stancl\Tenancy\Tests\Etc\Tenant;
use Stancl\Tenancy\UniqueIdentifierGenerators\UUIDGenerator;
use Stancl\Tenancy\Exceptions\TenancyNotInitializedException;
use Stancl\Tenancy\UniqueIdentifierGenerators\RandomHexGenerator;
use Stancl\Tenancy\UniqueIdentifierGenerators\RandomIntGenerator;
use Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator;
use Stancl\Tenancy\UniqueIdentifierGenerators\ULIDGenerator;
use function Stancl\Tenancy\Tests\pest;
afterEach(function () {
RandomIntGenerator::$min = 0;
RandomIntGenerator::$max = PHP_INT_MAX;
});
test('created event is dispatched', function () {
Event::fake([TenantCreated::class]);
@ -71,6 +80,20 @@ test('autoincrement ids are supported', function () {
expect($tenant2->id)->toBe(2);
});
test('ulid ids are supported', function () {
app()->bind(UniqueIdentifierGenerator::class, ULIDGenerator::class);
$tenant1 = Tenant::create();
expect($tenant1->id)->toBeString();
expect(strlen($tenant1->id))->toBe(26);
$tenant2 = Tenant::create();
expect($tenant2->id)->toBeString();
expect(strlen($tenant2->id))->toBe(26);
expect($tenant2->id > $tenant1->id)->toBeTrue();
});
test('hex ids are supported', function () {
app()->bind(UniqueIdentifierGenerator::class, RandomHexGenerator::class);
@ -87,6 +110,16 @@ test('hex ids are supported', function () {
RandomHexGenerator::$bytes = 6; // reset
});
test('random ints are supported', function () {
app()->bind(UniqueIdentifierGenerator::class, RandomIntGenerator::class);
RandomIntGenerator::$min = 200;
RandomIntGenerator::$max = 1000;
$tenant1 = Tenant::create();
expect($tenant1->id >= 200)->toBeTrue();
expect($tenant1->id <= 1000)->toBeTrue();
});
test('random string ids are supported', function () {
app()->bind(UniqueIdentifierGenerator::class, RandomStringGenerator::class);

View file

@ -25,6 +25,7 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Exceptions\StatefulGuardRequiredException;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
pest()->artisan('migrate', [
@ -88,7 +89,7 @@ test('tenant user can be impersonated on a tenant domain', function () {
expect(session('tenancy_impersonating'))->toBeTrue();
// Leave impersonation
UserImpersonation::leave();
UserImpersonation::stopImpersonating();
expect(UserImpersonation::isImpersonating())->toBeFalse();
expect(session('tenancy_impersonating'))->toBeNull();
@ -134,7 +135,7 @@ test('tenant user can be impersonated on a tenant path', function () {
expect(session('tenancy_impersonating'))->toBeTrue();
// Leave impersonation
UserImpersonation::leave();
UserImpersonation::stopImpersonating();
expect(UserImpersonation::isImpersonating())->toBeFalse();
expect(session('tenancy_impersonating'))->toBeNull();

View file

@ -22,6 +22,8 @@ use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper;
use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper;
use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use function Stancl\Tenancy\Tests\pest;
abstract class TestCase extends \Orchestra\Testbench\TestCase
{
@ -34,6 +36,8 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
{
parent::setUp();
ini_set('memory_limit', '1G');
Redis::connection('default')->flushdb();
Redis::connection('cache')->flushdb();
Artisan::call('cache:clear memcached'); // flush memcached
@ -85,11 +89,8 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
'--realpath' => true,
]);
// Laravel 6.x support todo@refactor clean up
$testResponse = class_exists('Illuminate\Testing\TestResponse') ? 'Illuminate\Testing\TestResponse' : 'Illuminate\Foundation\Testing\TestResponse';
$testResponse::macro('assertContent', function ($content) {
$assertClass = class_exists('Illuminate\Testing\Assert') ? 'Illuminate\Testing\Assert' : 'Illuminate\Foundation\Testing\Assert';
$assertClass::assertSame($content, $this->baseResponse->getContent());
\Illuminate\Testing\TestResponse::macro('assertContent', function ($content) {
\Illuminate\Testing\Assert::assertSame($content, $this->baseResponse->getContent());
return $this;
});
@ -175,18 +176,25 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
'tenancy.models.tenant' => Tenant::class, // Use test tenant w/ DBs & domains
]);
$app->singleton(RedisTenancyBootstrapper::class); // todo@samuel use proper approach eg config for singleton registration
$app->singleton(CacheTenancyBootstrapper::class); // todo@samuel use proper approach eg config for singleton registration
// Since we run the TSP with no bootstrappers enabled, we need
// to manually register bootstrappers as singletons here.
$app->singleton(RedisTenancyBootstrapper::class);
$app->singleton(CacheTenancyBootstrapper::class);
$app->singleton(BroadcastingConfigBootstrapper::class);
$app->singleton(BroadcastChannelPrefixBootstrapper::class);
$app->singleton(PostgresRLSBootstrapper::class);
$app->singleton(MailConfigBootstrapper::class);
$app->singleton(RootUrlBootstrapper::class);
$app->singleton(UrlGeneratorBootstrapper::class);
$app->singleton(FilesystemTenancyBootstrapper::class);
}
protected function getPackageProviders($app)
{
TenancyServiceProvider::$configure = function () {
config(['tenancy.bootstrappers' => []]);
};
return [
TenancyServiceProvider::class,
];

View file

@ -2,8 +2,6 @@
declare(strict_types=1);
use Stancl\Tenancy\Tenancy;
use Illuminate\Http\Request;
use Stancl\Tenancy\Enums\RouteMode;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Contracts\Http\Kernel;
@ -11,16 +9,14 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\Route as RouteFacade;
use Stancl\Tenancy\Tests\Etc\HasMiddlewareController;
use Stancl\Tenancy\Middleware\IdentificationMiddleware;
use Stancl\Tenancy\Resolvers\RequestDataTenantResolver;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Concerns\UsableWithEarlyIdentification;
use Stancl\Tenancy\Middleware\InitializeTenancyBySubdomain;
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
use Stancl\Tenancy\Middleware\PreventAccessFromUnwantedDomains;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException;
use function Stancl\Tenancy\Tests\pest;
test('a route can be universal using domain identification', function (array $routeMiddleware, array $globalMiddleware) {
foreach ($globalMiddleware as $middleware) {