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:
commit
2cfa8831a3
113 changed files with 3035 additions and 1373 deletions
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]]);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
73
tests/RunForMultipleTest.php
Normal file
73
tests/RunForMultipleTest.php
Normal 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');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue