From 8f958d577914edda083c60b74864f28752c08ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 14 Jan 2025 13:49:16 +0100 Subject: [PATCH 01/42] [4.x] Queue logic refactor (#1289) * simplify QueueTenancyBootstrapper * wip: add persistent queue bootstrapper, minor testcase refactor * ci: run persistent queue tests * simplify persistent queue bootstrapper * Fix code style (php-cs-fixer) * phpstan fixes, clarify previousTenant use * remove false positive regression test --------- Co-authored-by: github-actions[bot] --- .github/workflows/queue.yml | 2 + .../PersistentQueueTenancyBootstrapper.php | 146 ++++++++++++++++++ .../QueueTenancyBootstrapper.php | 82 ++-------- src/TenancyServiceProvider.php | 7 + tests/Features/NoAttachTest.php | 6 - tests/QueueTest.php | 68 ++++---- tests/TestCase.php | 19 ++- 7 files changed, 216 insertions(+), 114 deletions(-) create mode 100644 src/Bootstrappers/PersistentQueueTenancyBootstrapper.php diff --git a/.github/workflows/queue.yml b/.github/workflows/queue.yml index 0f3ec82e..4bd30f02 100644 --- a/.github/workflows/queue.yml +++ b/.github/workflows/queue.yml @@ -25,3 +25,5 @@ jobs: cd tenancy-queue-tester TENANCY_VERSION=${VERSION_PREFIX}#${GITHUB_SHA} ./setup.sh ./test.sh + ./alternative_config.sh + PERSISTENT=1 ./test.sh diff --git a/src/Bootstrappers/PersistentQueueTenancyBootstrapper.php b/src/Bootstrappers/PersistentQueueTenancyBootstrapper.php new file mode 100644 index 00000000..90bb3d08 --- /dev/null +++ b/src/Bootstrappers/PersistentQueueTenancyBootstrapper.php @@ -0,0 +1,146 @@ +make(Dispatcher::class), $app->runningUnitTests()); + } + + public function __construct(Repository $config, QueueManager $queue) + { + $this->config = $config; + $this->queue = $queue; + + $this->setUpPayloadGenerator(); + } + + protected static function setUpJobListener(Dispatcher $dispatcher, bool $runningTests): void + { + $previousTenant = null; + + $dispatcher->listen(JobProcessing::class, function ($event) use (&$previousTenant) { + $previousTenant = tenant(); + + static::initializeTenancyForQueue($event->job->payload()['tenant_id'] ?? null); + }); + + $dispatcher->listen(JobRetryRequested::class, function ($event) use (&$previousTenant) { + $previousTenant = tenant(); + + static::initializeTenancyForQueue($event->payload()['tenant_id'] ?? null); + }); + + // If we're running tests, we make sure to clean up after any artisan('queue:work') calls + $revertToPreviousState = function ($event) use (&$previousTenant, $runningTests) { + if ($runningTests) { + static::revertToPreviousState($event->job->payload()['tenant_id'] ?? null, $previousTenant); + + // We don't need to reset $previousTenant since the value will be set again when a job is processed. + } + + // If we're not running tests, we remain in the tenant's context. This makes other JobProcessed + // listeners able to deserialize the job, including with SerializesModels, since the tenant connection + // remains open. + }; + + $dispatcher->listen(JobProcessed::class, $revertToPreviousState); // artisan('queue:work') which succeeds + $dispatcher->listen(JobFailed::class, $revertToPreviousState); // artisan('queue:work') which fails + } + + protected static function initializeTenancyForQueue(string|int|null $tenantId): void + { + if (! $tenantId) { + // The job is not tenant-aware + if (tenancy()->initialized) { + // Tenancy was initialized, so we revert back to the central context + tenancy()->end(); + } + + return; + } + + // Re-initialize tenancy between all jobs even if the tenant is the same + // so that we don't work with an outdated tenant() instance in case it + // was updated outside the queue worker. + tenancy()->end(); + + /** @var Tenant $tenant */ + $tenant = tenancy()->find($tenantId); + tenancy()->initialize($tenant); + } + + protected static function revertToPreviousState(string|int|null $tenantId, ?Tenant $previousTenant): void + { + // The job was not tenant-aware + if (! $tenantId) { + return; + } + + // Revert back to the previous tenant + if (tenant() && $previousTenant && $previousTenant->isNot(tenant())) { + tenancy()->initialize($previousTenant); + } + + // End tenancy + if (tenant() && (! $previousTenant)) { + tenancy()->end(); + } + } + + protected function setUpPayloadGenerator(): void + { + $bootstrapper = &$this; + + if (! $this->queue instanceof QueueFake) { + $this->queue->createPayloadUsing(function ($connection) use (&$bootstrapper) { + return $bootstrapper->getPayload($connection); + }); + } + } + + public function getPayload(string $connection): array + { + if (! tenancy()->initialized) { + return []; + } + + if ($this->config["queue.connections.$connection.central"]) { + return []; + } + + return [ + 'tenant_id' => tenant()->getTenantKey(), + ]; + } + + public function bootstrap(Tenant $tenant): void {} + public function revert(): void {} +} diff --git a/src/Bootstrappers/QueueTenancyBootstrapper.php b/src/Bootstrappers/QueueTenancyBootstrapper.php index 16ae043b..2ed6b7f1 100644 --- a/src/Bootstrappers/QueueTenancyBootstrapper.php +++ b/src/Bootstrappers/QueueTenancyBootstrapper.php @@ -24,16 +24,6 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper /** @var QueueManager */ protected $queue; - /** - * Don't persist the same tenant across multiple jobs even if they have the same tenant ID. - * - * This is useful when you're changing the tenant's state (e.g. properties in the `data` column) and want the next job to initialize tenancy again - * with the new data. Features like the Tenant Config are only executed when tenancy is initialized, so the re-initialization is needed in some cases. - * - * @var bool - */ - public static $forceRefresh = false; - /** * The normal constructor is only executed after tenancy is bootstrapped. * However, we're registering a hook to initialize tenancy. Therefore, @@ -68,9 +58,12 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper static::initializeTenancyForQueue($event->payload()['tenant_id'] ?? null); }); - // If we're running tests, we make sure to clean up after any artisan('queue:work') calls $revertToPreviousState = function ($event) use (&$previousTenant) { - static::revertToPreviousState($event, $previousTenant); + // In queue worker context, this reverts to the central context. + // In dispatchSync context, this reverts to the previous tenant's context. + // There's no need to reset $previousTenant here since it's always first + // set in the above listeners and the app is reverted back to that context. + static::revertToPreviousState($event->job->payload()['tenant_id'] ?? null, $previousTenant); }; $dispatcher->listen(JobProcessed::class, $revertToPreviousState); // artisan('queue:work') which succeeds @@ -79,61 +72,25 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper protected static function initializeTenancyForQueue(string|int|null $tenantId): void { - if ($tenantId === null) { - // The job is not tenant-aware - if (tenancy()->initialized) { - // Tenancy was initialized, so we revert back to the central context - tenancy()->end(); - } - + if (! $tenantId) { return; } - if (static::$forceRefresh) { - // Re-initialize tenancy between all jobs - if (tenancy()->initialized) { - tenancy()->end(); - } - - /** @var Tenant $tenant */ - $tenant = tenancy()->find($tenantId); - tenancy()->initialize($tenant); - - return; - } - - if (tenancy()->initialized) { - // Tenancy is already initialized - if (tenant()->getTenantKey() === $tenantId) { - // It's initialized for the same tenant (e.g. dispatchSync was used, or the previous job also ran for this tenant) - return; - } - } - - // Tenancy was either not initialized, or initialized for a different tenant. - // Therefore, we initialize it for the correct tenant. - /** @var Tenant $tenant */ $tenant = tenancy()->find($tenantId); tenancy()->initialize($tenant); } - protected static function revertToPreviousState(JobProcessed|JobFailed $event, ?Tenant &$previousTenant): void + protected static function revertToPreviousState(string|int|null $tenantId, ?Tenant $previousTenant): void { - $tenantId = $event->job->payload()['tenant_id'] ?? null; - - // The job was not tenant-aware + // The job was not tenant-aware so no context switch was done if (! $tenantId) { return; } - // Revert back to the previous tenant - if (tenant() && $previousTenant?->isNot(tenant())) { - tenancy()->initialize($previousTenant); - } - - // End tenancy - if (tenant() && (! $previousTenant)) { + // End tenancy when there's no previous tenant + // (= when running in a queue worker, not dispatchSync) + if (tenant() && ! $previousTenant) { tenancy()->end(); } } @@ -149,16 +106,6 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper } } - public function bootstrap(Tenant $tenant): void - { - // - } - - public function revert(): void - { - // - } - public function getPayload(string $connection): array { if (! tenancy()->initialized) { @@ -169,10 +116,11 @@ class QueueTenancyBootstrapper implements TenancyBootstrapper return []; } - $id = tenant()->getTenantKey(); - return [ - 'tenant_id' => $id, + 'tenant_id' => tenant()->getTenantKey(), ]; } + + public function bootstrap(Tenant $tenant): void {} + public function revert(): void {} } diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 29caf7a7..4059479e 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy; +use Closure; use Illuminate\Cache\CacheManager; use Illuminate\Database\Console\Migrations\FreshCommand; use Illuminate\Routing\Events\RouteMatched; @@ -18,9 +19,15 @@ use Stancl\Tenancy\Resolvers\DomainTenantResolver; class TenancyServiceProvider extends ServiceProvider { + public static Closure|null $configure = null; + /* Register services. */ public function register(): void { + if (static::$configure) { + (static::$configure)(); + } + $this->mergeConfigFrom(__DIR__ . '/../assets/config.php', 'tenancy'); $this->app->singleton(Database\DatabaseManager::class); diff --git a/tests/Features/NoAttachTest.php b/tests/Features/NoAttachTest.php index 9ba6079d..e82f3eb4 100644 --- a/tests/Features/NoAttachTest.php +++ b/tests/Features/NoAttachTest.php @@ -20,12 +20,6 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Tests\Etc\Tenant; 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 diff --git a/tests/QueueTest.php b/tests/QueueTest.php index 4d3f5ce0..2095cc84 100644 --- a/tests/QueueTest.php +++ b/tests/QueueTest.php @@ -22,16 +22,12 @@ 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; beforeEach(function () { - $this->mockConsoleOutput = false; - config([ - 'tenancy.bootstrappers' => [ - QueueTenancyBootstrapper::class, - DatabaseTenancyBootstrapper::class, - ], + 'tenancy.bootstrappers' => [DatabaseTenancyBootstrapper::class], 'queue.default' => 'redis', ]); @@ -45,7 +41,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 +72,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 +94,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 +130,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 +155,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 +203,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 +223,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 +241,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 { diff --git a/tests/TestCase.php b/tests/TestCase.php index f9a2d357..7fc1813b 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -22,6 +22,7 @@ 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; abstract class TestCase extends \Orchestra\Testbench\TestCase { @@ -85,11 +86,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 +173,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, ]; From 5d3b3d3c21375aabd86d653824955a1e13dad36e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 16 Jan 2025 10:30:06 +0100 Subject: [PATCH 02/42] [4.x] Improve RootUrl and UrlGenerator bootstrappers (#1294) * Make RootUrlBootstrapper run ONLY in CLI by default (add $rootUrlOverrideInTests), work with resolved UrlGenerator * Make resolving 'url' return a pre-created generator instance instead of creating it on every app('url') call * Take care of doubling tenant keys in TenancyUrlGenerator, add regression test for using UrlGenerator and RootUrl bootstrappers together * Fix code style (php-cs-fixer) * refactor RootUrlBootstrapper * add docblock * clarify docblock * simplify test: use concrete values instead of overly dynamic code * Fix bootstrapper order in test, add url('/') assertion * Use $this->app instead of app() * Improve TenancyUrlGenerator and RootUrlBootstrapperTest clarity * Revert attempt to maintain compatibility between the two bootstrappers * Delete bootstrapper combining test * Fix code style (php-cs-fixer) --------- Co-authored-by: lukinovec Co-authored-by: PHP CS Fixer --- src/Bootstrappers/RootUrlBootstrapper.php | 37 ++++++++++++++----- .../UrlGeneratorBootstrapper.php | 32 ++++++++-------- .../Bootstrappers/RootUrlBootstrapperTest.php | 7 +++- 3 files changed, 48 insertions(+), 28 deletions(-) diff --git a/src/Bootstrappers/RootUrlBootstrapper.php b/src/Bootstrappers/RootUrlBootstrapper.php index 6a523673..3a650169 100644 --- a/src/Bootstrappers/RootUrlBootstrapper.php +++ b/src/Bootstrappers/RootUrlBootstrapper.php @@ -7,7 +7,6 @@ namespace Stancl\Tenancy\Bootstrappers; use Closure; use Illuminate\Config\Repository; use Illuminate\Contracts\Foundation\Application; -use Illuminate\Routing\UrlGenerator; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; @@ -36,28 +35,46 @@ class RootUrlBootstrapper implements TenancyBootstrapper protected string|null $originalRootUrl = null; + /** + * You may want to selectively enable or disable this bootstrapper in specific tests. + * For instance, when using `Livewire::test()` this bootstrapper can cause problems, + * due to an internal Livewire route, so you may want to disable it, while in tests + * that are generating URLs in things like mails, the bootstrapper should be used + * just like in any queued job. + */ + public static bool $rootUrlOverrideInTests = false; + public function __construct( - protected UrlGenerator $urlGenerator, protected Repository $config, protected Application $app, ) {} public function bootstrap(Tenant $tenant): void { - if ($this->app->runningInConsole() && static::$rootUrlOverride) { - $this->originalRootUrl = $this->urlGenerator->to('/'); - - $newRootUrl = (static::$rootUrlOverride)($tenant, $this->originalRootUrl); - - $this->urlGenerator->forceRootUrl($newRootUrl); - $this->config->set('app.url', $newRootUrl); + if (static::$rootUrlOverride === null) { + return; } + + if (! $this->app->runningInConsole()) { + return; + } + + if ($this->app->runningUnitTests() && ! static::$rootUrlOverrideInTests) { + return; + } + + $this->originalRootUrl = $this->app['url']->to('/'); + + $newRootUrl = (static::$rootUrlOverride)($tenant, $this->originalRootUrl); + + $this->app['url']->forceRootUrl($newRootUrl); + $this->config->set('app.url', $newRootUrl); } public function revert(): void { if ($this->originalRootUrl) { - $this->urlGenerator->forceRootUrl($this->originalRootUrl); + $this->app['url']->forceRootUrl($this->originalRootUrl); $this->config->set('app.url', $this->originalRootUrl); } } diff --git a/src/Bootstrappers/UrlGeneratorBootstrapper.php b/src/Bootstrappers/UrlGeneratorBootstrapper.php index e3bb4a99..15116760 100644 --- a/src/Bootstrappers/UrlGeneratorBootstrapper.php +++ b/src/Bootstrappers/UrlGeneratorBootstrapper.php @@ -37,7 +37,7 @@ class UrlGeneratorBootstrapper implements TenancyBootstrapper public function revert(): void { - $this->app->bind('url', fn () => $this->originalUrlGenerator); + $this->app->extend('url', fn () => $this->originalUrlGenerator); } /** @@ -47,24 +47,22 @@ class UrlGeneratorBootstrapper implements TenancyBootstrapper */ protected function useTenancyUrlGenerator(): void { - $this->app->extend('url', function (UrlGenerator $urlGenerator, Application $app) { - $newGenerator = new TenancyUrlGenerator( - $app['router']->getRoutes(), - $urlGenerator->getRequest(), - $app['config']->get('app.asset_url'), - ); + $newGenerator = new TenancyUrlGenerator( + $this->app['router']->getRoutes(), + $this->originalUrlGenerator->getRequest(), + $this->app['config']->get('app.asset_url'), + ); - $newGenerator->defaults($urlGenerator->getDefaultParameters()); + $newGenerator->defaults($this->originalUrlGenerator->getDefaultParameters()); - $newGenerator->setSessionResolver(function () { - return $this->app['session'] ?? null; - }); - - $newGenerator->setKeyResolver(function () { - return $this->app->make('config')->get('app.key'); - }); - - return $newGenerator; + $newGenerator->setSessionResolver(function () { + return $this->app['session'] ?? null; }); + + $newGenerator->setKeyResolver(function () { + return $this->app->make('config')->get('app.key'); + }); + + $this->app->extend('url', fn () => $newGenerator); } } diff --git a/tests/Bootstrappers/RootUrlBootstrapperTest.php b/tests/Bootstrappers/RootUrlBootstrapperTest.php index ee17a802..c25a8bae 100644 --- a/tests/Bootstrappers/RootUrlBootstrapperTest.php +++ b/tests/Bootstrappers/RootUrlBootstrapperTest.php @@ -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([ From 8b131ed647be857abcf982b50d8e6030c7e896d3 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 17 Jan 2025 10:20:40 +0100 Subject: [PATCH 03/42] Allow overriding root URL in tests by default (#1296) * Allow overriding root URL in tests by default * Add todo@revisit --- src/Bootstrappers/RootUrlBootstrapper.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Bootstrappers/RootUrlBootstrapper.php b/src/Bootstrappers/RootUrlBootstrapper.php index 3a650169..f45b8d74 100644 --- a/src/Bootstrappers/RootUrlBootstrapper.php +++ b/src/Bootstrappers/RootUrlBootstrapper.php @@ -41,8 +41,10 @@ class RootUrlBootstrapper implements TenancyBootstrapper * due to an internal Livewire route, so you may want to disable it, while in tests * that are generating URLs in things like mails, the bootstrapper should be used * just like in any queued job. + * + * todo@revisit */ - public static bool $rootUrlOverrideInTests = false; + public static bool $rootUrlOverrideInTests = true; public function __construct( protected Repository $config, From c239239972a2cf1d705b4b8171499493c197e379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 21 Jan 2025 14:19:22 +0100 Subject: [PATCH 04/42] fix #1297 - require spatie/invade:* --- composer.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/composer.json b/composer.json index a63e828f..18326bac 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ "ramsey/uuid": "^4.7.3", "stancl/jobpipeline": "2.0.0-rc2", "stancl/virtualcolumn": "dev-master", - "spatie/invade": "^1.1", + "spatie/invade": "*", "laravel/prompts": "0.*" }, "require-dev": { @@ -36,7 +36,6 @@ "spatie/valuestore": "^1.2.5", "pestphp/pest": "^2.0", "larastan/larastan": "^3.0", - "spatie/invade": "^1.1", "aws/aws-sdk-php-laravel": "~3.0" }, "autoload": { From 8f2cb894ce2cfbc9a86be0a423fe149fd6cd92b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 21 Jan 2025 14:35:00 +0100 Subject: [PATCH 05/42] phpstan fixes for spatie invader --- phpstan.neon | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/phpstan.neon b/phpstan.neon index 984f5d2c..2c6e3d69 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,6 +1,5 @@ includes: - ./vendor/larastan/larastan/extension.neon - - ./vendor/spatie/invade/phpstan-extension.neon parameters: paths: @@ -16,6 +15,12 @@ parameters: ignoreErrors: - identifier: trait.unused - identifier: missingType.iterableValue + - + message: '#Spatie\\Invade\\Invader#' + identifier: method.notFound + - + message: '#Spatie\\Invade\\Invader#' + identifier: property.notFound - '#FFI#' - '#Return type(.*?) of method Stancl\\Tenancy\\Database\\Models\\Tenant\:\:newCollection\(\) should be compatible with return type#' - '#Method Stancl\\Tenancy\\Database\\Models\\Tenant\:\:newCollection\(\) should return#' From 7ce76298645e35a88a70e09bb5cfc4c78c2e8b66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 21 Jan 2025 15:23:17 +0100 Subject: [PATCH 06/42] use mysql:8 in docker --- docker-compose-m1.override.yml | 6 ------ docker-compose.yml | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/docker-compose-m1.override.yml b/docker-compose-m1.override.yml index e74781de..64b07d03 100644 --- a/docker-compose-m1.override.yml +++ b/docker-compose-m1.override.yml @@ -1,9 +1,3 @@ services: - mysql: - # platform: linux/amd64 # either one works - image: arm64v8/mysql - mysql2: - # platform: linux/amd64 # either one works - image: arm64v8/mysql mssql: image: mcr.microsoft.com/azure-sql-edge diff --git a/docker-compose.yml b/docker-compose.yml index a4857cbf..d57e508b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,7 +33,7 @@ services: stdin_open: true tty: true mysql: - image: mysql:5.7 + image: mysql:8 environment: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: main @@ -46,7 +46,7 @@ services: tmpfs: - /var/lib/mysql mysql2: - image: mysql:5.7 + image: mysql:8 environment: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: main From 25360f6b6ac08dfa92766a36cdf513a08e390e66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 21 Jan 2025 17:06:15 +0100 Subject: [PATCH 07/42] [4.x] Improve id generators (#1300) * add RandomIntGenerator * remove string assertions * make int ranges configurable * update test to use min & max --- assets/config.php | 1 + src/Contracts/UniqueIdentifierGenerator.php | 2 +- .../RandomHexGenerator.php | 2 +- .../RandomIntGenerator.php | 22 +++++++++++++++++++ .../RandomStringGenerator.php | 2 +- .../UUIDGenerator.php | 2 +- tests/TenantModelTest.php | 16 ++++++++++++++ 7 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 src/UniqueIdentifierGenerators/RandomIntGenerator.php diff --git a/assets/config.php b/assets/config.php index 3a521a6c..9a70647e 100644 --- a/assets/config.php +++ b/assets/config.php @@ -33,6 +33,7 @@ return [ * * @see \Stancl\Tenancy\UniqueIdentifierGenerators\UUIDGenerator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomHexGenerator + * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomIntGenerator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator */ 'id_generator' => UniqueIdentifierGenerators\UUIDGenerator::class, diff --git a/src/Contracts/UniqueIdentifierGenerator.php b/src/Contracts/UniqueIdentifierGenerator.php index 14d91ae0..939f433c 100644 --- a/src/Contracts/UniqueIdentifierGenerator.php +++ b/src/Contracts/UniqueIdentifierGenerator.php @@ -11,5 +11,5 @@ interface UniqueIdentifierGenerator /** * Generate a unique identifier for a model. */ - public static function generate(Model $model): string; + public static function generate(Model $model): string|int; } diff --git a/src/UniqueIdentifierGenerators/RandomHexGenerator.php b/src/UniqueIdentifierGenerators/RandomHexGenerator.php index 3da05208..aa63d5d6 100644 --- a/src/UniqueIdentifierGenerators/RandomHexGenerator.php +++ b/src/UniqueIdentifierGenerators/RandomHexGenerator.php @@ -18,7 +18,7 @@ class RandomHexGenerator implements UniqueIdentifierGenerator { public static int $bytes = 6; - public static function generate(Model $model): string + public static function generate(Model $model): string|int { return bin2hex(random_bytes(static::$bytes)); } diff --git a/src/UniqueIdentifierGenerators/RandomIntGenerator.php b/src/UniqueIdentifierGenerators/RandomIntGenerator.php new file mode 100644 index 00000000..427dff1a --- /dev/null +++ b/src/UniqueIdentifierGenerators/RandomIntGenerator.php @@ -0,0 +1,22 @@ +toString(); } diff --git a/tests/TenantModelTest.php b/tests/TenantModelTest.php index ca3c4902..f6e04cbb 100644 --- a/tests/TenantModelTest.php +++ b/tests/TenantModelTest.php @@ -20,8 +20,14 @@ 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; +afterEach(function () { + RandomIntGenerator::$min = 0; + RandomIntGenerator::$max = PHP_INT_MAX; +}); + test('created event is dispatched', function () { Event::fake([TenantCreated::class]); @@ -87,6 +93,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); From 30ee4e952982faff0d442d14fafe88731488383c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 14 Feb 2025 08:19:02 +0100 Subject: [PATCH 08/42] [4.x] Fix 1267: early return in runForMultiple if an empty array is passed (#1286) * fix 1267: early return in runForMultiple if an empty array is passed * Test that runForMulltiple runs the passed closure for the right tenants * Correct comment --------- Co-authored-by: lukinovec --- src/Tenancy.php | 6 ++- tests/RunForMultipleTest.php | 73 ++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 tests/RunForMultipleTest.php diff --git a/src/Tenancy.php b/src/Tenancy.php index 7e509481..31947dbe 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -204,8 +204,10 @@ class Tenancy // Wrap string in array $tenants = is_string($tenants) ? [$tenants] : $tenants; - // Use all tenants if $tenants is falsy - $tenants = $tenants ?: $this->model()->cursor(); // todo@phpstan phpstan thinks this isn't needed, but tests fail without it + // If $tenants is falsy by this point (e.g. an empty array) there's no work to be done + if (! $tenants) { + return; + } $originalTenant = $this->tenant; diff --git a/tests/RunForMultipleTest.php b/tests/RunForMultipleTest.php new file mode 100644 index 00000000..c82385f6 --- /dev/null +++ b/tests/RunForMultipleTest.php @@ -0,0 +1,73 @@ +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'); + }); + } +}); From fffaf7c58cef4d8a0bebd4f7e05d5b6cd92c451c Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 14 Feb 2025 08:48:16 +0100 Subject: [PATCH 09/42] Test ecnrypted casts (#1284) --- tests/TenantDatabaseManagerTest.php | 46 +++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index 0b5376d9..b10d5ac3 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -405,6 +405,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 +565,13 @@ function createUsersTable() $table->timestamps(); }); } + +class TenantWithEncryptedPassword extends Tenant +{ + protected function casts(): array + { + return [ + 'tenancy_db_password' => 'encrypted', + ]; + } +} From cecf07a8c9e84af95f5bb1af26085222af60f1fe Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 14 Feb 2025 13:57:29 +0100 Subject: [PATCH 10/42] [4.x] Add tenant parameter to `defaults()` in `UrlGeneratorBootstrapper` (#1311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Pass tenant parameter using defaults in UrlGeneratorBootstrapper, update tests accordingly (wip) * Fix code style (php-cs-fixer) * Update bootstrapper * Improve TenancyUrlGenerator docblocks * Improve bootstrapper/TenancyUrlGenerator tests (WIP) * Improve route() name prefixing test * Keep `UrlGeneratorBootstrapper::$addTenantParameterToDefaults` disabled by default * Add `$override` functionality to TenancyUrlGenerator * Test $override functionality, update new defaults in the bootstrapper tests * Fix code style (php-cs-fixer) * Update comments * Update routeNameOverride() * cleanup --------- Co-authored-by: github-actions[bot] Co-authored-by: Samuel Štancl --- .../UrlGeneratorBootstrapper.php | 28 +++- src/Overrides/TenancyUrlGenerator.php | 79 ++++++--- .../UrlGeneratorBootstrapperTest.php | 152 ++++++++++-------- 3 files changed, 170 insertions(+), 89 deletions(-) diff --git a/src/Bootstrappers/UrlGeneratorBootstrapper.php b/src/Bootstrappers/UrlGeneratorBootstrapper.php index 15116760..6158f22a 100644 --- a/src/Bootstrappers/UrlGeneratorBootstrapper.php +++ b/src/Bootstrappers/UrlGeneratorBootstrapper.php @@ -10,6 +10,7 @@ use Illuminate\Support\Facades\URL; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Overrides\TenancyUrlGenerator; +use Stancl\Tenancy\Resolvers\PathTenantResolver; /** * Makes the app use TenancyUrlGenerator (instead of Illuminate\Routing\UrlGenerator) which: @@ -19,10 +20,20 @@ use Stancl\Tenancy\Overrides\TenancyUrlGenerator; * Used with path and query string identification. * * @see TenancyUrlGenerator - * @see \Stancl\Tenancy\Resolvers\PathTenantResolver + * @see PathTenantResolver */ class UrlGeneratorBootstrapper implements TenancyBootstrapper { + /** + * Should the tenant route parameter get added to TenancyUrlGenerator::defaults(). + * + * This is recommended when using path identification since defaults() generally has better support in integrations, + * namely Ziggy, compared to TenancyUrlGenerator::$passTenantParameterToRoutes. + * + * With query string identification, this has no effect since URL::defaults() only works for route paramaters. + */ + public static bool $addTenantParameterToDefaults = true; + public function __construct( protected Application $app, protected UrlGenerator $originalUrlGenerator, @@ -32,7 +43,7 @@ class UrlGeneratorBootstrapper implements TenancyBootstrapper { URL::clearResolvedInstances(); - $this->useTenancyUrlGenerator(); + $this->useTenancyUrlGenerator($tenant); } public function revert(): void @@ -45,7 +56,7 @@ class UrlGeneratorBootstrapper implements TenancyBootstrapper * * @see \Illuminate\Routing\RoutingServiceProvider registerUrlGenerator() */ - protected function useTenancyUrlGenerator(): void + protected function useTenancyUrlGenerator(Tenant $tenant): void { $newGenerator = new TenancyUrlGenerator( $this->app['router']->getRoutes(), @@ -53,7 +64,16 @@ class UrlGeneratorBootstrapper implements TenancyBootstrapper $this->app['config']->get('app.asset_url'), ); - $newGenerator->defaults($this->originalUrlGenerator->getDefaultParameters()); + $defaultParameters = $this->originalUrlGenerator->getDefaultParameters(); + + if (static::$addTenantParameterToDefaults) { + $defaultParameters = array_merge( + $defaultParameters, + [PathTenantResolver::tenantParameterName() => $tenant->getTenantKey()] + ); + } + + $newGenerator->defaults($defaultParameters); $newGenerator->setSessionResolver(function () { return $this->app['session'] ?? null; diff --git a/src/Overrides/TenancyUrlGenerator.php b/src/Overrides/TenancyUrlGenerator.php index 7c0a7879..53798c4e 100644 --- a/src/Overrides/TenancyUrlGenerator.php +++ b/src/Overrides/TenancyUrlGenerator.php @@ -13,39 +13,77 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver; /** * This class is used in place of the default UrlGenerator when UrlGeneratorBootstrapper is enabled. * - * TenancyUrlGenerator does two extra things: - * 1. Autofill the {tenant} parameter in the tenant context with the current tenant if $passTenantParameterToRoutes is enabled (enabled by default) - * 2. Prepend the route name with `tenant.` (or the configured prefix) if $prefixRouteNames is enabled (disabled by default) + * TenancyUrlGenerator does a few extra things: + * - Autofills the tenant parameter in the tenant context with the current tenant. + * This is done either by: + * - URL::defaults() -- if UrlGeneratorBootstrapper::$addTenantParameterToDefaults is enabled. + * This generally has the best support since tools like e.g. Ziggy read defaults(). + * - Automatically passing ['tenant' => ...] to each route() call -- if TenancyUrlGenerator::$passTenantParameterToRoutes is enabled + * This is a more universal solution since it supports both path identification and query parameter identification. * - * Both of these can be skipped by passing the $bypassParameter (`['central' => true]` by default) + * - Prepends route names passed to route() and URL::temporarySignedRoute() + * with `tenant.` (or the configured prefix) if $prefixRouteNames is enabled. + * This is primarily useful when using route cloning with path identification. + * + * To bypass this behavior on any single route() call, pass the $bypassParameter as true (['central' => true] by default). */ class TenancyUrlGenerator extends UrlGenerator { /** - * Parameter which bypasses the behavior modification of route() and temporarySignedRoute(). + * Parameter which works as a flag for bypassing the behavior modification of route() and temporarySignedRoute(). * - * E.g. route('tenant') => app.test/{tenant}/tenant (or app.test/tenant?tenant=tenantKey if the route doesn't accept the tenant parameter) - * route('tenant', [$bypassParameter => true]) => app.test/tenant. + * For example, in tenant context: + * Route::get('/', ...)->name('home'); + * // query string identification + * Route::get('/tenant', ...)->middleware(InitializeTenancyByRequestData::class)->name('tenant.home'); + * - route('home') => app.test/tenant?tenant=tenantKey + * - route('home', [$bypassParameter => true]) => app.test/ + * - route('tenant.home', [$bypassParameter => true]) => app.test/tenant -- no query string added + * + * Note: UrlGeneratorBootstrapper::$addTenantParameterToDefaults is not affected by this, though + * it doesn't matter since it doesn't pass any extra parameters when not needed. + * + * @see UrlGeneratorBootstrapper */ public static string $bypassParameter = 'central'; /** - * Determine if the route names passed to `route()` or `temporarySignedRoute()` - * should get prefixed with the tenant route name prefix. + * Should route names passed to route() or temporarySignedRoute() + * get prefixed with the tenant route name prefix. * - * This is useful when using path identification with packages that generate URLs, - * like Jetstream, so that you don't have to manually prefix route names passed to each route() call. + * This is useful when using e.g. path identification with third-party packages + * where you don't have control over all route() calls or don't want to change + * too many files. Often this will be when using route cloning. */ public static bool $prefixRouteNames = false; /** - * Determine if the tenant parameter should get passed - * to the links generated by `route()` or `temporarySignedRoute()` whenever available - * (enabled by default – works with both path and query string identification). + * Should the tenant parameter be passed to route() or temporarySignedRoute() calls. * - * With path identification, you can disable this and use URL::defaults() instead (as an alternative solution). + * This is useful with path or query parameter identification. The former can be handled + * more elegantly using UrlGeneratorBootstrapper::$addTenantParameterToDefaults. + * + * @see UrlGeneratorBootstrapper */ - public static bool $passTenantParameterToRoutes = true; + public static bool $passTenantParameterToRoutes = false; + + /** + * Route name overrides. + * + * Note: This behavior can be bypassed using $bypassParameter just like + * $prefixRouteNames and $passTenantParameterToRoutes. + * + * Example from a Jetstream integration: + * [ + * 'profile.show' => 'tenant.profile.show', + * 'two-factor.login' => 'tenant.two-factor.login', + * ] + * + * In the tenant context: + * - `route('profile.show')` will return a URL as if you called `route('tenant.profile.show')`. + * - `route('profile.show', ['central' => true])` will return a URL as if you called `route('profile.show')`. + */ + public static array $overrides = []; /** * Override the route() method so that the route name gets prefixed @@ -99,7 +137,7 @@ class TenancyUrlGenerator extends UrlGenerator protected function prepareRouteInputs(string $name, array $parameters): array { if (! $this->routeBehaviorModificationBypassed($parameters)) { - $name = $this->prefixRouteName($name); + $name = $this->routeNameOverride($name) ?? $this->prefixRouteName($name); $parameters = $this->addTenantParameter($parameters); } @@ -124,10 +162,15 @@ class TenancyUrlGenerator extends UrlGenerator } /** - * If `tenant()` isn't null, add tenant paramter to the passed parameters. + * If `tenant()` isn't null, add the tenant parameter to the passed parameters. */ protected function addTenantParameter(array $parameters): array { return tenant() && static::$passTenantParameterToRoutes ? array_merge($parameters, [PathTenantResolver::tenantParameterName() => tenant()->getTenantKey()]) : $parameters; } + + protected function routeNameOverride(string $name): string|null + { + return static::$overrides[$name] ?? null; + } } diff --git a/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php b/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php index 8ef3169d..77d50073 100644 --- a/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php +++ b/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php @@ -19,12 +19,14 @@ 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,36 +43,28 @@ 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() { +test('tenancy url generator 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]); + Route::get('/tenant/home', fn () => route('tenant.home'))->name('tenant.home'); $tenant = Tenant::create(); - $tenantKey = $tenant->getTenantKey(); $centralRouteUrl = route('home'); - $tenantRouteUrl = route('tenant.home', ['tenant' => $tenantKey]); - TenancyUrlGenerator::$bypassParameter = 'bypassParameter'; + $tenantRouteUrl = route('tenant.home'); 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($centralRouteUrl); + // When $prefixRouteNames is true, the route name passed to the route() helper ('home') gets prefixed with 'tenant.' 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 + // The 'tenant.home' route name doesn't get prefixed -- it is already prefixed with 'tenant.' expect(route('tenant.home'))->toBe($tenantRouteUrl); // Ending tenancy reverts route() behavior changes @@ -79,6 +73,76 @@ test('url generator bootstrapper can prefix route names passed to the route help expect(route('home'))->toBe($centralRouteUrl); }); +test('the route helper can receive the tenant parameter automatically', function ( + string $identification, + bool $addTenantParameterToDefaults, + bool $passTenantParameterToRoutes, +) { + config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); + + $appUrl = config('app.url'); + + UrlGeneratorBootstrapper::$addTenantParameterToDefaults = $addTenantParameterToDefaults; + + // When the tenant parameter isn't added to defaults, the tenant parameter has to be passed "manually" + // by setting $passTenantParameterToRoutes to true. This is only preferable with query string identification. + // With path identification, this ultimately doesn't have any effect + // if UrlGeneratorBootstrapper::$addTenantParameterToDefaults is true, + // but TenancyUrlGenerator::$passTenantParameterToRoutes can still be used instead. + TenancyUrlGenerator::$passTenantParameterToRoutes = $passTenantParameterToRoutes; + + $tenant = Tenant::create(); + $tenantKey = $tenant->getTenantKey(); + + Route::get('/central/home', fn () => route('home'))->name('home'); + + $tenantRoute = $identification === InitializeTenancyByPath::class ? "/{tenant}/home" : "/tenant/home"; + + Route::get($tenantRoute, fn () => route('tenant.home')) + ->name('tenant.home') + ->middleware(['tenant', $identification]); + + tenancy()->initialize($tenant); + + $expectedUrl = match (true) { + $identification === InitializeTenancyByRequestData::class && $passTenantParameterToRoutes => "{$appUrl}/tenant/home?tenant={$tenantKey}", + $identification === InitializeTenancyByRequestData::class => "{$appUrl}/tenant/home", // $passTenantParameterToRoutes is false + $identification === InitializeTenancyByPath::class && ($addTenantParameterToDefaults || $passTenantParameterToRoutes) => "{$appUrl}/{$tenantKey}/home", + $identification === InitializeTenancyByPath::class => null, // Should throw an exception -- route() doesn't receive the tenant parameter in this case + }; + + if ($expectedUrl === null) { + expect(fn () => route('tenant.home'))->toThrow(UrlGenerationException::class, 'Missing parameter: tenant'); + } else { + expect(route('tenant.home'))->toBe($expectedUrl); + } +})->with([InitializeTenancyByPath::class, InitializeTenancyByRequestData::class]) + ->with([true, false]) // UrlGeneratorBootstrapper::$addTenantParameterToDefaults + ->with([true, false]); // TenancyUrlGenerator::$passTenantParameterToRoutes + +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 () { $tenantParameterName = PathTenantResolver::tenantParameterName(); @@ -105,54 +169,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); -}); From 29bfe532fa6ce8f9722b7a3c70cb2946a1d54568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 14 Feb 2025 14:20:41 +0100 Subject: [PATCH 11/42] Add laravel/framework:dev-master to CI matrix --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef40e072..f3fbde73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ jobs: include: - laravel: "^11.0" php: "8.4" + - laravel: "dev-master" + php: "8.4" steps: - name: Checkout From 7bc2bb6f6a8a7b7ea6eea7ae53b8b0bdc85ba2c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 14 Feb 2025 14:35:34 +0100 Subject: [PATCH 12/42] Revert "Add laravel/framework:dev-master to CI matrix" This reverts commit 29bfe532fa6ce8f9722b7a3c70cb2946a1d54568. --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3fbde73..ef40e072 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,6 @@ jobs: include: - laravel: "^11.0" php: "8.4" - - laravel: "dev-master" - php: "8.4" steps: - name: Checkout From b9cc63feedff846913b77137e74b71ecf16e11df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 19 Feb 2025 12:02:58 +0100 Subject: [PATCH 13/42] handle exceptions in Tenancy:run() --- src/Tenancy.php | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/Tenancy.php b/src/Tenancy.php index 31947dbe..5ab6400d 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -77,20 +77,23 @@ class Tenancy public function run(Tenant $tenant, Closure $callback): mixed { $originalTenant = $this->tenant; + $result = null; - $this->initialize($tenant); - $result = $callback($tenant); + try { + $this->initialize($tenant); + $result = $callback($tenant); + } finally { + if ($originalTenant) { + $this->initialize($originalTenant); + } else { + $this->end(); + } + } if ($result instanceof PendingDispatch) { // #1277 $result = null; } - if ($originalTenant) { - $this->initialize($originalTenant); - } else { - $this->end(); - } - return $result; } From ffad2db103dd3279db8ee62d152250f913d374e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 19 Feb 2025 12:28:38 +0100 Subject: [PATCH 14/42] fix regression in previous commit: consume PendingDispatch *before* reverting context --- src/Tenancy.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Tenancy.php b/src/Tenancy.php index 5ab6400d..f96c0a51 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -83,6 +83,10 @@ class Tenancy $this->initialize($tenant); $result = $callback($tenant); } finally { + if ($result instanceof PendingDispatch) { // #1277 + $result = null; + } + if ($originalTenant) { $this->initialize($originalTenant); } else { @@ -90,10 +94,6 @@ class Tenancy } } - if ($result instanceof PendingDispatch) { // #1277 - $result = null; - } - return $result; } From eac88dcc2a5a294f4fb5a2d412d79fa91af1cd0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 19 Feb 2025 12:38:35 +0100 Subject: [PATCH 15/42] contributing note about mssql on Apple Silicon --- CONTRIBUTING.md | 4 +++- docker-compose.yml | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 498534f7..7d256f42 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,10 +24,12 @@ To fix this, simply delete the database memory by shutting down containers and s Same thing for `SQLSTATE[HY000]: General error: 1615 Prepared statement needs to be re-prepared`. -### Docker on M1 +### Docker on Apple Silicon Run `composer docker-m1` to symlink `docker-compose-m1.override.yml` to `docker-compose.override.yml`. This will reconfigure a few services in the docker compose config to be compatible with M1. +2025 note: By now only MSSQL doesn't have good M1 support. The override also started being a bit problematic, having issues with starts, often requiring multiple starts. This often makes the original image in docker-compose more stable, even if it's amd64-only. With Rosetta enabled, you should be able to use it without issues. + ### Coverage reports To run tests and generate coverage reports, use `composer test-full`. diff --git a/docker-compose.yml b/docker-compose.yml index d57e508b..9d5eb6c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -72,12 +72,12 @@ services: tmpfs: - /var/lib/postgresql/data mssql: - image: mcr.microsoft.com/mssql/server:2019-latest + image: mcr.microsoft.com/mssql/server:2022-latest environment: - ACCEPT_EULA=Y - SA_PASSWORD=P@ssword # todo reuse env from above healthcheck: # https://github.com/Microsoft/mssql-docker/issues/133#issuecomment-1995615432 - test: timeout 1 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/1433' + test: timeout 2 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/1433' interval: 10s timeout: 10s retries: 10 From 657e165cc8d1ac92622c28ee87798330a69defef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 20 Feb 2025 20:49:09 +0100 Subject: [PATCH 16/42] [4.x] Cleanup (#1317) * cleanup, resolve todos, add immediate todos * Improve path_identification_middleware docblock * rename leave() method in tests * wip fix hardcoded values making assumptions about the parameters used in routing * defaultParameterNames * fix CreatesDatabaseUsers return values * $tenant -> tenant() * resolve more todos * make comment block a complete block * Correct useTenantRoutesInFortify(), delete unused import * test fixes * remove todos * remove JobPipeline todo * simplify comment example * remove todo * fix VERSION_PREFIX in queue.yml --------- Co-authored-by: lukinovec --- .github/workflows/queue.yml | 2 +- assets/TenancyServiceProvider.stub.php | 88 +++++++------------ assets/config.php | 17 ++-- .../FilesystemTenancyBootstrapper.php | 2 +- .../Integrations/FortifyRouteBootstrapper.php | 79 ++++++++--------- src/Bootstrappers/RootUrlBootstrapper.php | 9 +- .../UrlGeneratorBootstrapper.php | 5 +- .../Concerns/CreatesDatabaseUsers.php | 9 +- src/Database/Concerns/HasPending.php | 4 +- .../MicrosoftSQLDatabaseManager.php | 2 - ...ssionControlledPostgreSQLSchemaManager.php | 2 + src/Events/Contracts/TenantEvent.php | 12 +-- ...enantCouldNotBeIdentifiedByIdException.php | 2 - src/Features/UserImpersonation.php | 2 +- src/Listeners/ForgetTenantParameter.php | 2 + .../PreventAccessFromUnwantedDomains.php | 2 +- src/Overrides/CacheManager.php | 2 - src/Overrides/TenancyUrlGenerator.php | 18 +++- src/Resolvers/PathTenantResolver.php | 7 +- src/ResourceSyncing/SyncMaster.php | 2 - .../FortifyRouteBootstrapperTest.php | 50 ++++------- tests/TenantAssetTest.php | 8 +- tests/TenantUserImpersonationTest.php | 4 +- 23 files changed, 148 insertions(+), 182 deletions(-) diff --git a/.github/workflows/queue.yml b/.github/workflows/queue.yml index 4bd30f02..2b3a63c5 100644 --- a/.github/workflows/queue.yml +++ b/.github/workflows/queue.yml @@ -11,7 +11,7 @@ jobs: - name: Prepare composer version constraint prefix run: | BRANCH=${GITHUB_REF#refs/heads/} - if [[ $BRANCH =~ ^[0-9] ]]; then + if [[ $BRANCH =~ ^[0-9]\.x$ ]]; then echo "VERSION_PREFIX=${BRANCH}-dev" >> $GITHUB_ENV else echo "VERSION_PREFIX=dev-${BRANCH}" >> $GITHUB_ENV diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 3d53529e..708e4450 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -145,24 +145,19 @@ class TenancyServiceProvider extends ServiceProvider */ protected function overrideUrlInTenantContext(): void { - /** - * Import your tenant model! - * - * \Stancl\Tenancy\Bootstrappers\RootUrlBootstrapper::$rootUrlOverride = function (Tenant $tenant, string $originalRootUrl) { - * $tenantDomain = $tenant instanceof \Stancl\Tenancy\Contracts\SingleDomainTenant - * ? $tenant->domain - * : $tenant->domains->first()->domain; - * - * $scheme = str($originalRootUrl)->before('://'); - * - * // If you're using domain identification: - * return $scheme . '://' . $tenantDomain . '/'; - * - * // If you're using subdomain identification: - * $originalDomain = str($originalRootUrl)->after($scheme . '://'); - * return $scheme . '://' . $tenantDomain . '.' . $originalDomain . '/'; - * }; - */ + // \Stancl\Tenancy\Bootstrappers\RootUrlBootstrapper::$rootUrlOverride = function (Tenant $tenant, string $originalRootUrl) { + // $tenantDomain = $tenant instanceof \Stancl\Tenancy\Contracts\SingleDomainTenant + // ? $tenant->domain + // : $tenant->domains->first()->domain; + // $scheme = str($originalRootUrl)->before('://'); + // + // // If you're using domain identification: + // return $scheme . '://' . $tenantDomain . '/'; + // + // // If you're using subdomain identification: + // $originalDomain = str($originalRootUrl)->after($scheme . '://'); + // return $scheme . '://' . $tenantDomain . '.' . $originalDomain . '/'; + // }; } public function register() @@ -178,32 +173,17 @@ class TenancyServiceProvider extends ServiceProvider $this->makeTenancyMiddlewareHighestPriority(); $this->overrideUrlInTenantContext(); - /** - * Include soft deleted resources in synced resource queries. - * - * ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::$scopeGetModelQuery = function (Builder $query) { - * if ($query->hasMacro('withTrashed')) { - * $query->withTrashed(); - * } - * }; - */ + // // Include soft deleted resources in synced resource queries. + // ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::$scopeGetModelQuery = function (Builder $query) { + // if ($query->hasMacro('withTrashed')) { + // $query->withTrashed(); + // } + // }; - /** - * To make Livewire v3 work with Tenancy, make the update route universal. - * - * Livewire::setUpdateRoute(function ($handle) { - * return RouteFacade::post('/livewire/update', $handle)->middleware(['web', 'universal']); - * }); - */ - - // if (InitializeTenancyByRequestData::inGlobalStack()) { - // FortifyRouteBootstrapper::$fortifyHome = 'dashboard'; - // TenancyUrlGenerator::$prefixRouteNames = false; - // } - - if (tenancy()->globalStackHasMiddleware(config('tenancy.identification.path_identification_middleware'))) { - TenancyUrlGenerator::$prefixRouteNames = true; - } + // // To make Livewire v3 work with Tenancy, make the update route universal. + // Livewire::setUpdateRoute(function ($handle) { + // return RouteFacade::post('/livewire/update', $handle)->middleware(['web', 'universal', \Stancl\Tenancy::defaultMiddleware()]); + // }); } protected function bootEvents() @@ -228,10 +208,7 @@ class TenancyServiceProvider extends ServiceProvider ->group(base_path('routes/tenant.php')); } - // Delete this condition when using route-level path identification - if (tenancy()->globalStackHasMiddleware(config('tenancy.identification.path_identification_middleware'))) { - $this->cloneRoutes(); - } + // $this->cloneRoutes(); }); } @@ -245,16 +222,13 @@ class TenancyServiceProvider extends ServiceProvider /** @var CloneRoutesAsTenant $cloneRoutes */ $cloneRoutes = $this->app->make(CloneRoutesAsTenant::class); - /** - * You can provide a closure for cloning a specific route, e.g.: - * $cloneRoutes->cloneUsing('welcome', function () { - * RouteFacade::get('/tenant-welcome', fn () => 'Current tenant: ' . tenant()->getTenantKey()) - * ->middleware(['universal', InitializeTenancyByPath::class]) - * ->name('tenant.welcome'); - * }); - * - * To see the default behavior of cloning the universal routes, check out the cloneRoute() method in CloneRoutesAsTenant. - */ + // // You can provide a closure for cloning a specific route, e.g.: + // $cloneRoutes->cloneUsing('welcome', function () { + // RouteFacade::get('/tenant-welcome', fn () => 'Current tenant: ' . tenant()->getTenantKey()) + // ->middleware(['universal', InitializeTenancyByPath::class]) + // ->name('tenant.welcome'); + // }); + // // To see the default behavior of cloning the universal routes, check out the cloneRoute() method in CloneRoutesAsTenant. $cloneRoutes->handle(); } diff --git a/assets/config.php b/assets/config.php index 9a70647e..db3820bf 100644 --- a/assets/config.php +++ b/assets/config.php @@ -91,7 +91,7 @@ return [ /** * Identification middleware tenancy recognizes as path identification middleware. * - * This is used during determining whether whether a path identification is used + * This is used for determining if a path identification middleware is used * during operations specific to path identification, e.g. forgetting the tenant parameter in ForgetTenantParameter. * * If you're using a custom path identification middleware, add it here. @@ -118,6 +118,7 @@ return [ Resolvers\PathTenantResolver::class => [ 'tenant_parameter_name' => 'tenant', 'tenant_model_column' => null, // null = tenant key + 'tenant_route_name_prefix' => null, // null = 'tenant.' 'allowed_extra_model_columns' => [], // used with binding route fields 'cache' => false, @@ -130,8 +131,6 @@ return [ 'cache_store' => null, // null = default ], ], - - // todo@docs update integration guides to use Stancl\Tenancy::defaultMiddleware() ], /** @@ -215,7 +214,14 @@ return [ // 'pgsql' => Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLSchemaManager::class, // Also permission controlled ], - // todo@docblock + /* + * Drop tenant databases when `php artisan migrate:fresh` is used. + * You may want to use this locally since deleting tenants only + * deletes their databases when they're deleted individually, not + * when the records are mass deleted from the database. + * + * Note: This overrides the default MigrateFresh command. + */ 'drop_tenant_databases_on_migrate_fresh' => false, ], @@ -320,7 +326,6 @@ return [ */ 'url_override' => [ // Note that the local disk you add must exist in the tenancy.filesystem.root_override config - // todo@v4 Rename url_override to something that describes the config key better 'public' => 'public-%tenant%', ], @@ -356,7 +361,7 @@ return [ * leave asset() helper tenancy disabled and explicitly use tenant_asset() calls in places * where you want to use tenant-specific assets (product images, avatars, etc). */ - 'asset_helper_tenancy' => false, // todo@rename asset_helper_override? + 'asset_helper_override' => false, ], /** diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index ab7dc856..d5088c5c 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -92,7 +92,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper protected function assetHelper(string|false $suffix): void { - if (! $this->app['config']['tenancy.filesystem.asset_helper_tenancy']) { + if (! $this->app['config']['tenancy.filesystem.asset_helper_override']) { return; } diff --git a/src/Bootstrappers/Integrations/FortifyRouteBootstrapper.php b/src/Bootstrappers/Integrations/FortifyRouteBootstrapper.php index 2c5712ee..05f3fa11 100644 --- a/src/Bootstrappers/Integrations/FortifyRouteBootstrapper.php +++ b/src/Bootstrappers/Integrations/FortifyRouteBootstrapper.php @@ -7,54 +7,52 @@ namespace Stancl\Tenancy\Bootstrappers\Integrations; use Illuminate\Config\Repository; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; -use Stancl\Tenancy\Enums\Context; use Stancl\Tenancy\Resolvers\PathTenantResolver; /** - * Allows customizing Fortify action redirects - * so that they can also redirect to tenant routes instead of just the central routes. + * Allows customizing Fortify action redirects so that they can also redirect + * to tenant routes instead of just the central routes. * - * Works with path and query string identification. + * This should be used with path/query string identification OR when using Fortify + * universally, including with domains. + * + * When using domain identification, there's no need to pass the tenant parameter, + * you only want to customize the routes being used, so you can set $passTenantParameter + * to false. */ class FortifyRouteBootstrapper implements TenancyBootstrapper { /** - * Make Fortify actions redirect to custom routes. + * Fortify redirects that should be used in tenant context. * - * For each route redirect, specify the intended route context (central or tenant). - * Based on the provided context, we pass the tenant parameter to the route (or not). - * The tenant parameter is only passed to the route when you specify its context as tenant. - * - * The route redirects should be in the following format: - * - * 'fortify_action' => [ - * 'route_name' => 'tenant.route', - * 'context' => Context::TENANT, - * ] - * - * For example: - * - * FortifyRouteBootstrapper::$fortifyRedirectMap = [ - * // On logout, redirect the user to the "bye" route in the central app - * 'logout' => [ - * 'route_name' => 'bye', - * 'context' => Context::CENTRAL, - * ], - * - * // On login, redirect the user to the "welcome" route in the tenant app - * 'login' => [ - * 'route_name' => 'welcome', - * 'context' => Context::TENANT, - * ], - * ]; + * Syntax: ['redirect_name' => 'tenant_route_name'] */ public static array $fortifyRedirectMap = []; + /** + * Should the tenant parameter be passed to fortify routes in the tenant context. + * + * This should be enabled with path/query string identification and disabled with domain identification. + * + * You may also disable this when using path/query string identification if passing the tenant parameter + * is handled in another way (TenancyUrlGenerator::$passTenantParameter for both, + * UrlGeneratorBootstrapper:$addTenantParameterToDefaults for path identification). + */ + public static bool $passTenantParameter = true; + /** * Tenant route that serves as Fortify's home (e.g. a tenant dashboard route). * This route will always receive the tenant parameter. */ - public static string $fortifyHome = 'tenant.dashboard'; + public static string|null $fortifyHome = 'tenant.dashboard'; + + /** + * Use default parameter names ('tenant' name and tenant key value) instead of the parameter name + * and column name configured in the path resolver config. + * + * You want to enable this when using query string identification while having customized that config. + */ + public static bool $defaultParameterNames = false; protected array $originalFortifyConfig = []; @@ -76,27 +74,22 @@ class FortifyRouteBootstrapper implements TenancyBootstrapper protected function useTenantRoutesInFortify(Tenant $tenant): void { - $tenantKey = $tenant->getTenantKey(); - $tenantParameterName = PathTenantResolver::tenantParameterName(); + $tenantParameterName = static::$defaultParameterNames ? 'tenant' : PathTenantResolver::tenantParameterName(); + $tenantParameterValue = static::$defaultParameterNames ? $tenant->getTenantKey() : PathTenantResolver::tenantParameterValue($tenant); - $generateLink = function (array $redirect) use ($tenantKey, $tenantParameterName) { - // Specifying the context is only required with query string identification - // because with path identification, the tenant parameter should always present - $passTenantParameter = $redirect['context'] === Context::TENANT; - - // Only pass the tenant parameter when the user should be redirected to a tenant route - return route($redirect['route_name'], $passTenantParameter ? [$tenantParameterName => $tenantKey] : []); + $generateLink = function (string $redirect) use ($tenantParameterValue, $tenantParameterName) { + return route($redirect, static::$passTenantParameter ? [$tenantParameterName => $tenantParameterValue] : []); }; // Get redirect URLs for the configured redirect routes $redirects = array_merge( $this->originalFortifyConfig['redirects'] ?? [], // Fortify config redirects - array_map(fn (array $redirect) => $generateLink($redirect), static::$fortifyRedirectMap), // Mapped redirects + array_map(fn (string $redirect) => $generateLink($redirect), static::$fortifyRedirectMap), // Mapped redirects ); if (static::$fortifyHome) { // Generate the home route URL with the tenant parameter and make it the Fortify home route - $this->config->set('fortify.home', route(static::$fortifyHome, [$tenantParameterName => $tenantKey])); + $this->config->set('fortify.home', route(static::$fortifyHome, static::$passTenantParameter ? [$tenantParameterName => $tenantParameterValue] : [])); } $this->config->set('fortify.redirects', $redirects); diff --git a/src/Bootstrappers/RootUrlBootstrapper.php b/src/Bootstrappers/RootUrlBootstrapper.php index f45b8d74..5958737c 100644 --- a/src/Bootstrappers/RootUrlBootstrapper.php +++ b/src/Bootstrappers/RootUrlBootstrapper.php @@ -36,13 +36,8 @@ class RootUrlBootstrapper implements TenancyBootstrapper protected string|null $originalRootUrl = null; /** - * You may want to selectively enable or disable this bootstrapper in specific tests. - * For instance, when using `Livewire::test()` this bootstrapper can cause problems, - * due to an internal Livewire route, so you may want to disable it, while in tests - * that are generating URLs in things like mails, the bootstrapper should be used - * just like in any queued job. - * - * todo@revisit + * Overriding the root url may cause issues in *some* tests, so you can disable + * the behavior by setting this property to false. */ public static bool $rootUrlOverrideInTests = true; diff --git a/src/Bootstrappers/UrlGeneratorBootstrapper.php b/src/Bootstrappers/UrlGeneratorBootstrapper.php index 6158f22a..b5289904 100644 --- a/src/Bootstrappers/UrlGeneratorBootstrapper.php +++ b/src/Bootstrappers/UrlGeneratorBootstrapper.php @@ -69,7 +69,10 @@ class UrlGeneratorBootstrapper implements TenancyBootstrapper if (static::$addTenantParameterToDefaults) { $defaultParameters = array_merge( $defaultParameters, - [PathTenantResolver::tenantParameterName() => $tenant->getTenantKey()] + [ + PathTenantResolver::tenantParameterName() => PathTenantResolver::tenantParameterValue($tenant), // path identification + 'tenant' => $tenant->getTenantKey(), // query string identification + ], ); } diff --git a/src/Database/Concerns/CreatesDatabaseUsers.php b/src/Database/Concerns/CreatesDatabaseUsers.php index 8e102fd0..73d8e777 100644 --- a/src/Database/Concerns/CreatesDatabaseUsers.php +++ b/src/Database/Concerns/CreatesDatabaseUsers.php @@ -10,16 +10,11 @@ trait CreatesDatabaseUsers { public function createDatabase(TenantWithDatabase $tenant): bool { - parent::createDatabase($tenant); - - return $this->createUser($tenant->database()); + return parent::createDatabase($tenant) && $this->createUser($tenant->database()); } public function deleteDatabase(TenantWithDatabase $tenant): bool { - // Some DB engines require the user to be deleted before the database (e.g. Postgres) - $this->deleteUser($tenant->database()); - - return parent::deleteDatabase($tenant); + return $this->deleteUser($tenant->database()) && parent::deleteDatabase($tenant); } } diff --git a/src/Database/Concerns/HasPending.php b/src/Database/Concerns/HasPending.php index 8142e0ea..ffb35f0c 100644 --- a/src/Database/Concerns/HasPending.php +++ b/src/Database/Concerns/HasPending.php @@ -23,6 +23,8 @@ use Stancl\Tenancy\Events\PullingPendingTenant; */ trait HasPending { + public static string $pendingSinceCast = 'timestamp'; + /** Boot the trait. */ public static function bootHasPending(): void { @@ -32,7 +34,7 @@ trait HasPending /** Initialize the trait. */ public function initializeHasPending(): void { - $this->casts['pending_since'] = 'timestamp'; + $this->casts['pending_since'] = static::$pendingSinceCast; } /** Determine if the model instance is in a pending state. */ diff --git a/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php index 1e5426ea..da993956 100644 --- a/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/MicrosoftSQLDatabaseManager.php @@ -11,8 +11,6 @@ class MicrosoftSQLDatabaseManager extends TenantDatabaseManager public function createDatabase(TenantWithDatabase $tenant): bool { $database = $tenant->database()->getName(); - $charset = $this->connection()->getConfig('charset'); - $collation = $this->connection()->getConfig('collation'); // todo check why these are not used return $this->connection()->statement("CREATE DATABASE [{$database}]"); } diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php index 5462eafe..933740ed 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php @@ -32,6 +32,8 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage // Grant permissions to any existing tables. This is used with RLS // todo@samuel refactor this along with the todo in TenantDatabaseManager // and move the grantPermissions() call inside the condition in `ManagesPostgresUsers::createUser()` + // but maybe moving it inside $createUser is wrong because some central user may migrate new tables + // while the RLS user should STILL get access to those tables foreach ($tables as $table) { $tableName = $table->table_name; diff --git a/src/Events/Contracts/TenantEvent.php b/src/Events/Contracts/TenantEvent.php index e07708b7..fd48ac10 100644 --- a/src/Events/Contracts/TenantEvent.php +++ b/src/Events/Contracts/TenantEvent.php @@ -7,15 +7,11 @@ namespace Stancl\Tenancy\Events\Contracts; use Illuminate\Queue\SerializesModels; use Stancl\Tenancy\Contracts\Tenant; -abstract class TenantEvent // todo we could add a feature to JobPipeline that automatically gets data for the send() from here +abstract class TenantEvent { use SerializesModels; - /** @var Tenant */ - public $tenant; - - public function __construct(Tenant $tenant) - { - $this->tenant = $tenant; - } + public function __construct( + public Tenant $tenant, + ) {} } diff --git a/src/Exceptions/TenantCouldNotBeIdentifiedByIdException.php b/src/Exceptions/TenantCouldNotBeIdentifiedByIdException.php index b344be53..6f61455e 100644 --- a/src/Exceptions/TenantCouldNotBeIdentifiedByIdException.php +++ b/src/Exceptions/TenantCouldNotBeIdentifiedByIdException.php @@ -2,8 +2,6 @@ declare(strict_types=1); -// todo perhaps create Identification namespace - namespace Stancl\Tenancy\Exceptions; use Stancl\Tenancy\Contracts\TenantCouldNotBeIdentifiedException; diff --git a/src/Features/UserImpersonation.php b/src/Features/UserImpersonation.php index fd608cc4..2e0fedbf 100644 --- a/src/Features/UserImpersonation.php +++ b/src/Features/UserImpersonation.php @@ -62,7 +62,7 @@ class UserImpersonation implements Feature /** * Logout from the current domain and forget impersonation session. */ - public static function leave(): void // todo@name possibly rename + public static function stopImpersonating(): void { auth()->logout(); diff --git a/src/Listeners/ForgetTenantParameter.php b/src/Listeners/ForgetTenantParameter.php index 424b1440..0b1d1440 100644 --- a/src/Listeners/ForgetTenantParameter.php +++ b/src/Listeners/ForgetTenantParameter.php @@ -8,6 +8,8 @@ use Illuminate\Routing\Events\RouteMatched; use Stancl\Tenancy\Enums\RouteMode; use Stancl\Tenancy\Resolvers\PathTenantResolver; +// todo@earlyIdReview + /** * Remove the tenant parameter from the matched route when path identification is used globally. * diff --git a/src/Middleware/PreventAccessFromUnwantedDomains.php b/src/Middleware/PreventAccessFromUnwantedDomains.php index 05e0dbaa..91ebff05 100644 --- a/src/Middleware/PreventAccessFromUnwantedDomains.php +++ b/src/Middleware/PreventAccessFromUnwantedDomains.php @@ -68,7 +68,7 @@ class PreventAccessFromUnwantedDomains return in_array($request->getHost(), config('tenancy.identification.central_domains'), true); } - // todo@samuel + // todo@samuel technically not an identification middleware but probably ok to keep this here public function requestHasTenant(Request $request): bool { return false; diff --git a/src/Overrides/CacheManager.php b/src/Overrides/CacheManager.php index 9c78288e..dfbe1c71 100644 --- a/src/Overrides/CacheManager.php +++ b/src/Overrides/CacheManager.php @@ -6,8 +6,6 @@ namespace Stancl\Tenancy\Overrides; use Illuminate\Cache\CacheManager as BaseCacheManager; -// todo@move move to Cache namespace? - class CacheManager extends BaseCacheManager { /** diff --git a/src/Overrides/TenancyUrlGenerator.php b/src/Overrides/TenancyUrlGenerator.php index 53798c4e..4c6120a8 100644 --- a/src/Overrides/TenancyUrlGenerator.php +++ b/src/Overrides/TenancyUrlGenerator.php @@ -85,6 +85,14 @@ class TenancyUrlGenerator extends UrlGenerator */ public static array $overrides = []; + /** + * Use default parameter names ('tenant' name and tenant key value) instead of the parameter name + * and column name configured in the path resolver config. + * + * You want to enable this when using query string identification while having customized that config. + */ + public static bool $defaultParameterNames = false; + /** * Override the route() method so that the route name gets prefixed * and the tenant parameter gets added when in tenant context. @@ -166,7 +174,15 @@ class TenancyUrlGenerator extends UrlGenerator */ protected function addTenantParameter(array $parameters): array { - return tenant() && static::$passTenantParameterToRoutes ? array_merge($parameters, [PathTenantResolver::tenantParameterName() => tenant()->getTenantKey()]) : $parameters; + if (tenant() && static::$passTenantParameterToRoutes) { + if (static::$defaultParameterNames) { + return array_merge($parameters, ['tenant' => tenant()->getTenantKey()]); + } else { + return array_merge($parameters, [PathTenantResolver::tenantParameterName() => PathTenantResolver::tenantParameterValue(tenant())]); + } + } else { + return $parameters; + } } protected function routeNameOverride(string $name): string|null diff --git a/src/Resolvers/PathTenantResolver.php b/src/Resolvers/PathTenantResolver.php index 556ec4a6..773154e4 100644 --- a/src/Resolvers/PathTenantResolver.php +++ b/src/Resolvers/PathTenantResolver.php @@ -73,7 +73,7 @@ class PathTenantResolver extends Contracts\CachedTenantResolver public static function tenantRouteNamePrefix(): string { - return config('tenancy.identification.resolvers.' . static::class . '.tenant_route_name_prefix') ?? static::tenantParameterName() . '.'; + return config('tenancy.identification.resolvers.' . static::class . '.tenant_route_name_prefix') ?? 'tenant.'; } public static function tenantModelColumn(): string @@ -81,6 +81,11 @@ class PathTenantResolver extends Contracts\CachedTenantResolver return config('tenancy.identification.resolvers.' . static::class . '.tenant_model_column') ?? tenancy()->model()->getTenantKeyName(); } + public static function tenantParameterValue(Tenant $tenant): string + { + return $tenant->getAttribute(static::tenantModelColumn()); + } + /** @return string[] */ public static function allowedExtraModelColumns(): array { diff --git a/src/ResourceSyncing/SyncMaster.php b/src/ResourceSyncing/SyncMaster.php index 9a373930..882aeb54 100644 --- a/src/ResourceSyncing/SyncMaster.php +++ b/src/ResourceSyncing/SyncMaster.php @@ -9,8 +9,6 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; -// todo@move move all resource syncing-related things to a separate namespace? - /** * @property-read TenantWithDatabase[]|Collection $tenants */ diff --git a/tests/Bootstrappers/FortifyRouteBootstrapperTest.php b/tests/Bootstrappers/FortifyRouteBootstrapperTest.php index 63a08c2f..1942a1c5 100644 --- a/tests/Bootstrappers/FortifyRouteBootstrapperTest.php +++ b/tests/Bootstrappers/FortifyRouteBootstrapperTest.php @@ -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::$defaultParameterNames = 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); }); diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index f7191831..bcdc701b 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -65,7 +65,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 +78,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 +93,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 +165,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(); diff --git a/tests/TenantUserImpersonationTest.php b/tests/TenantUserImpersonationTest.php index 1e72c604..8d4f5794 100644 --- a/tests/TenantUserImpersonationTest.php +++ b/tests/TenantUserImpersonationTest.php @@ -88,7 +88,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 +134,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(); From 8960a8304785e9bb96305743dcd814677374315b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 25 Feb 2025 16:26:18 +0100 Subject: [PATCH 17/42] [4.x] Laravel 12 support (#1321) * Add Laravel 12 support, drop Laravel 11 support * Fix RLS tree generation (specify schema name in generateTrees()) * ci fixes, use stable virtualcolumn version --------- Co-authored-by: lukinovec --- .github/workflows/ci.yml | 2 +- .github/workflows/queue.yml | 16 ++++++++++++---- composer.json | 15 +++++++-------- src/RLS/PolicyManagers/TableRLSManager.php | 5 ++++- tests/AutomaticModeTest.php | 2 ++ tests/Bootstrappers/BootstrapperTest.php | 1 + .../BroadcastChannelPrefixBootstrapperTest.php | 1 + .../Bootstrappers/CacheTagsBootstrapperTest.php | 1 + .../DatabaseSessionBootstrapperTest.php | 1 + .../DatabaseTenancyBootstrapper.php | 3 +++ .../FilesystemTenancyBootstrapperTest.php | 1 + tests/BroadcastingTest.php | 1 + tests/CachedTenantResolverTest.php | 2 +- tests/CloneActionTest.php | 1 + ...binedDomainAndSubdomainIdentificationTest.php | 3 +-- tests/CommandsTest.php | 1 + tests/DatabaseUsersTest.php | 1 + tests/DomainTest.php | 1 + tests/EarlyIdentificationTest.php | 1 + tests/EventListenerTest.php | 1 + tests/Features/NoAttachTest.php | 1 + tests/Features/RedirectTest.php | 1 + tests/Features/TenantConfigTest.php | 1 + tests/MailTest.php | 2 ++ tests/MaintenanceModeTest.php | 1 + tests/ManualModeTest.php | 3 ++- tests/OriginHeaderIdentificationTest.php | 1 + tests/PathIdentificationTest.php | 1 + tests/PendingTenantsTest.php | 1 + tests/Pest.php | 12 +++++++----- tests/PreventAccessFromUnwantedDomainsTest.php | 1 + tests/QueueTest.php | 2 ++ tests/RLS/PolicyTest.php | 1 + tests/RLS/TableManagerTest.php | 1 + tests/RLS/TraitManagerTest.php | 1 + tests/RequestDataIdentificationTest.php | 1 + tests/ResourceSyncingTest.php | 1 + tests/ScopeSessionsTest.php | 1 + tests/SessionSeparationTest.php | 1 + tests/SingleDatabaseTenancyTest.php | 1 + tests/SingleDomainTenantTest.php | 1 + tests/SubdomainTest.php | 1 + tests/TenantAssetTest.php | 1 + tests/TenantAwareCommandTest.php | 1 + tests/TenantDatabaseManagerTest.php | 1 + tests/TenantModelTest.php | 1 + tests/TenantUserImpersonationTest.php | 1 + tests/TestCase.php | 1 + tests/UniversalRouteTest.php | 6 +----- 49 files changed, 81 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef40e072..91699f08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: include: - - laravel: "^11.0" + - laravel: "^12.0" php: "8.4" steps: diff --git a/.github/workflows/queue.yml b/.github/workflows/queue.yml index 2b3a63c5..cb3937e0 100644 --- a/.github/workflows/queue.yml +++ b/.github/workflows/queue.yml @@ -10,11 +10,19 @@ jobs: steps: - name: Prepare composer version constraint prefix run: | - BRANCH=${GITHUB_REF#refs/heads/} - if [[ $BRANCH =~ ^[0-9]\.x$ ]]; then - echo "VERSION_PREFIX=${BRANCH}-dev" >> $GITHUB_ENV + if [[ $GITHUB_REF == refs/tags/* ]]; then + # For refs like "refs/tags/v3.9.0", remove "refs/tags/v" prefix to get just "3.9.0" + VERSION=${GITHUB_REF#refs/tags/v} + echo "VERSION_PREFIX=${VERSION}" >> $GITHUB_ENV else - echo "VERSION_PREFIX=dev-${BRANCH}" >> $GITHUB_ENV + BRANCH=${GITHUB_REF#refs/heads/} + if [[ $BRANCH =~ ^[0-9]\.x$ ]]; then + # Branches starting with %d.x need to use -dev suffix + echo "VERSION_PREFIX=${BRANCH}-dev" >> $GITHUB_ENV + else + # All other branches use dev-${branch} prefix + echo "VERSION_PREFIX=dev-${BRANCH}" >> $GITHUB_ENV + fi fi - name: Clone test suite diff --git a/composer.json b/composer.json index 18326bac..e3a7faf4 100644 --- a/composer.json +++ b/composer.json @@ -18,25 +18,24 @@ "require": { "php": "^8.4", "ext-json": "*", - "illuminate/support": "^10.1|^11.3", + "illuminate/support": "^12.0", "laravel/tinker": "^2.0", "facade/ignition-contracts": "^1.0.2", "spatie/ignition": "^1.4", "ramsey/uuid": "^4.7.3", - "stancl/jobpipeline": "2.0.0-rc2", - "stancl/virtualcolumn": "dev-master", + "stancl/jobpipeline": "2.0.0-rc5", + "stancl/virtualcolumn": "^1.5.0", "spatie/invade": "*", "laravel/prompts": "0.*" }, "require-dev": { - "laravel/framework": "^10.1|^11.3", - "orchestra/testbench": "^8.0|^9.0", + "laravel/framework": "^12.0", + "orchestra/testbench": "^10.0", "league/flysystem-aws-s3-v3": "^3.12.2", "doctrine/dbal": "^3.6.0", "spatie/valuestore": "^1.2.5", - "pestphp/pest": "^2.0", - "larastan/larastan": "^3.0", - "aws/aws-sdk-php-laravel": "~3.0" + "pestphp/pest": "^3.0", + "larastan/larastan": "^3.0" }, "autoload": { "psr-4": { diff --git a/src/RLS/PolicyManagers/TableRLSManager.php b/src/RLS/PolicyManagers/TableRLSManager.php index 098e8015..61c62f94 100644 --- a/src/RLS/PolicyManagers/TableRLSManager.php +++ b/src/RLS/PolicyManagers/TableRLSManager.php @@ -75,7 +75,10 @@ class TableRLSManager implements RLSPolicyManager $builder = $this->database->getSchemaBuilder(); // We loop through each table in the database - foreach ($builder->getTableListing() as $table) { + foreach ($builder->getTableListing(schema: $this->database->getConfig('search_path')) as $table) { + // E.g. "public.table_name" -> "table_name" + $table = str($table)->afterLast('.')->toString(); + // For each table, we get a list of all foreign key columns $foreignKeys = collect($builder->getForeignKeys($table))->map(function ($foreign) use ($table) { return $this->formatForeignKey($foreign, $table); diff --git a/tests/AutomaticModeTest.php b/tests/AutomaticModeTest.php index f34aa7f1..fbeb06fc 100644 --- a/tests/AutomaticModeTest.php +++ b/tests/AutomaticModeTest.php @@ -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); diff --git a/tests/Bootstrappers/BootstrapperTest.php b/tests/Bootstrappers/BootstrapperTest.php index 10120f85..c4fed90c 100644 --- a/tests/Bootstrappers/BootstrapperTest.php +++ b/tests/Bootstrappers/BootstrapperTest.php @@ -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; diff --git a/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php b/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php index 4c3ea30a..785430f5 100644 --- a/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php +++ b/tests/Bootstrappers/BroadcastChannelPrefixBootstrapperTest.php @@ -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); diff --git a/tests/Bootstrappers/CacheTagsBootstrapperTest.php b/tests/Bootstrappers/CacheTagsBootstrapperTest.php index fa63fc6c..660be1a7 100644 --- a/tests/Bootstrappers/CacheTagsBootstrapperTest.php +++ b/tests/Bootstrappers/CacheTagsBootstrapperTest.php @@ -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]]); diff --git a/tests/Bootstrappers/DatabaseSessionBootstrapperTest.php b/tests/Bootstrappers/DatabaseSessionBootstrapperTest.php index f249c975..a6faa9a8 100644 --- a/tests/Bootstrappers/DatabaseSessionBootstrapperTest.php +++ b/tests/Bootstrappers/DatabaseSessionBootstrapperTest.php @@ -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 diff --git a/tests/Bootstrappers/DatabaseTenancyBootstrapper.php b/tests/Bootstrappers/DatabaseTenancyBootstrapper.php index 8c8259cd..14109500 100644 --- a/tests/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/tests/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -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); diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index 06aad296..d6b6a231 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -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); diff --git a/tests/BroadcastingTest.php b/tests/BroadcastingTest.php index ba221307..c3509426 100644 --- a/tests/BroadcastingTest.php +++ b/tests/BroadcastingTest.php @@ -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(); diff --git a/tests/CachedTenantResolverTest.php b/tests/CachedTenantResolverTest.php index 0ca9553f..02d935c5 100644 --- a/tests/CachedTenantResolverTest.php +++ b/tests/CachedTenantResolverTest.php @@ -12,8 +12,8 @@ use Stancl\Tenancy\Resolvers\DomainTenantResolver; use Illuminate\Support\Facades\Route as RouteFacade; use Illuminate\Support\Facades\Schema; 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']); diff --git a/tests/CloneActionTest.php b/tests/CloneActionTest.php index 8be8881b..3706f31e 100644 --- a/tests/CloneActionTest.php +++ b/tests/CloneActionTest.php @@ -10,6 +10,7 @@ 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) { diff --git a/tests/CombinedDomainAndSubdomainIdentificationTest.php b/tests/CombinedDomainAndSubdomainIdentificationTest.php index 85f11182..1cae6408 100644 --- a/tests/CombinedDomainAndSubdomainIdentificationTest.php +++ b/tests/CombinedDomainAndSubdomainIdentificationTest.php @@ -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([ diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index aecbb07c..7ebb07a8 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -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')) { diff --git a/tests/DatabaseUsersTest.php b/tests/DatabaseUsersTest.php index d10aca57..aed487ac 100644 --- a/tests/DatabaseUsersTest.php +++ b/tests/DatabaseUsersTest.php @@ -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([ diff --git a/tests/DomainTest.php b/tests/DomainTest.php index cb104532..e393f538 100644 --- a/tests/DomainTest.php +++ b/tests/DomainTest.php @@ -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; diff --git a/tests/EarlyIdentificationTest.php b/tests/EarlyIdentificationTest.php index 4fbf6e3a..48ac4d12 100644 --- a/tests/EarlyIdentificationTest.php +++ b/tests/EarlyIdentificationTest.php @@ -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([ diff --git a/tests/EventListenerTest.php b/tests/EventListenerTest.php index d88d63de..5aeb4769 100644 --- a/tests/EventListenerTest.php +++ b/tests/EventListenerTest.php @@ -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; diff --git a/tests/Features/NoAttachTest.php b/tests/Features/NoAttachTest.php index e82f3eb4..a1588a24 100644 --- a/tests/Features/NoAttachTest.php +++ b/tests/Features/NoAttachTest.php @@ -18,6 +18,7 @@ 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) { if (php_uname('m') == 'aarch64') { diff --git a/tests/Features/RedirectTest.php b/tests/Features/RedirectTest.php index 7aca2e92..a4102070 100644 --- a/tests/Features/RedirectTest.php +++ b/tests/Features/RedirectTest.php @@ -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([ diff --git a/tests/Features/TenantConfigTest.php b/tests/Features/TenantConfigTest.php index 5c12c5f0..b06ddba9 100644 --- a/tests/Features/TenantConfigTest.php +++ b/tests/Features/TenantConfigTest.php @@ -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 = []; diff --git a/tests/MailTest.php b/tests/MailTest.php index c41b5578..be651765 100644 --- a/tests/MailTest.php +++ b/tests/MailTest.php @@ -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']); diff --git a/tests/MaintenanceModeTest.php b/tests/MaintenanceModeTest.php index 59024c96..9c90f0d3 100644 --- a/tests/MaintenanceModeTest.php +++ b/tests/MaintenanceModeTest.php @@ -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 () { diff --git a/tests/ManualModeTest.php b/tests/ManualModeTest.php index f9983cf7..2e723586 100644 --- a/tests/ManualModeTest.php +++ b/tests/ManualModeTest.php @@ -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) { diff --git a/tests/OriginHeaderIdentificationTest.php b/tests/OriginHeaderIdentificationTest.php index 83737f1f..071aa493 100644 --- a/tests/OriginHeaderIdentificationTest.php +++ b/tests/OriginHeaderIdentificationTest.php @@ -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; diff --git a/tests/PathIdentificationTest.php b/tests/PathIdentificationTest.php index 9fbaf68b..1df74092 100644 --- a/tests/PathIdentificationTest.php +++ b/tests/PathIdentificationTest.php @@ -12,6 +12,7 @@ use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByPathException; use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Resolvers\PathTenantResolver; use Stancl\Tenancy\Tests\Etc\Tenant; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { // Make sure the tenant parameter is set to 'tenant' diff --git a/tests/PendingTenantsTest.php b/tests/PendingTenantsTest.php index 26fd5c34..3339baaf 100644 --- a/tests/PendingTenantsTest.php +++ b/tests/PendingTenantsTest.php @@ -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(); diff --git a/tests/Pest.php b/tests/Pest.php index 5380da0a..cd18d174 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,7 @@ 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; +} diff --git a/tests/PreventAccessFromUnwantedDomainsTest.php b/tests/PreventAccessFromUnwantedDomainsTest.php index 99d0c2fe..9c4764d2 100644 --- a/tests/PreventAccessFromUnwantedDomainsTest.php +++ b/tests/PreventAccessFromUnwantedDomainsTest.php @@ -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([ diff --git a/tests/QueueTest.php b/tests/QueueTest.php index 2095cc84..25ab320e 100644 --- a/tests/QueueTest.php +++ b/tests/QueueTest.php @@ -24,6 +24,8 @@ 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 () { config([ diff --git a/tests/RLS/PolicyTest.php b/tests/RLS/PolicyTest.php index 7c7165bc..7278776a 100644 --- a/tests/RLS/PolicyTest.php +++ b/tests/RLS/PolicyTest.php @@ -17,6 +17,7 @@ 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 () { TraitRLSManager::$excludedModels = [Article::class]; diff --git a/tests/RLS/TableManagerTest.php b/tests/RLS/TableManagerTest.php index 84689a72..4a8ac058 100644 --- a/tests/RLS/TableManagerTest.php +++ b/tests/RLS/TableManagerTest.php @@ -19,6 +19,7 @@ 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; beforeEach(function () { TableRLSManager::$scopeByDefault = true; diff --git a/tests/RLS/TraitManagerTest.php b/tests/RLS/TraitManagerTest.php index 7a7dd37a..af2f6f84 100644 --- a/tests/RLS/TraitManagerTest.php +++ b/tests/RLS/TraitManagerTest.php @@ -25,6 +25,7 @@ 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 () { TraitRLSManager::$implicitRLS = true; diff --git a/tests/RequestDataIdentificationTest.php b/tests/RequestDataIdentificationTest.php index 70792adb..f04d99a7 100644 --- a/tests/RequestDataIdentificationTest.php +++ b/tests/RequestDataIdentificationTest.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Route; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException; use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData; use Stancl\Tenancy\Tests\Etc\Tenant; +use function Stancl\Tenancy\Tests\pest; beforeEach(function () { config([ diff --git a/tests/ResourceSyncingTest.php b/tests/ResourceSyncingTest.php index 34a6ba14..a1870e86 100644 --- a/tests/ResourceSyncingTest.php +++ b/tests/ResourceSyncingTest.php @@ -43,6 +43,7 @@ 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 function Stancl\Tenancy\Tests\pest; beforeEach(function () { config(['tenancy.bootstrappers' => [ diff --git a/tests/ScopeSessionsTest.php b/tests/ScopeSessionsTest.php index 5a8a9e51..e62fa370 100644 --- a/tests/ScopeSessionsTest.php +++ b/tests/ScopeSessionsTest.php @@ -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([ diff --git a/tests/SessionSeparationTest.php b/tests/SessionSeparationTest.php index 6706a18e..02b018d1 100644 --- a/tests/SessionSeparationTest.php +++ b/tests/SessionSeparationTest.php @@ -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 diff --git a/tests/SingleDatabaseTenancyTest.php b/tests/SingleDatabaseTenancyTest.php index c71e6d38..c0d3aef3 100644 --- a/tests/SingleDatabaseTenancyTest.php +++ b/tests/SingleDatabaseTenancyTest.php @@ -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) { diff --git a/tests/SingleDomainTenantTest.php b/tests/SingleDomainTenantTest.php index 3a68ee8b..49bd7d95 100644 --- a/tests/SingleDomainTenantTest.php +++ b/tests/SingleDomainTenantTest.php @@ -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]); diff --git a/tests/SubdomainTest.php b/tests/SubdomainTest.php index 9ddc48ba..a7cc58ae 100644 --- a/tests/SubdomainTest.php +++ b/tests/SubdomainTest.php @@ -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 diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index bcdc701b..5c223fe2 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -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' => [ diff --git a/tests/TenantAwareCommandTest.php b/tests/TenantAwareCommandTest.php index fe49685e..764e5a9b 100644 --- a/tests/TenantAwareCommandTest.php +++ b/tests/TenantAwareCommandTest.php @@ -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(); diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index b10d5ac3..c41ea35a 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -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; diff --git a/tests/TenantModelTest.php b/tests/TenantModelTest.php index f6e04cbb..796f92f4 100644 --- a/tests/TenantModelTest.php +++ b/tests/TenantModelTest.php @@ -22,6 +22,7 @@ use Stancl\Tenancy\Exceptions\TenancyNotInitializedException; use Stancl\Tenancy\UniqueIdentifierGenerators\RandomHexGenerator; use Stancl\Tenancy\UniqueIdentifierGenerators\RandomIntGenerator; use Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator; +use function Stancl\Tenancy\Tests\pest; afterEach(function () { RandomIntGenerator::$min = 0; diff --git a/tests/TenantUserImpersonationTest.php b/tests/TenantUserImpersonationTest.php index 8d4f5794..8c9c4124 100644 --- a/tests/TenantUserImpersonationTest.php +++ b/tests/TenantUserImpersonationTest.php @@ -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', [ diff --git a/tests/TestCase.php b/tests/TestCase.php index 7fc1813b..6a167c46 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -23,6 +23,7 @@ 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 { diff --git a/tests/UniversalRouteTest.php b/tests/UniversalRouteTest.php index 528d46bf..c8df0ab0 100644 --- a/tests/UniversalRouteTest.php +++ b/tests/UniversalRouteTest.php @@ -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) { From 37a0f1a713724126fcd480e4cf3522293de314fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 13 Mar 2025 17:03:49 +0100 Subject: [PATCH 18/42] [4.x] Invalidate resolver cache on delete (#1329) * Invalidate resolver cache on delete * Fix code style (php-cs-fixer) --------- Co-authored-by: github-actions[bot] --- .../Concerns/InvalidatesResolverCache.php | 7 +-- .../InvalidatesTenantsResolverCache.php | 11 ++-- tests/CachedTenantResolverTest.php | 50 +++++++++++++++++++ 3 files changed, 55 insertions(+), 13 deletions(-) diff --git a/src/Database/Concerns/InvalidatesResolverCache.php b/src/Database/Concerns/InvalidatesResolverCache.php index cd9cb25b..71659b68 100644 --- a/src/Database/Concerns/InvalidatesResolverCache.php +++ b/src/Database/Concerns/InvalidatesResolverCache.php @@ -4,16 +4,13 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\Concerns; -use Illuminate\Database\Eloquent\Model; -use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Tenancy; trait InvalidatesResolverCache { public static function bootInvalidatesResolverCache(): void { - static::saved(function (Tenant&Model $tenant) { - Tenancy::invalidateResolverCache($tenant); - }); + static::saved(Tenancy::invalidateResolverCache(...)); + static::deleting(Tenancy::invalidateResolverCache(...)); } } diff --git a/src/Database/Concerns/InvalidatesTenantsResolverCache.php b/src/Database/Concerns/InvalidatesTenantsResolverCache.php index d954567f..48cacbbd 100644 --- a/src/Database/Concerns/InvalidatesTenantsResolverCache.php +++ b/src/Database/Concerns/InvalidatesTenantsResolverCache.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\Concerns; use Illuminate\Database\Eloquent\Model; -use Stancl\Tenancy\Resolvers\Contracts\CachedTenantResolver; use Stancl\Tenancy\Tenancy; /** @@ -15,13 +14,9 @@ trait InvalidatesTenantsResolverCache { public static function bootInvalidatesTenantsResolverCache(): void { - static::saved(function (Model $model) { - foreach (Tenancy::cachedResolvers() as $resolver) { - /** @var CachedTenantResolver $resolver */ - $resolver = app($resolver); + $invalidateCache = static fn (Model $model) => Tenancy::invalidateResolverCache($model->tenant); - $resolver->invalidateCache($model->tenant); - } - }); + static::saved($invalidateCache); + static::deleting($invalidateCache); } } diff --git a/tests/CachedTenantResolverTest.php b/tests/CachedTenantResolverTest.php index 02d935c5..558fb345 100644 --- a/tests/CachedTenantResolverTest.php +++ b/tests/CachedTenantResolverTest.php @@ -11,6 +11,8 @@ 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\Resolvers\RequestDataTenantResolver; use function Stancl\Tenancy\Tests\pest; @@ -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(); From 95dd906de2edca3ab51176bdc9ed0e419788230d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Tue, 18 Mar 2025 18:42:08 +0100 Subject: [PATCH 19/42] [4.x] Make the ImpersonationToken model configurable (#1335) * Make the ImpersonationToken model configurable, resolve #1315 * Add type definition * Make phpstan happy --- assets/config.php | 1 + src/Database/Models/ImpersonationToken.php | 9 ++++++++- src/Features/UserImpersonation.php | 22 +++++++++++++++++----- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/assets/config.php b/assets/config.php index db3820bf..9089974d 100644 --- a/assets/config.php +++ b/assets/config.php @@ -15,6 +15,7 @@ return [ 'models' => [ 'tenant' => Stancl\Tenancy\Database\Models\Tenant::class, 'domain' => Stancl\Tenancy\Database\Models\Domain::class, + 'impersonation_token' => Stancl\Tenancy\Database\Models\ImpersonationToken::class, /** * Name of the column used to relate models to tenants. diff --git a/src/Database/Models/ImpersonationToken.php b/src/Database/Models/ImpersonationToken.php index 6dabbd03..38d2463e 100644 --- a/src/Database/Models/ImpersonationToken.php +++ b/src/Database/Models/ImpersonationToken.php @@ -25,6 +25,9 @@ class ImpersonationToken extends Model { use CentralConnection; + /** You can set this property to customize the table name */ + public static string $tableName = 'tenant_user_impersonation_tokens'; + protected $guarded = []; public $timestamps = false; @@ -33,11 +36,15 @@ class ImpersonationToken extends Model public $incrementing = false; - protected $table = 'tenant_user_impersonation_tokens'; protected $casts = [ 'created_at' => 'datetime', ]; + public function getTable() + { + return static::$tableName; + } + public static function booted(): void { static::creating(function ($model) { diff --git a/src/Features/UserImpersonation.php b/src/Features/UserImpersonation.php index 2e0fedbf..3db563a4 100644 --- a/src/Features/UserImpersonation.php +++ b/src/Features/UserImpersonation.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Features; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Auth; use Stancl\Tenancy\Contracts\Feature; @@ -18,8 +19,8 @@ class UserImpersonation implements Feature public function bootstrap(Tenancy $tenancy): void { - $tenancy->macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): ImpersonationToken { - return ImpersonationToken::create([ + $tenancy->macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): Model { + return UserImpersonation::modelClass()::create([ Tenancy::tenantKeyColumn() => $tenant->getTenantKey(), 'user_id' => $userId, 'redirect_url' => $redirectUrl, @@ -30,10 +31,15 @@ class UserImpersonation implements Feature } /** Impersonate a user and get an HTTP redirect response. */ - public static function makeResponse(#[\SensitiveParameter] string|ImpersonationToken $token, ?int $ttl = null): RedirectResponse + public static function makeResponse(#[\SensitiveParameter] string|Model $token, ?int $ttl = null): RedirectResponse { - /** @var ImpersonationToken $token */ - $token = $token instanceof ImpersonationToken ? $token : ImpersonationToken::findOrFail($token); + /** + * The model does NOT have to extend ImpersonationToken, but usually it WILL be a child + * of ImpersonationToken and this makes it clear to phpstan that the model has a redirect_url property. + * + * @var ImpersonationToken $token + */ + $token = $token instanceof Model ? $token : static::modelClass()::findOrFail($token); $ttl ??= static::$ttl; $tokenExpired = $token->created_at->diffInSeconds(now()) > $ttl; @@ -54,6 +60,12 @@ class UserImpersonation implements Feature return redirect($token->redirect_url); } + /** @return class-string */ + public static function modelClass(): string + { + return config('tenancy.models.impersonation_token'); + } + public static function isImpersonating(): bool { return session()->has('tenancy_impersonating'); From 84a2863d2dda93c3a1eb92aa8f7cafd019256b02 Mon Sep 17 00:00:00 2001 From: Sergio Peris Date: Tue, 18 Mar 2025 18:56:29 +0100 Subject: [PATCH 20/42] [4.x] Fix fully qualified name at TenancyServiceProvider.stub.php (#1334) --- assets/TenancyServiceProvider.stub.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 708e4450..2f2f11f2 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -182,7 +182,7 @@ class TenancyServiceProvider extends ServiceProvider // // To make Livewire v3 work with Tenancy, make the update route universal. // Livewire::setUpdateRoute(function ($handle) { - // return RouteFacade::post('/livewire/update', $handle)->middleware(['web', 'universal', \Stancl\Tenancy::defaultMiddleware()]); + // return RouteFacade::post('/livewire/update', $handle)->middleware(['web', 'universal', \Stancl\Tenancy\Tenancy::defaultMiddleware()]); // }); } From 8d87ee9dfc5eaa545cd19f4681e0cc2a673f0b2e Mon Sep 17 00:00:00 2001 From: Alexandru Bucur Date: Tue, 18 Mar 2025 19:00:35 +0100 Subject: [PATCH 21/42] [4.x] Add ULIDGenerator (#1332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: introduce a simple ULID generator * add test --------- Co-authored-by: Samuel Štancl --- .../ULIDGenerator.php | 20 +++++++++++++++++++ tests/TenantModelTest.php | 16 +++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 src/UniqueIdentifierGenerators/ULIDGenerator.php diff --git a/src/UniqueIdentifierGenerators/ULIDGenerator.php b/src/UniqueIdentifierGenerators/ULIDGenerator.php new file mode 100644 index 00000000..17b62898 --- /dev/null +++ b/src/UniqueIdentifierGenerators/ULIDGenerator.php @@ -0,0 +1,20 @@ +toString(); + } +} diff --git a/tests/TenantModelTest.php b/tests/TenantModelTest.php index 796f92f4..4c6e77e1 100644 --- a/tests/TenantModelTest.php +++ b/tests/TenantModelTest.php @@ -22,6 +22,8 @@ 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 () { @@ -78,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); From 8cd15db1fcafcc97bcb968867785995d58c18169 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 18 Mar 2025 21:27:27 +0100 Subject: [PATCH 22/42] [4.x] Make RemoveStorageSymlinksAction able to delete broken symlinks (#1323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add regression test for removing invalid symlinks * Move commented RemoveStorageSymlinks job to the DeletingTenant pipeline (better default - the symlinks will be removed *before* deleting tenant storage) * Remove symlink validity check from symlinkExists() (only check for the symlink's existence) * Delete complete todo0 * Make the symlink assertions more explicit * update test name --------- Co-authored-by: Samuel Štancl --- assets/TenancyServiceProvider.stub.php | 2 +- src/Concerns/DealsWithTenantSymlinks.php | 2 +- tests/ActionTest.php | 52 ++++++++++++++++++++++-- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 2f2f11f2..d49d96d0 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -53,6 +53,7 @@ class TenancyServiceProvider extends ServiceProvider Events\DeletingTenant::class => [ JobPipeline::make([ Jobs\DeleteDomains::class, + // Jobs\RemoveStorageSymlinks::class, ])->send(function (Events\DeletingTenant $event) { return $event->tenant; })->shouldBeQueued(false), @@ -62,7 +63,6 @@ class TenancyServiceProvider extends ServiceProvider Events\TenantDeleted::class => [ JobPipeline::make([ Jobs\DeleteDatabase::class, - // Jobs\RemoveStorageSymlinks::class, ])->send(function (Events\TenantDeleted $event) { return $event->tenant; })->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production. diff --git a/src/Concerns/DealsWithTenantSymlinks.php b/src/Concerns/DealsWithTenantSymlinks.php index 37984dc8..114eadb5 100644 --- a/src/Concerns/DealsWithTenantSymlinks.php +++ b/src/Concerns/DealsWithTenantSymlinks.php @@ -56,6 +56,6 @@ trait DealsWithTenantSymlinks /** Determine if the provided path is an existing symlink. */ protected function symlinkExists(string $link): bool { - return file_exists($link) && is_link($link); + return is_link($link); } } diff --git a/tests/ActionTest.php b/tests/ActionTest.php index 1adcb32d..63b6b377 100644 --- a/tests/ActionTest.php +++ b/tests/ActionTest.php @@ -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(); }); From dc90e60a2f601bef1177827ffbc73f1b5fdcc30e Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 4 Apr 2025 03:15:37 +0200 Subject: [PATCH 23/42] [4.x] Make ScopeSessions usable on universal routes (#1342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Skip ScopeSessions MW if the current context is central and the route is universal * Add regressiont test * Simplify code --------- Co-authored-by: Samuel Štancl --- src/Middleware/ScopeSessions.php | 4 ++++ tests/ScopeSessionsTest.php | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/Middleware/ScopeSessions.php b/src/Middleware/ScopeSessions.php index 46bd5dc4..879b9d97 100644 --- a/src/Middleware/ScopeSessions.php +++ b/src/Middleware/ScopeSessions.php @@ -19,6 +19,10 @@ class ScopeSessions public function handle(Request $request, Closure $next): mixed { if (! tenancy()->initialized) { + if (tenancy()->routeIsUniversal(tenancy()->getRoute($request))) { + return $next($request); + } + throw new TenancyNotInitializedException('Tenancy needs to be initialized before the session scoping middleware is executed'); } diff --git a/tests/ScopeSessionsTest.php b/tests/ScopeSessionsTest.php index e62fa370..4fccac58 100644 --- a/tests/ScopeSessionsTest.php +++ b/tests/ScopeSessionsTest.php @@ -55,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(); +}); From 27685ffe5a30c2fe963f71eb45fd563c0d952abe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 9 May 2025 15:15:22 +0200 Subject: [PATCH 24/42] improve sample RootUrlBootstrapper config --- assets/TenancyServiceProvider.stub.php | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index d49d96d0..d9cfaef9 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -147,16 +147,19 @@ class TenancyServiceProvider extends ServiceProvider { // \Stancl\Tenancy\Bootstrappers\RootUrlBootstrapper::$rootUrlOverride = function (Tenant $tenant, string $originalRootUrl) { // $tenantDomain = $tenant instanceof \Stancl\Tenancy\Contracts\SingleDomainTenant - // ? $tenant->domain - // : $tenant->domains->first()->domain; + // ? $tenant->domain + // : $tenant->domains->first()->domain; + // // $scheme = str($originalRootUrl)->before('://'); // - // // If you're using domain identification: - // return $scheme . '://' . $tenantDomain . '/'; - // - // // If you're using subdomain identification: - // $originalDomain = str($originalRootUrl)->after($scheme . '://'); - // return $scheme . '://' . $tenantDomain . '.' . $originalDomain . '/'; + // if (str_contains($tenantDomain, '.')) { + // // Domain identification + // return $scheme . '://' . $tenantDomain . '/'; + // } else { + // // Subdomain identification + // $originalDomain = str($originalRootUrl)->after($scheme . '://')->before('/'); + // return $scheme . '://' . $tenantDomain . '.' . $originalDomain . '/'; + // } // }; } From 588d1fcc0db8a1f612738f2f4e5ee0e5f23aa52f Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 15 May 2025 14:54:04 +0200 Subject: [PATCH 25/42] [4.x] Make TableRLSManager skip foreign keys with 'no-rls' comment right away (#1352) * When a foreign key has no-rls comment (or no comment when scopeByDefault is false), skip path generation earlier * Fix column definitions --- src/RLS/PolicyManagers/TableRLSManager.php | 16 +++++++--------- tests/RLS/TableManagerTest.php | 19 ++++++++++++++++--- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/RLS/PolicyManagers/TableRLSManager.php b/src/RLS/PolicyManagers/TableRLSManager.php index 61c62f94..8e941b31 100644 --- a/src/RLS/PolicyManagers/TableRLSManager.php +++ b/src/RLS/PolicyManagers/TableRLSManager.php @@ -108,6 +108,12 @@ class TableRLSManager implements RLSPolicyManager protected function generatePaths(string $table, array $foreign, array &$paths, array $currentPath = []): void { + // If the foreign key has a comment of 'no-rls', we skip it + // Also skip the foreign key if implicit scoping is off and the foreign key has no comment + if ($foreign['comment'] === 'no-rls' || (! static::$scopeByDefault && $foreign['comment'] === null)) { + return; + } + if (in_array($foreign['foreignTable'], array_column($currentPath, 'foreignTable'))) { throw new RecursiveRelationshipException; } @@ -115,15 +121,7 @@ class TableRLSManager implements RLSPolicyManager $currentPath[] = $foreign; if ($foreign['foreignTable'] === tenancy()->model()->getTable()) { - $comments = array_column($currentPath, 'comment'); - $pathCanUseRls = static::$scopeByDefault ? - ! in_array('no-rls', $comments) : - ! in_array('no-rls', $comments) && ! in_array(null, $comments); - - if ($pathCanUseRls) { - // If the foreign table is the tenants table, add the current path to $paths - $paths[] = $currentPath; - } + $paths[] = $currentPath; } else { // If not, recursively generate paths for the foreign table foreach ($this->database->getSchemaBuilder()->getForeignKeys($foreign['foreignTable']) as $nextConstraint) { diff --git a/tests/RLS/TableManagerTest.php b/tests/RLS/TableManagerTest.php index 4a8ac058..fd9c6f44 100644 --- a/tests/RLS/TableManagerTest.php +++ b/tests/RLS/TableManagerTest.php @@ -503,7 +503,7 @@ test('table rls manager generates relationship trees with tables related to the // 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'); + $table->foreignId('comment_id')->comment('rls')->constrained('comments')->onUpdate('cascade')->onDelete('cascade'); }); // Non-nullable paths are preferred over nullable paths @@ -640,16 +640,29 @@ test('table rls manager generates queries correctly', function() { test('table manager throws an exception when encountering a recursive relationship', function() { Schema::create('recursive_posts', function (Blueprint $table) { $table->id(); - $table->foreignId('highlighted_comment_id')->constrained('comments')->nullable()->comment('rls'); + $table->foreignId('highlighted_comment_id')->nullable()->comment('rls')->constrained('comments'); }); 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); }); +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'); + }); + + Schema::table('comments', function (Blueprint $table) { + $table->foreignId('recursive_post_id')->comment('rls')->constrained('recursive_posts'); + }); + + expect(fn () => app(TableRLSManager::class)->generateTrees())->not()->toThrow(RecursiveRelationshipException::class); +}); + class Post extends Model { protected $guarded = []; From 37f626081274f976d77551afdf36f3c009eb5331 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 29 May 2025 18:36:09 +0200 Subject: [PATCH 26/42] Add CLAUDE.md --- CLAUDE.md | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..7061fcda --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,97 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Testing +- `composer test` - Run tests without coverage using Docker +- `./t 'test name'` - Run a specific test + +### Code Quality +- `composer phpstan` - Run PHPStan static analysis (level 8) +- `composer cs` - Fix code style using PHP CS Fixer + +### Docker Development +- `composer docker-up` - Start Docker environment +- `composer docker-down` - Stop Docker environment +- `composer docker-restart` - Restart Docker environment + +## Architecture Overview + +**Tenancy for Laravel** is a multi-tenancy package that automatically handles tenant isolation without requiring changes to application code. + +### Core Components + +**Central Classes:** +- `Tenancy` - Main orchestrator class managing tenant context and lifecycle +- `TenancyServiceProvider` (NOT the stub) - Registers services, commands, and bootstrappers +- `Tenant` (model) - Represents individual tenants with domains and databases +- `Domain` (model) - Maps domains/subdomains to tenants + +**Tenant Identification:** +- **Resolvers** (`src/Resolvers/`) - Identify tenants by domain, path, or request data - this data comes from middleware +- **Middleware** (`src/Middleware/`) - Middleware that calls resolvers and tries to initialize tenancy based on information from a request +- **Cached resolvers** - Cached wrapper around resolvers to avoid querying the central database + +**Tenancy Bootstrappers (`src/Bootstrappers/`):** +- `DatabaseTenancyBootstrapper` - Switches database connections +- `CacheTenancyBootstrapper` - Isolates cache by tenant +- `FilesystemTenancyBootstrapper` - Manages tenant-specific storage +- `QueueTenancyBootstrapper` - Ensures queued jobs run in correct tenant context +- `RedisTenancyBootstrapper` - Prefixes Redis keys by tenant + +**Database Management:** +- **DatabaseManager** - Creates/deletes tenant databases and users +- **TenantDatabaseManagers** - Database-specific implementations (MySQL, PostgreSQL, SQLite, SQL Server) +- **Row Level Security (RLS)** - PostgreSQL-based tenant isolation using policies + +**Advanced Features:** +- **Resource Syncing** - Sync central models to tenant databases +- **User Impersonation** - Admin access to tenant contexts +- **Cross-domain redirects** - Handle multi-domain tenant setups +- **Telescope integration** - Tag entries by tenant + +### Key Patterns + +**Tenant Context Management:** +```php +tenancy()->initialize($tenant); // Switch to tenant +tenancy()->run($tenant, $callback); // Atomic tenant execution +tenancy()->runForMultiple($tenants, $callback); // Batch operations +tenancy()->central($callback); // Run in central context +``` + +**Tenant Identification Flow:** +1. Middleware identifies tenant from request (domain/subdomain/path) +2. Resolver fetches tenant model from identification data +3. Tenancy initializes and bootstrappers configure tenant context +4. Application runs with tenant-specific database/cache/storage + +**Route Middleware Groups:** +All of these work as flags, i.e. middleware groups that are empty arrays with a purely semantic use. +- `tenant` - Routes requiring tenant context +- `central` - Routes for central/admin functionality +- `universal` - Routes working in both contexts +- `clone` - Tells route cloning logic to clone the route + +### Testing Environment + +Tests use Docker with MySQL/PostgreSQL/Redis. The `./test` script runs Pest tests inside containers with proper database isolation. + +`./t 'test name'` is equivalent to `./test --filter 'test name'` + +**Key test patterns:** +- Database preparation and cleanup between tests +- Multi-database scenarios (central + tenant databases) +- Middleware and identification testing +- Resource syncing validation + +### Configuration + +Central config in `config/tenancy.php` controls: +- Tenant/domain model classes +- Database connection settings +- Enabled bootstrappers and features +- Identification middleware and resolvers +- Cache and storage prefixes From f4cc99b317d38368d1346b5693856cda46514aca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 29 May 2025 18:39:05 +0200 Subject: [PATCH 27/42] fix phpstan --- src/Database/Concerns/PendingScope.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Concerns/PendingScope.php b/src/Database/Concerns/PendingScope.php index b5b6b9cb..d83a37dd 100644 --- a/src/Database/Concerns/PendingScope.php +++ b/src/Database/Concerns/PendingScope.php @@ -20,7 +20,7 @@ class PendingScope implements Scope /** * Apply the scope to a given Eloquent query builder. * - * @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder + * @param Builder $builder * * @return void */ From 5f7fd38e5acd4355292a066e343d9b4d011c1db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 2 Jun 2025 03:43:47 +0200 Subject: [PATCH 28/42] [4.x] URL generation, request data identification improvements (#1357) * UrlGenerator: set defaults based on config; request data: move config to config file+resolver * Claude code adjustments * improve request data tests, simplify complex test in UrlGeneratorBootstrapperTest * url generator test: test changing tenant parameter name * request data identification: add tenant_model_column configuration * defaultParameterNames -> passQueryParameter * move comment * minor refactor in PathIdentificationTest, expand CLAUDE.md to include early identification section * Fix COLOR_FLAG * improve test name Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * TenancyUrlGenerator: add a check for queryParameterName being null Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix code style (php-cs-fixer) --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] --- CLAUDE.md | 32 +++ assets/config.php | 9 +- .../Integrations/FortifyRouteBootstrapper.php | 33 ++- .../UrlGeneratorBootstrapper.php | 16 +- .../InitializeTenancyByRequestData.php | 38 ++- src/Overrides/TenancyUrlGenerator.php | 30 ++- src/Resolvers/RequestDataTenantResolver.php | 41 +++- t | 8 +- test | 8 +- .../FortifyRouteBootstrapperTest.php | 2 +- .../UrlGeneratorBootstrapperTest.php | 232 ++++++++++++++---- tests/PathIdentificationTest.php | 12 +- tests/RequestDataIdentificationTest.php | 105 +++++--- 13 files changed, 440 insertions(+), 126 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7061fcda..bb6dedac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Testing - `composer test` - Run tests without coverage using Docker +- `./test tests/TestFile.php` - Run an entire test file - `./t 'test name'` - Run a specific test +- You can append `-v` to get a full stack trace if a test fails due to an exception ### Code Quality - `composer phpstan` - Run PHPStan static analysis (level 8) @@ -75,6 +77,36 @@ All of these work as flags, i.e. middleware groups that are empty arrays with a - `universal` - Routes working in both contexts - `clone` - Tells route cloning logic to clone the route +### Early Identification + +**Early identification** ensures tenancy is initialized before controller instantiation, which is critical for certain scenarios. + +**When needed:** +- Controllers using constructor dependency injection +- Integration with packages that inject dependencies in constructors + +**The Problem:** +Laravel executes controller constructors and route model binding before route-level middleware runs, causing services to use central context instead of tenant context. + +**Solutions:** +1. **Avoid Constructor Injection** - Use method injection instead +2. **Laravel's Native Solution** - Use controllers that implement `HasMiddleware` interface +3. **Kernel Identification** - Add middleware to HTTP Kernel's global stack: + +```php +// In HttpKernel.php +protected $middleware = [ + \Stancl\Tenancy\Middleware\InitializeTenancyByDomain::class, + // other middleware... +]; +``` + +Note you also need to flag the route with the `'tenant'` middleware if default route mode (set in config) isn't set to TENANT. + +**Benefits:** +- Constructor dependency injection receives tenant-aware services +- Seamless integration with existing Laravel applications + ### Testing Environment Tests use Docker with MySQL/PostgreSQL/Redis. The `./test` script runs Pest tests inside containers with proper database isolation. diff --git a/assets/config.php b/assets/config.php index 9089974d..73becdee 100644 --- a/assets/config.php +++ b/assets/config.php @@ -119,7 +119,7 @@ return [ Resolvers\PathTenantResolver::class => [ 'tenant_parameter_name' => 'tenant', 'tenant_model_column' => null, // null = tenant key - 'tenant_route_name_prefix' => null, // null = 'tenant.' + 'tenant_route_name_prefix' => 'tenant.', 'allowed_extra_model_columns' => [], // used with binding route fields 'cache' => false, @@ -127,6 +127,13 @@ return [ 'cache_store' => null, // null = default ], Resolvers\RequestDataTenantResolver::class => [ + // Set any of these to null to disable that method of identification + 'header' => 'X-Tenant', + 'cookie' => 'tenant', + 'query_parameter' => 'tenant', + + 'tenant_model_column' => null, // null = tenant key + 'cache' => false, 'cache_ttl' => 3600, // seconds 'cache_store' => null, // null = default diff --git a/src/Bootstrappers/Integrations/FortifyRouteBootstrapper.php b/src/Bootstrappers/Integrations/FortifyRouteBootstrapper.php index 05f3fa11..fb371d6a 100644 --- a/src/Bootstrappers/Integrations/FortifyRouteBootstrapper.php +++ b/src/Bootstrappers/Integrations/FortifyRouteBootstrapper.php @@ -8,6 +8,7 @@ use Illuminate\Config\Repository; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Resolvers\PathTenantResolver; +use Stancl\Tenancy\Resolvers\RequestDataTenantResolver; /** * Allows customizing Fortify action redirects so that they can also redirect @@ -38,7 +39,7 @@ class FortifyRouteBootstrapper implements TenancyBootstrapper * is handled in another way (TenancyUrlGenerator::$passTenantParameter for both, * UrlGeneratorBootstrapper:$addTenantParameterToDefaults for path identification). */ - public static bool $passTenantParameter = true; + public static bool $passTenantParameter = false; /** * Tenant route that serves as Fortify's home (e.g. a tenant dashboard route). @@ -47,12 +48,22 @@ class FortifyRouteBootstrapper implements TenancyBootstrapper public static string|null $fortifyHome = 'tenant.dashboard'; /** - * Use default parameter names ('tenant' name and tenant key value) instead of the parameter name - * and column name configured in the path resolver config. + * Follow the query_parameter config instead of the tenant_parameter_name (path identification) config. * - * You want to enable this when using query string identification while having customized that config. + * This only has an effect when: + * - $passTenantParameter is enabled, and + * - the tenant_parameter_name config for the path resolver differs from the query_parameter config for the request data resolver. + * + * In such a case, instead of adding ['tenant' => '...'] to the route parameters (or whatever your tenant_parameter_name is if not 'tenant'), + * the query_parameter will be passed instead, e.g. ['team' => '...'] if your query_parameter config is 'team'. + * + * This is enabled by default because typically you will not need $passTenantParameter with path identification. + * UrlGeneratorBootstrapper::$addTenantParameterToDefaults is recommended instead when using path identification. + * + * On the other hand, when using request data identification (specifically query string) you WILL need to + * pass the parameter therefore you would use $passTenantParameter. */ - public static bool $defaultParameterNames = false; + public static bool $passQueryParameter = true; protected array $originalFortifyConfig = []; @@ -74,8 +85,14 @@ class FortifyRouteBootstrapper implements TenancyBootstrapper protected function useTenantRoutesInFortify(Tenant $tenant): void { - $tenantParameterName = static::$defaultParameterNames ? 'tenant' : PathTenantResolver::tenantParameterName(); - $tenantParameterValue = static::$defaultParameterNames ? $tenant->getTenantKey() : PathTenantResolver::tenantParameterValue($tenant); + if (static::$passQueryParameter) { + // todo@tests + $tenantParameterName = RequestDataTenantResolver::queryParameterName(); + $tenantParameterValue = RequestDataTenantResolver::payloadValue($tenant); + } else { + $tenantParameterName = PathTenantResolver::tenantParameterName(); + $tenantParameterValue = PathTenantResolver::tenantParameterValue($tenant); + } $generateLink = function (string $redirect) use ($tenantParameterValue, $tenantParameterName) { return route($redirect, static::$passTenantParameter ? [$tenantParameterName => $tenantParameterValue] : []); @@ -89,7 +106,7 @@ class FortifyRouteBootstrapper implements TenancyBootstrapper if (static::$fortifyHome) { // Generate the home route URL with the tenant parameter and make it the Fortify home route - $this->config->set('fortify.home', route(static::$fortifyHome, static::$passTenantParameter ? [$tenantParameterName => $tenantParameterValue] : [])); + $this->config->set('fortify.home', $generateLink(static::$fortifyHome)); } $this->config->set('fortify.redirects', $redirects); diff --git a/src/Bootstrappers/UrlGeneratorBootstrapper.php b/src/Bootstrappers/UrlGeneratorBootstrapper.php index b5289904..6c923d21 100644 --- a/src/Bootstrappers/UrlGeneratorBootstrapper.php +++ b/src/Bootstrappers/UrlGeneratorBootstrapper.php @@ -67,13 +67,15 @@ class UrlGeneratorBootstrapper implements TenancyBootstrapper $defaultParameters = $this->originalUrlGenerator->getDefaultParameters(); if (static::$addTenantParameterToDefaults) { - $defaultParameters = array_merge( - $defaultParameters, - [ - PathTenantResolver::tenantParameterName() => PathTenantResolver::tenantParameterValue($tenant), // path identification - 'tenant' => $tenant->getTenantKey(), // query string identification - ], - ); + $tenantParameterName = PathTenantResolver::tenantParameterName(); + + $defaultParameters = array_merge($defaultParameters, [ + $tenantParameterName => PathTenantResolver::tenantParameterValue($tenant), + ]); + + foreach (PathTenantResolver::allowedExtraModelColumns() as $column) { + $defaultParameters["$tenantParameterName:$column"] = $tenant->getAttribute($column); + } } $newGenerator->defaults($defaultParameters); diff --git a/src/Middleware/InitializeTenancyByRequestData.php b/src/Middleware/InitializeTenancyByRequestData.php index a4e8a9c2..d7a13e2c 100644 --- a/src/Middleware/InitializeTenancyByRequestData.php +++ b/src/Middleware/InitializeTenancyByRequestData.php @@ -18,9 +18,6 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware { use UsableWithEarlyIdentification; - public static string $header = 'X-Tenant'; - public static string $cookie = 'tenant'; - public static string $queryParameter = 'tenant'; public static ?Closure $onFail = null; public static bool $requireCookieEncryption = false; @@ -54,18 +51,19 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware protected function getPayload(Request $request): string|null { - if (static::$header && $request->hasHeader(static::$header)) { - $payload = $request->header(static::$header); - } elseif ( - static::$queryParameter && - $request->has(static::$queryParameter) - ) { - $payload = $request->get(static::$queryParameter); - } elseif (static::$cookie && $request->hasCookie(static::$cookie)) { - $payload = $request->cookie(static::$cookie); + $headerName = RequestDataTenantResolver::headerName(); + $queryParameterName = RequestDataTenantResolver::queryParameterName(); + $cookieName = RequestDataTenantResolver::cookieName(); + + if ($headerName && $request->hasHeader($headerName)) { + $payload = $request->header($headerName); + } elseif ($queryParameterName && $request->has($queryParameterName)) { + $payload = $request->get($queryParameterName); + } elseif ($cookieName && $request->hasCookie($cookieName)) { + $payload = $request->cookie($cookieName); if ($payload && is_string($payload)) { - $payload = $this->getTenantFromCookie($payload); + $payload = $this->getTenantFromCookie($cookieName, $payload); } } else { $payload = null; @@ -86,12 +84,12 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware return (bool) $this->getPayload($request); } - protected function getTenantFromCookie(string $cookie): string|null + protected function getTenantFromCookie(string $cookieName, string $cookieValue): string|null { // If the cookie looks like it's encrypted, we try decrypting it - if (str_starts_with($cookie, 'eyJpdiI')) { + if (str_starts_with($cookieValue, 'eyJpdiI')) { try { - $json = base64_decode($cookie); + $json = base64_decode($cookieValue); $data = json_decode($json, true); if ( @@ -100,9 +98,9 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware ) { // We can confidently assert that the cookie is encrypted. If this call were to fail, this method would just // return null and the cookie payload would get skipped. - $cookie = CookieValuePrefix::validate( - static::$cookie, - Crypt::decryptString($cookie), + $cookieValue = CookieValuePrefix::validate( + $cookieName, + Crypt::decryptString($cookieValue), Crypt::getAllKeys() ); } @@ -113,6 +111,6 @@ class InitializeTenancyByRequestData extends IdentificationMiddleware return null; } - return $cookie; + return $cookieValue; } } diff --git a/src/Overrides/TenancyUrlGenerator.php b/src/Overrides/TenancyUrlGenerator.php index 4c6120a8..ed14d5b5 100644 --- a/src/Overrides/TenancyUrlGenerator.php +++ b/src/Overrides/TenancyUrlGenerator.php @@ -9,6 +9,7 @@ use Illuminate\Routing\UrlGenerator; use Illuminate\Support\Arr; use InvalidArgumentException; use Stancl\Tenancy\Resolvers\PathTenantResolver; +use Stancl\Tenancy\Resolvers\RequestDataTenantResolver; /** * This class is used in place of the default UrlGenerator when UrlGeneratorBootstrapper is enabled. @@ -86,12 +87,22 @@ class TenancyUrlGenerator extends UrlGenerator public static array $overrides = []; /** - * Use default parameter names ('tenant' name and tenant key value) instead of the parameter name - * and column name configured in the path resolver config. + * Follow the query_parameter config instead of the tenant_parameter_name (path identification) config. * - * You want to enable this when using query string identification while having customized that config. + * This only has an effect when: + * - $passTenantParameterToRoutes is enabled, and + * - the tenant_parameter_name config for the path resolver differs from the query_parameter config for the request data resolver. + * + * In such a case, instead of adding ['tenant' => '...'] to the route parameters (or whatever your tenant_parameter_name is if not 'tenant'), + * the query_parameter will be passed instead, e.g. ['team' => '...'] if your query_parameter config is 'team'. + * + * This is enabled by default because typically you will not need $passTenantParameterToRoutes with path identification. + * UrlGeneratorBootstrapper::$addTenantParameterToDefaults is recommended instead when using path identification. + * + * On the other hand, when using request data identification (specifically query string) you WILL need to pass the parameter + * directly to route() calls, therefore you would use $passTenantParameterToRoutes to avoid having to do that manually. */ - public static bool $defaultParameterNames = false; + public static bool $passQueryParameter = true; /** * Override the route() method so that the route name gets prefixed @@ -175,11 +186,14 @@ class TenancyUrlGenerator extends UrlGenerator protected function addTenantParameter(array $parameters): array { if (tenant() && static::$passTenantParameterToRoutes) { - if (static::$defaultParameterNames) { - return array_merge($parameters, ['tenant' => tenant()->getTenantKey()]); - } else { - return array_merge($parameters, [PathTenantResolver::tenantParameterName() => PathTenantResolver::tenantParameterValue(tenant())]); + if (static::$passQueryParameter) { + $queryParameterName = RequestDataTenantResolver::queryParameterName(); + if ($queryParameterName !== null) { + return array_merge($parameters, [$queryParameterName => RequestDataTenantResolver::payloadValue(tenant())]); + } } + + return array_merge($parameters, [PathTenantResolver::tenantParameterName() => PathTenantResolver::tenantParameterValue(tenant())]); } else { return $parameters; } diff --git a/src/Resolvers/RequestDataTenantResolver.php b/src/Resolvers/RequestDataTenantResolver.php index 7ebc90ab..4d8b3277 100644 --- a/src/Resolvers/RequestDataTenantResolver.php +++ b/src/Resolvers/RequestDataTenantResolver.php @@ -20,7 +20,9 @@ class RequestDataTenantResolver extends Contracts\CachedTenantResolver { $payload = (string) $args[0]; - if ($payload && $tenant = tenancy()->find($payload, withRelations: true)) { + $column = static::tenantModelColumn(); + + if ($payload && $tenant = tenancy()->find($payload, $column, withRelations: true)) { return $tenant; } @@ -29,8 +31,43 @@ class RequestDataTenantResolver extends Contracts\CachedTenantResolver public function getPossibleCacheKeys(Tenant&Model $tenant): array { + // todo@tests return [ - $this->formatCacheKey($tenant->getTenantKey()), + $this->formatCacheKey(static::payloadValue($tenant)), ]; } + + public static function payloadValue(Tenant $tenant): string + { + return $tenant->getAttribute(static::tenantModelColumn()); + } + + public static function tenantModelColumn(): string + { + return config('tenancy.identification.resolvers.' . static::class . '.tenant_model_column') ?? tenancy()->model()->getTenantKeyName(); + } + + /** + * Returns the name of the header used for identification, or null if header identification is disabled. + */ + public static function headerName(): string|null + { + return config('tenancy.identification.resolvers.' . static::class . '.header'); + } + + /** + * Returns the name of the query parameter used for identification, or null if query parameter identification is disabled. + */ + public static function queryParameterName(): string|null + { + return config('tenancy.identification.resolvers.' . static::class . '.query_parameter'); + } + + /** + * Returns the name of the cookie used for identification, or null if cookie identification is disabled. + */ + public static function cookieName(): string|null + { + return config('tenancy.identification.resolvers.' . static::class . '.cookie'); + } } diff --git a/t b/t index 4fd5931c..36d2d391 100755 --- a/t +++ b/t @@ -1,3 +1,9 @@ #!/bin/bash -docker compose exec -e COLUMNS=$(tput cols) -T test vendor/bin/pest --color=always --no-coverage --filter "$@" +if [[ "${CLAUDECODE}" != "1" ]]; then + COLOR_FLAG="--colors=always" +else + COLOR_FLAG="--colors=never" +fi + +docker compose exec -e COLUMNS=$(tput cols) -T test vendor/bin/pest ${COLOR_FLAG} --no-coverage --filter "$@" diff --git a/test b/test index b8bd8fa0..0df8f63e 100755 --- a/test +++ b/test @@ -1,4 +1,10 @@ #!/bin/bash +if [[ "${CLAUDECODE}" != "1" ]]; then + COLOR_FLAG="--colors=always" +else + COLOR_FLAG="--colors=never" +fi + # --columns doesn't seem to work at the moment, so we're setting it using an environment variable -docker compose exec -e COLUMNS=$(tput cols) -T test vendor/bin/pest --colors=always "$@" +docker compose exec -e COLUMNS=$(tput cols) -T test vendor/bin/pest ${COLOR_FLAG} "$@" diff --git a/tests/Bootstrappers/FortifyRouteBootstrapperTest.php b/tests/Bootstrappers/FortifyRouteBootstrapperTest.php index 1942a1c5..63f0f2a0 100644 --- a/tests/Bootstrappers/FortifyRouteBootstrapperTest.php +++ b/tests/Bootstrappers/FortifyRouteBootstrapperTest.php @@ -20,7 +20,7 @@ afterEach(function () { FortifyRouteBootstrapper::$passTenantParameter = true; FortifyRouteBootstrapper::$fortifyRedirectMap = []; FortifyRouteBootstrapper::$fortifyHome = 'tenant.dashboard'; - FortifyRouteBootstrapper::$defaultParameterNames = false; + FortifyRouteBootstrapper::$passQueryParameter = false; }); test('fortify route tenancy bootstrapper updates fortify config correctly', function() { diff --git a/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php b/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php index 77d50073..39fcc475 100644 --- a/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php +++ b/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php @@ -1,5 +1,6 @@ route('home'))->name('home'); - // Tenant route name prefix is 'tenant.' by default - Route::get('/tenant/home', fn () => route('tenant.home'))->name('tenant.home'); + 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(); - $centralRouteUrl = route('home'); - $tenantRouteUrl = route('tenant.home'); config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); tenancy()->initialize($tenant); // Route names don't get prefixed when TenancyUrlGenerator::$prefixRouteNames is false (default) - expect(route('home'))->toBe($centralRouteUrl); + expect(route('home'))->toBe('http://localhost/central/home'); - // When $prefixRouteNames is true, the route name passed to the route() helper ('home') gets prefixed with 'tenant.' automatically. + // When $prefixRouteNames is true, the route name passed to the route() helper ('home') gets prefixed automatically. TenancyUrlGenerator::$prefixRouteNames = true; - expect(route('home'))->toBe($tenantRouteUrl); + expect(route('home'))->toBe('http://localhost/tenant/home'); - // The 'tenant.home' route name doesn't get prefixed -- it is already prefixed with 'tenant.' - expect(route('tenant.home'))->toBe($tenantRouteUrl); + // 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('the route helper can receive the tenant parameter automatically', function ( - string $identification, - bool $addTenantParameterToDefaults, - bool $passTenantParameterToRoutes, -) { +test('path identification route helper behavior', function (bool $addTenantParameterToDefaults, bool $passTenantParameterToRoutes) { config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); - $appUrl = config('app.url'); - UrlGeneratorBootstrapper::$addTenantParameterToDefaults = $addTenantParameterToDefaults; - - // When the tenant parameter isn't added to defaults, the tenant parameter has to be passed "manually" - // by setting $passTenantParameterToRoutes to true. This is only preferable with query string identification. - // With path identification, this ultimately doesn't have any effect - // if UrlGeneratorBootstrapper::$addTenantParameterToDefaults is true, - // but TenancyUrlGenerator::$passTenantParameterToRoutes can still be used instead. TenancyUrlGenerator::$passTenantParameterToRoutes = $passTenantParameterToRoutes; $tenant = Tenant::create(); - $tenantKey = $tenant->getTenantKey(); - Route::get('/central/home', fn () => route('home'))->name('home'); - - $tenantRoute = $identification === InitializeTenancyByPath::class ? "/{tenant}/home" : "/tenant/home"; - - Route::get($tenantRoute, fn () => route('tenant.home')) + Route::get('/{tenant}/home', fn () => tenant('id')) ->name('tenant.home') - ->middleware(['tenant', $identification]); + ->middleware([InitializeTenancyByPath::class]); tenancy()->initialize($tenant); - $expectedUrl = match (true) { - $identification === InitializeTenancyByRequestData::class && $passTenantParameterToRoutes => "{$appUrl}/tenant/home?tenant={$tenantKey}", - $identification === InitializeTenancyByRequestData::class => "{$appUrl}/tenant/home", // $passTenantParameterToRoutes is false - $identification === InitializeTenancyByPath::class && ($addTenantParameterToDefaults || $passTenantParameterToRoutes) => "{$appUrl}/{$tenantKey}/home", - $identification === InitializeTenancyByPath::class => null, // Should throw an exception -- route() doesn't receive the tenant parameter in this case - }; - - if ($expectedUrl === null) { + if (! $addTenantParameterToDefaults && ! $passTenantParameterToRoutes) { expect(fn () => route('tenant.home'))->toThrow(UrlGenerationException::class, 'Missing parameter: tenant'); } else { - expect(route('tenant.home'))->toBe($expectedUrl); + // 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([InitializeTenancyByPath::class, InitializeTenancyByRequestData::class]) - ->with([true, false]) // UrlGeneratorBootstrapper::$addTenantParameterToDefaults +})->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]]); diff --git a/tests/PathIdentificationTest.php b/tests/PathIdentificationTest.php index 1df74092..79cd3816 100644 --- a/tests/PathIdentificationTest.php +++ b/tests/PathIdentificationTest.php @@ -35,6 +35,11 @@ beforeEach(function () { }); }); +afterEach(function () { + InitializeTenancyByPath::$onFail = null; + Tenant::$extraCustomColumns = []; +}); + test('tenant can be identified by path', function () { Tenant::create([ 'id' => 'acme', @@ -150,6 +155,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'); @@ -185,8 +191,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 () { @@ -218,8 +222,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 () { @@ -243,6 +245,4 @@ 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 }); diff --git a/tests/RequestDataIdentificationTest.php b/tests/RequestDataIdentificationTest.php index f04d99a7..0f635bf1 100644 --- a/tests/RequestDataIdentificationTest.php +++ b/tests/RequestDataIdentificationTest.php @@ -2,9 +2,11 @@ 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; @@ -15,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'); }); + From f771aa8645dac1e630b399e42c7621cff9ac838d Mon Sep 17 00:00:00 2001 From: lukinovec Date: Mon, 2 Jun 2025 19:05:17 +0200 Subject: [PATCH 29/42] [4.x] Test that route model binding works correctly with path identification (#1360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Test that route model binding works with path identification (closure-based routes) * Correct test name * Update tests/PathIdentificationTest.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * make assertions more clear --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Samuel Štancl --- tests/PathIdentificationTest.php | 49 ++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/PathIdentificationTest.php b/tests/PathIdentificationTest.php index 79cd3816..fd602a9f 100644 --- a/tests/PathIdentificationTest.php +++ b/tests/PathIdentificationTest.php @@ -3,15 +3,29 @@ 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 () { @@ -246,3 +260,38 @@ test('any extra model column needs to be whitelisted', function () { config(['tenancy.identification.resolvers.' . PathTenantResolver::class . '.allowed_extra_model_columns' => ['slug']]); pest()->get('/acme/foo')->assertSee($tenant->getTenantKey()); }); + +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'); +}); From e74e1f92e12726b34739d7d2057a1a459a881927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 2 Jun 2025 20:34:49 +0200 Subject: [PATCH 30/42] Make RouteMode enum backed (#1362) --- .php-cs-fixer.php | 1 + src/Enums/RouteMode.php | 11 +++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 15a75d64..c0fe775c 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -132,6 +132,7 @@ $finder = Finder::create() ->in([ $project_path . '/src', ]) + ->exclude('Enums') ->name('*.php') ->notName('*.blade.php') ->ignoreDotFiles(true) diff --git a/src/Enums/RouteMode.php b/src/Enums/RouteMode.php index b64e550c..b04833d9 100644 --- a/src/Enums/RouteMode.php +++ b/src/Enums/RouteMode.php @@ -4,9 +4,12 @@ declare(strict_types=1); namespace Stancl\Tenancy\Enums; -enum RouteMode +/** + * Note: The backing values are not part of the public API and are subject to change. + */ +enum RouteMode: int { - case TENANT; - case CENTRAL; - case UNIVERSAL; + case CENTRAL = 0b01; + case TENANT = 0b10; + case UNIVERSAL = 0b11; } From 2057e1e5ae469d305144d9e5f8a842d915d489f9 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 5 Jun 2025 05:06:05 +0200 Subject: [PATCH 31/42] [4.x] Make forcing RLS configurable (#1293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add `$forceRls` static property to tenants:rls * Set `$forceRls` in tests where scoping is tested, add non-superuser, non-bypassrls table owner test * Move DROP TABLE statement * Remove try/catch * Put DROP OWNED BY into try/catch * Static property cleanup in afterEach * Make with() matrix syntax more clear by using with() multiple times * Fix typo, improve comment * Move and update force RLS comment * Add test for `$forceRls = false`, refactor BYPASSRLS test * Update link in test comment * Add a dataset for `$forceRls` in the table owner test, fix BYPASSRLS test * Correct PR link comment * minor fixes * Add test that makes the bypassrls/forceRls behavior clear * Delete redundant test * cleanup * Update tests/RLS/TableManagerTest.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Samuel Štancl Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Commands/CreateUserWithRLSPolicies.php | 28 ++-- tests/RLS/PolicyTest.php | 14 +- tests/RLS/TableManagerTest.php | 146 ++++++++++++++++++++- tests/RLS/TraitManagerTest.php | 13 +- 4 files changed, 181 insertions(+), 20 deletions(-) diff --git a/src/Commands/CreateUserWithRLSPolicies.php b/src/Commands/CreateUserWithRLSPolicies.php index aa171d58..420df935 100644 --- a/src/Commands/CreateUserWithRLSPolicies.php +++ b/src/Commands/CreateUserWithRLSPolicies.php @@ -22,6 +22,23 @@ class CreateUserWithRLSPolicies extends Command protected $description = "Creates RLS policies for all tables related to the tenant table. Also creates the RLS user if it doesn't exist yet"; + /** + * Force, rather than just enable, the created RLS policies. + * + * By default, table owners bypass RLS policies. When this is enabled, + * they also need the BYPASSRLS permission. If your setup lets you create + * a user with BYPASSRLS, you may prefer leaving this on for additional + * safety. Otherwise, if you can't use BYPASSRLS, you can set this to false + * and depend on the behavior of table owners bypassing RLS automatically. + * + * This setting generally doesn't affect behavior at all with "default" + * setups, however if you have a more custom setup, with additional users + * involved (e.g. central connection user not being the same user that + * creates tables, or the created "RLS user" creating some tables) you + * should take care with how you configure this. + */ + public static bool $forceRls = true; + public function handle(PermissionControlledPostgreSQLSchemaManager $manager): int { $username = config('tenancy.rls.user.username'); @@ -49,14 +66,9 @@ class CreateUserWithRLSPolicies extends Command // Enable RLS scoping on the table (without this, queries won't be scoped using RLS) DB::statement("ALTER TABLE {$table} ENABLE ROW LEVEL SECURITY"); - /** - * Force RLS scoping on the table, so that the table owner users - * don't bypass the scoping – table owners bypass RLS by default. - * - * E.g. when using a custom implementation where you create tables as the RLS user, - * the queries won't be scoped for the RLS user unless we force the RLS scoping using this query. - */ - DB::statement("ALTER TABLE {$table} FORCE ROW LEVEL SECURITY"); + if (static::$forceRls) { + DB::statement("ALTER TABLE {$table} FORCE ROW LEVEL SECURITY"); + } } /** diff --git a/tests/RLS/PolicyTest.php b/tests/RLS/PolicyTest.php index 7278776a..ee9bf5cc 100644 --- a/tests/RLS/PolicyTest.php +++ b/tests/RLS/PolicyTest.php @@ -20,6 +20,7 @@ use Stancl\Tenancy\Bootstrappers\PostgresRLSBootstrapper; use function Stancl\Tenancy\Tests\pest; beforeEach(function () { + CreateUserWithRLSPolicies::$forceRls = true; TraitRLSManager::$excludedModels = [Article::class]; TraitRLSManager::$modelDirectories = [__DIR__ . '/Etc']; @@ -79,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(" @@ -184,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'); @@ -216,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]); diff --git a/tests/RLS/TableManagerTest.php b/tests/RLS/TableManagerTest.php index fd9c6f44..c0520a1d 100644 --- a/tests/RLS/TableManagerTest.php +++ b/tests/RLS/TableManagerTest.php @@ -22,6 +22,7 @@ use Stancl\Tenancy\Database\Exceptions\RecursiveRelationshipException; use function Stancl\Tenancy\Tests\pest; beforeEach(function () { + CreateUserWithRLSPolicies::$forceRls = true; TableRLSManager::$scopeByDefault = true; Event::listen(TenancyInitialized::class, BootstrapTenancy::class); @@ -107,6 +108,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')); @@ -159,7 +164,9 @@ 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) { + CreateUserWithRLSPolicies::$forceRls = $forceRls; + // 3-levels deep relationship Schema::create('notes', function (Blueprint $table) { $table->id(); @@ -320,7 +327,7 @@ 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([true, false]); test('table rls manager generates relationship trees with tables related to the tenants table', function (bool $scopeByDefault) { TableRLSManager::$scopeByDefault = $scopeByDefault; @@ -535,6 +542,109 @@ test('table rls manager generates relationship trees with tables related to the ]); })->with([true, false]); +// 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->string('name'); + + $table->string('tenant_id')->comment('rls'); + $table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade'); + + $table->timestamps(); + }); + + $tenant1 = Tenant::create(); + + // Create RLS policy for the orders table + pest()->artisan('tenants:rls'); + + $tenant1->run(fn () => Order::create(['name' => 'order1', 'tenant_id' => $tenant1->getTenantKey()])); + + // 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(); + }); + + $tenant1 = Tenant::create(); + + // Create RLS policy for the orders table + pest()->artisan('tenants:rls'); + + $tenant1->run(fn () => Order::create(['name' => 'order1', 'tenant_id' => $tenant1->getTenantKey()])); + + // 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([ << app(TableRLSManager::class)->generateTrees())->not()->toThrow(RecursiveRelationshipException::class); }); +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 = []; @@ -715,3 +852,8 @@ class Author extends Model { protected $guarded = []; } + +class Order extends Model +{ + protected $guarded = []; +} diff --git a/tests/RLS/TraitManagerTest.php b/tests/RLS/TraitManagerTest.php index af2f6f84..b88b147b 100644 --- a/tests/RLS/TraitManagerTest.php +++ b/tests/RLS/TraitManagerTest.php @@ -28,6 +28,7 @@ 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]; @@ -78,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); @@ -149,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; @@ -263,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 */ From e1fc0e107d236a113ab5e67fc1fa0044925100e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 18 Jun 2025 23:29:24 +0200 Subject: [PATCH 32/42] remove ignition dependencies --- composer.json | 2 - .../TenantCouldNotBeIdentifiedException.php | 42 +------------------ .../TenantColumnNotWhitelistedException.php | 5 +-- ...enantCouldNotBeIdentifiedByIdException.php | 5 +-- ...antCouldNotBeIdentifiedByPathException.php | 5 +-- ...dNotBeIdentifiedByRequestDataException.php | 5 +-- ...tCouldNotBeIdentifiedOnDomainException.php | 5 +-- 7 files changed, 7 insertions(+), 62 deletions(-) diff --git a/composer.json b/composer.json index e3a7faf4..3d3bd3eb 100644 --- a/composer.json +++ b/composer.json @@ -20,8 +20,6 @@ "ext-json": "*", "illuminate/support": "^12.0", "laravel/tinker": "^2.0", - "facade/ignition-contracts": "^1.0.2", - "spatie/ignition": "^1.4", "ramsey/uuid": "^4.7.3", "stancl/jobpipeline": "2.0.0-rc5", "stancl/virtualcolumn": "^1.5.0", diff --git a/src/Contracts/TenantCouldNotBeIdentifiedException.php b/src/Contracts/TenantCouldNotBeIdentifiedException.php index a83a0c75..b0ff74a4 100644 --- a/src/Contracts/TenantCouldNotBeIdentifiedException.php +++ b/src/Contracts/TenantCouldNotBeIdentifiedException.php @@ -5,49 +5,11 @@ declare(strict_types=1); namespace Stancl\Tenancy\Contracts; use Exception; -use Spatie\ErrorSolutions\Contracts\BaseSolution; -use Spatie\ErrorSolutions\Contracts\ProvidesSolution; -abstract class TenantCouldNotBeIdentifiedException extends Exception implements ProvidesSolution +abstract class TenantCouldNotBeIdentifiedException extends Exception { - /** Default solution title. */ - protected string $solutionTitle = 'Tenant could not be identified'; - - /** Default solution description. */ - protected string $solutionDescription = 'Are you sure this tenant exists?'; - - /** Set the message. */ - protected function tenantCouldNotBeIdentified(string $how): static + protected function tenantCouldNotBeIdentified(string $how): void { $this->message = 'Tenant could not be identified ' . $how; - - return $this; - } - - /** Set the solution title. */ - protected function title(string $solutionTitle): static - { - $this->solutionTitle = $solutionTitle; - - return $this; - } - - /** Set the solution description. */ - protected function description(string $solutionDescription): static - { - $this->solutionDescription = $solutionDescription; - - return $this; - } - - /** Get the Ignition description. */ - public function getSolution(): BaseSolution - { - return BaseSolution::create($this->solutionTitle) - ->setSolutionDescription($this->solutionDescription) - ->setDocumentationLinks([ - 'Tenants' => 'https://tenancyforlaravel.com/docs/v3/tenants', - 'Tenant Identification' => 'https://tenancyforlaravel.com/docs/v3/tenant-identification', - ]); } } diff --git a/src/Exceptions/TenantColumnNotWhitelistedException.php b/src/Exceptions/TenantColumnNotWhitelistedException.php index 6b59d8f7..20eb70b2 100644 --- a/src/Exceptions/TenantColumnNotWhitelistedException.php +++ b/src/Exceptions/TenantColumnNotWhitelistedException.php @@ -10,9 +10,6 @@ class TenantColumnNotWhitelistedException extends TenantCouldNotBeIdentifiedExce { public function __construct(int|string $tenant_id) { - $this - ->tenantCouldNotBeIdentified("on path with tenant key: $tenant_id (column not whitelisted)") - ->title('Tenant could not be identified on this route because the used column is not whitelisted.') - ->description('Please add the column to the list of allowed columns in the PathTenantResolver config.'); + $this->tenantCouldNotBeIdentified("on path with tenant key: $tenant_id (column not whitelisted)"); } } diff --git a/src/Exceptions/TenantCouldNotBeIdentifiedByIdException.php b/src/Exceptions/TenantCouldNotBeIdentifiedByIdException.php index 6f61455e..36ef1d09 100644 --- a/src/Exceptions/TenantCouldNotBeIdentifiedByIdException.php +++ b/src/Exceptions/TenantCouldNotBeIdentifiedByIdException.php @@ -10,9 +10,6 @@ class TenantCouldNotBeIdentifiedByIdException extends TenantCouldNotBeIdentified { public function __construct(int|string $tenant_id) { - $this - ->tenantCouldNotBeIdentified("by tenant key: $tenant_id") - ->title('Tenant could not be identified with that key') - ->description('Are you sure the key is correct and the tenant exists?'); + $this->tenantCouldNotBeIdentified("by tenant key: $tenant_id"); } } diff --git a/src/Exceptions/TenantCouldNotBeIdentifiedByPathException.php b/src/Exceptions/TenantCouldNotBeIdentifiedByPathException.php index ef51f8bf..a004b39a 100644 --- a/src/Exceptions/TenantCouldNotBeIdentifiedByPathException.php +++ b/src/Exceptions/TenantCouldNotBeIdentifiedByPathException.php @@ -10,9 +10,6 @@ class TenantCouldNotBeIdentifiedByPathException extends TenantCouldNotBeIdentifi { public function __construct(int|string $tenant_id) { - $this - ->tenantCouldNotBeIdentified("on path with tenant key: $tenant_id") - ->title('Tenant could not be identified on this path') - ->description('Did you forget to create a tenant for this path?'); + $this->tenantCouldNotBeIdentified("on path with tenant key: $tenant_id"); } } diff --git a/src/Exceptions/TenantCouldNotBeIdentifiedByRequestDataException.php b/src/Exceptions/TenantCouldNotBeIdentifiedByRequestDataException.php index 1f1c98a1..ab2b92a9 100644 --- a/src/Exceptions/TenantCouldNotBeIdentifiedByRequestDataException.php +++ b/src/Exceptions/TenantCouldNotBeIdentifiedByRequestDataException.php @@ -10,9 +10,6 @@ class TenantCouldNotBeIdentifiedByRequestDataException extends TenantCouldNotBeI { public function __construct(mixed $payload) { - $this - ->tenantCouldNotBeIdentified("by request data with payload: $payload") - ->title('Tenant could not be identified using this request data') - ->description('Did you forget to create a tenant with this id?'); + $this->tenantCouldNotBeIdentified("by request data with payload: $payload"); } } diff --git a/src/Exceptions/TenantCouldNotBeIdentifiedOnDomainException.php b/src/Exceptions/TenantCouldNotBeIdentifiedOnDomainException.php index 0421fe1b..5470c60a 100644 --- a/src/Exceptions/TenantCouldNotBeIdentifiedOnDomainException.php +++ b/src/Exceptions/TenantCouldNotBeIdentifiedOnDomainException.php @@ -10,9 +10,6 @@ class TenantCouldNotBeIdentifiedOnDomainException extends TenantCouldNotBeIdenti { public function __construct(string $domain) { - $this - ->tenantCouldNotBeIdentified("on domain $domain") - ->title('Tenant could not be identified on this domain') - ->description('Did you forget to create a tenant for this domain?'); + $this->tenantCouldNotBeIdentified("on domain $domain"); } } From 12fcbabd76315fdab6c33318354ec28b9bc8de9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Wed, 18 Jun 2025 23:52:35 +0200 Subject: [PATCH 33/42] phpstan fix --- src/helpers.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers.php b/src/helpers.php index 5794a000..903c5c40 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -30,7 +30,7 @@ if (! function_exists('tenant')) { return app(Tenant::class); } - return app(Tenant::class)?->getAttribute($key); + return app(Tenant::class)->getAttribute($key); } } From 748122906365f97f528897a2eec4d3d84a790388 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 19 Jun 2025 00:12:38 +0200 Subject: [PATCH 34/42] revert regression in last commit, opt for a phpstan ignore instead --- src/helpers.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/helpers.php b/src/helpers.php index 903c5c40..c8f5c9b3 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -30,7 +30,8 @@ if (! function_exists('tenant')) { return app(Tenant::class); } - return app(Tenant::class)->getAttribute($key); + // @phpstan-ignore-next-line nullsafe.neverNull + return app(Tenant::class)?->getAttribute($key); } } From 7e1fe075f40d8a4674ef96228d9a43c116dc49a8 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 26 Jun 2025 13:39:11 +0200 Subject: [PATCH 35/42] [4.x] Test that global scopes on syncable models can break resource syncing, and that $scopeGetModelQuery can be used as a workaround for that (#1285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add test for syncable models with global scopes * minor fixes * Make test clearer * Improve test name * Clarify scopeGetModelQuery test, document edge case * Fix assertion * Delete extra newline * Update the scopeGetModelQuery test so that it tests a realistic case * Clarify test * cleanup * Try simplifying the tests * Revert change to test adding unnecessary complexity * Make test clear, extensively commented and as simple as possible * Delete unused import * Make test clearer * Polish comments * Improve comment * Explicitly reset global scopes on models in beforeEach() * Simplify comments in test * Revert changes in test * add assertion * add global scope reset to afterEach --------- Co-authored-by: Samuel Štancl --- tests/ResourceSyncingTest.php | 101 ++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/tests/ResourceSyncingTest.php b/tests/ResourceSyncingTest.php index a1870e86..3250c37a 100644 --- a/tests/ResourceSyncingTest.php +++ b/tests/ResourceSyncingTest.php @@ -43,6 +43,8 @@ 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 () { @@ -68,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', @@ -106,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 () { @@ -1173,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. * @@ -1244,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'; @@ -1321,6 +1421,7 @@ class CentralCompany extends Model implements SyncMaster ]; } } + class TenantCompany extends Model implements Syncable { use ResourceSyncing; From 1e926a1dde3c9f99340ca35df88dd1848309d8a3 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 1 Jul 2025 00:32:42 +0200 Subject: [PATCH 36/42] [4.x] Route cloning refactor (#1353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactor cloning action, update tests * Delete redundant "should not be cloned" part from shouldBeCloned() * Use 'clone' instead of a universal route in tenant parameter removal test * Improve comment * Add test for cloneRoutesWithMiddleware(), correct existing tests * Allow cloning specific routes by name * Fix typo in CloneActionTest Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * clean up CloneRoutesAsTenant, add a todo * phpstan * Add test for handling 'clone' in MW groups * Improve regression test * Improve regression test * Handle nested cloning flags in CloneRoutesAsTenant * Ignore routes that are already considered tenant routes from cloning, update test accordingly * Clarify cloning logic * CloneRoutesAsTenant cleanup * Rewrite clone action annotation, fix fluent usage bug * Improve tests (comments, use $tenant->id instead of $tenant->getTenantKey()) * Test that the clone action can be used fluently without issues now (could serve as a regression test for the routesToClone change in previous commit) * Minor annotation improvements * Improve route cloning action docblock * Add note about clearing the $routesToClone property * improve docblock * clean up tests * fix typo --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Samuel Štancl --- src/Actions/CloneRoutesAsTenant.php | 250 +++++++++------- tests/CloneActionTest.php | 445 ++++++++++++++-------------- tests/EarlyIdentificationTest.php | 18 +- 3 files changed, 362 insertions(+), 351 deletions(-) diff --git a/src/Actions/CloneRoutesAsTenant.php b/src/Actions/CloneRoutesAsTenant.php index 86c3df9d..c5818878 100644 --- a/src/Actions/CloneRoutesAsTenant.php +++ b/src/Actions/CloneRoutesAsTenant.php @@ -7,40 +7,72 @@ namespace Stancl\Tenancy\Actions; use Closure; use Illuminate\Routing\Route; use Illuminate\Routing\Router; -use Illuminate\Support\Collection; use Illuminate\Support\Str; -use Stancl\Tenancy\Enums\RouteMode; use Stancl\Tenancy\Resolvers\PathTenantResolver; /** - * The CloneRoutesAsTenant action clones - * routes flagged with the 'universal' middleware, - * all routes without a flag if the default route mode is universal, - * and routes that directly use the InitializeTenancyByPath middleware. + * Clones either all existing routes for which shouldBeCloned() returns true + * (by default, all routes with any middleware present in $cloneRoutesWithMiddleware), + * or if any routes were manually added to $routesToClone using $action->cloneRoute($route), + * clone just the routes in $routesToClone. This means that only the routes specified + * by cloneRoute() (which can be chained infinitely -- you can specify as many routes as you want) + * will be cloned. * - * The main purpose of this action is to make the integration - * of packages (e.g., Jetstream or Livewire) easier with path-based tenant identification. + * The main purpose of this action is to make the integration of packages + * (e.g., Jetstream or Livewire) easier with path-based tenant identification. * - * By default, universal routes are cloned as tenant routes (= they get flagged with the 'tenant' middleware) - * and prefixed with the '/{tenant}' path prefix. Their name also gets prefixed with the tenant name prefix. + * The default for $cloneRoutesWithMiddleware is ['clone']. + * If $routesToClone is empty, all routes with any middleware specified in $cloneRoutesWithMiddleware will be cloned. + * The middleware can be in a group, nested as deep as you want + * (e.g. if a route has a 'foo' middleware which is a group containing the 'clone' middleware, the route will be cloned). * - * Routes with the path identification middleware get cloned similarly, but only if they're not universal at the same time. - * Unlike universal routes, these routes don't get the tenant flag, - * because they don't need it (they're not universal, and they have the identification MW, so they're already considered tenant). + * You may customize $cloneRoutesWithMiddleware using cloneRoutesWithMiddleware() to make any middleware of your choice trigger cloning. + * By providing a callback to shouldClone(), you can change how it's determined if a route should be cloned if you don't want to use middleware flags. * - * You can use the `cloneUsing()` hook to customize the route definitions, - * and the `skipRoute()` method to skip cloning of specific routes. - * You can also use the $tenantParameterName and $tenantRouteNamePrefix - * static properties to customize the tenant parameter name or the route name prefix. + * Cloned routes are prefixed with '/{tenant}', flagged with 'tenant' middleware, and have their names prefixed with 'tenant.'. + * The parameter name and prefix can be changed e.g. to `/{team}` and `team.` by configuring the path resolver (tenantParameterName and tenantRouteNamePrefix). + * Routes with names that are already prefixed won't be cloned - but that's just the default behavior. + * The cloneUsing() method allows you to completely override the default behavior and customize how the cloned routes will be defined. * - * Note that routes already containing the tenant parameter or prefix won't be cloned. + * After cloning, only top-level middleware in $cloneRoutesWithMiddleware will be removed + * from the new route (so by default, 'clone' will be omitted from the new route's MW). + * Middleware groups are preserved as-is, even if they contain cloning middleware. + * + * Routes that already contain the tenant parameter or have names with the tenant prefix + * will not be cloned. + * + * Example usage: + * ``` + * Route::get('/foo', fn () => true)->name('foo')->middleware('clone'); + * Route::get('/bar', fn () => true)->name('bar')->middleware('universal'); + * + * $cloneAction = app(CloneRoutesAsTenant::class); + * + * // Clone foo route as /{tenant}/foo/ and name it tenant.foo ('clone' middleware won't be present in the cloned route) + * $cloneAction->handle(); + * + * // Clone bar route as /{tenant}/bar and name it tenant.bar ('universal' middleware won't be present in the cloned route) + * $cloneAction->cloneRoutesWithMiddleware(['universal'])->handle(); + * + * Route::get('/baz', fn () => true)->name('baz'); + * + * // Clone baz route as /{tenant}/bar and name it tenant.baz ('universal' middleware won't be present in the cloned route) + * $cloneAction->cloneRoute('baz')->handle(); + * ``` + * + * Calling handle() will also clear the $routesToClone array. + * This means that $action->cloneRoute('foo')->handle() will clone the 'foo' route, but subsequent calls to handle() will behave + * as if cloneRoute() wasn't called at all ($routesToClone will be empty). + * Note that calling handle() does not reset the other properties. + * + * @see Stancl\Tenancy\Resolvers\PathTenantResolver */ class CloneRoutesAsTenant { - protected array $cloneRouteUsing = []; - protected array $skippedRoutes = [ - 'stancl.tenancy.asset', - ]; + protected array $routesToClone = []; + protected Closure|null $cloneUsing = null; // The callback should accept Route instance or the route name (string) + protected Closure|null $shouldClone = null; + protected array $cloneRoutesWithMiddleware = ['clone']; public function __construct( protected Router $router, @@ -48,100 +80,77 @@ class CloneRoutesAsTenant public function handle(): void { - $this->getRoutesToClone()->each(fn (Route $route) => $this->cloneRoute($route)); + // If no routes were specified using cloneRoute(), get all routes + // and for each, determine if it should be cloned + if (! $this->routesToClone) { + $this->routesToClone = collect($this->router->getRoutes()->get()) + ->filter(fn (Route $route) => $this->shouldBeCloned($route)) + ->all(); + } + + foreach ($this->routesToClone as $route) { + // If the cloneUsing callback is set, + // use the callback to clone the route instead of the default + if ($this->cloneUsing) { + ($this->cloneUsing)($route); + + continue; + } + + if (is_string($route)) { + $this->router->getRoutes()->refreshNameLookups(); + $route = $this->router->getRoutes()->getByName($route); + } + + $this->copyMiscRouteProperties($route, $this->createNewRoute($route)); + } + + // Clean up the routesToClone array after cloning so that subsequent calls aren't affected + $this->routesToClone = []; $this->router->getRoutes()->refreshNameLookups(); } - /** - * Make the action clone a specific route using the provided callback instead of the default one. - */ - public function cloneUsing(string $routeName, Closure $callback): static + public function cloneUsing(Closure|null $cloneUsing): static { - $this->cloneRouteUsing[$routeName] = $callback; + $this->cloneUsing = $cloneUsing; return $this; } - /** - * Skip a route's cloning. - */ - public function skipRoute(string $routeName): static + public function cloneRoutesWithMiddleware(array $middleware): static { - $this->skippedRoutes[] = $routeName; + $this->cloneRoutesWithMiddleware = $middleware; return $this; } - /** - * @return Collection - */ - protected function getRoutesToClone(): Collection + public function shouldClone(Closure|null $shouldClone): static { - $tenantParameterName = PathTenantResolver::tenantParameterName(); + $this->shouldClone = $shouldClone; - /** - * Clone all routes that: - * - don't have the tenant parameter - * - aren't in the $skippedRoutes array - * - are using path identification (kernel or route-level). - * - * Non-universal cloned routes will only be available in the tenant context, - * universal routes will be available in both contexts. - */ - return collect($this->router->getRoutes()->get())->filter(function (Route $route) use ($tenantParameterName) { - if ( - tenancy()->routeHasMiddleware($route, 'tenant') || - in_array($route->getName(), $this->skippedRoutes, true) || - in_array($tenantParameterName, $route->parameterNames(), true) - ) { - return false; - } - - $pathIdentificationMiddleware = config('tenancy.identification.path_identification_middleware'); - $routeHasPathIdentificationMiddleware = tenancy()->routeHasMiddleware($route, $pathIdentificationMiddleware); - $routeHasNonPathIdentificationMiddleware = tenancy()->routeHasIdentificationMiddleware($route) && ! $routeHasPathIdentificationMiddleware; - $pathIdentificationMiddlewareInGlobalStack = tenancy()->globalStackHasMiddleware($pathIdentificationMiddleware); - - /** - * The route should get cloned if: - * - it has route-level path identification middleware, OR - * - it uses kernel path identification (it doesn't have any route-level identification middleware) and the route is tenant or universal. - * - * The route is considered tenant if: - * - it's flagged as tenant, OR - * - it's not flagged as tenant or universal, but it has the identification middleware - * - * The route is considered universal if it's flagged as universal, and it doesn't have the tenant flag - * (it's still considered universal if it has route-level path identification middleware + the universal flag). - * - * If the route isn't flagged, the context is determined using the default route mode. - */ - $pathIdentificationUsed = (! $routeHasNonPathIdentificationMiddleware) && - ($routeHasPathIdentificationMiddleware || $pathIdentificationMiddlewareInGlobalStack); - - return $pathIdentificationUsed && - (tenancy()->getRouteMode($route) === RouteMode::UNIVERSAL || tenancy()->routeHasMiddleware($route, 'clone')); - }); + return $this; } - /** - * Clone a route using a callback specified in the $cloneRouteUsing property (using the cloneUsing method). - * If there's no callback specified for the route, use the default way of cloning routes. - */ - protected function cloneRoute(Route $route): void + public function cloneRoute(Route|string $route): static { - $routeName = $route->getName(); + $this->routesToClone[] = $route; - // If the route's cloning callback exists - // Use the callback to clone the route instead of the default way of cloning routes - if ($routeName && $customRouteCallback = data_get($this->cloneRouteUsing, $routeName)) { - $customRouteCallback($route); + return $this; + } - return; + protected function shouldBeCloned(Route $route): bool + { + // Don't clone routes that already have tenant parameter or prefix + if ($this->routeIsTenant($route)) { + return false; } - $this->copyMiscRouteProperties($route, $this->createNewRoute($route)); + if ($this->shouldClone) { + return ($this->shouldClone)($route); + } + + return tenancy()->routeHasMiddleware($route, $this->cloneRoutesWithMiddleware); } protected function createNewRoute(Route $route): Route @@ -150,33 +159,24 @@ class CloneRoutesAsTenant $prefix = trim($route->getPrefix() ?? '', '/'); $uri = $route->getPrefix() ? Str::after($route->uri(), $prefix) : $route->uri(); - $newRouteAction = collect($route->action)->tap(function (Collection $action) use ($route, $prefix) { - /** @var array $routeMiddleware */ - $routeMiddleware = $action->get('middleware') ?? []; + $action = collect($route->action); - // Make the new route have the same middleware as the original route - // Add the 'tenant' middleware to the new route - // Exclude `universal` and `clone` middleware from the new route (it should only be flagged as tenant) - $newRouteMiddleware = collect($routeMiddleware) - ->merge(['tenant']) // Add 'tenant' flag - ->filter(fn (string $middleware) => ! in_array($middleware, ['universal', 'clone'])) - ->toArray(); + // Make the new route have the same middleware as the original route + // Add the 'tenant' middleware to the new route + // Exclude $this->cloneRoutesWithMiddleware MW from the new route (it should only be flagged as tenant) - $tenantRouteNamePrefix = PathTenantResolver::tenantRouteNamePrefix(); + $middleware = $this->processMiddlewareForCloning($action->get('middleware') ?? []); - // Make sure the route name has the tenant route name prefix - $newRouteNamePrefix = $route->getName() - ? $tenantRouteNamePrefix . Str::after($route->getName(), $tenantRouteNamePrefix) - : null; + if ($name = $route->getName()) { + $action->put('as', PathTenantResolver::tenantRouteNamePrefix() . $name); + } - return $action - ->put('as', $newRouteNamePrefix) - ->put('middleware', $newRouteMiddleware) - ->put('prefix', $prefix . '/{' . PathTenantResolver::tenantParameterName() . '}'); - })->toArray(); + $action + ->put('middleware', $middleware) + ->put('prefix', $prefix . '/{' . PathTenantResolver::tenantParameterName() . '}'); /** @var Route $newRoute */ - $newRoute = $this->router->$method($uri, $newRouteAction); + $newRoute = $this->router->$method($uri, $action->toArray()); return $newRoute; } @@ -194,4 +194,26 @@ class CloneRoutesAsTenant ->withTrashed($originalRoute->allowsTrashedBindings()) ->setDefaults($originalRoute->defaults); } + + /** Removes top-level cloneRoutesWithMiddleware and adds 'tenant' middleware. */ + protected function processMiddlewareForCloning(array $middleware): array + { + $processedMiddleware = array_filter( + $middleware, + fn ($mw) => ! in_array($mw, $this->cloneRoutesWithMiddleware) + ); + + $processedMiddleware[] = 'tenant'; + + return array_unique($processedMiddleware); + } + + /** Check if route already has tenant parameter or name prefix. */ + protected function routeIsTenant(Route $route): bool + { + $routeHasTenantParameter = in_array(PathTenantResolver::tenantParameterName(), $route->parameterNames()); + $routeHasTenantPrefix = $route->getName() && str_starts_with($route->getName(), PathTenantResolver::tenantRouteNamePrefix()); + + return $routeHasTenantParameter || $routeHasTenantPrefix; + } } diff --git a/tests/CloneActionTest.php b/tests/CloneActionTest.php index 3706f31e..866babb5 100644 --- a/tests/CloneActionTest.php +++ b/tests/CloneActionTest.php @@ -1,270 +1,195 @@ 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/'), ]; @@ -286,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(); } }); @@ -301,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'); }); }); @@ -316,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'); +}); diff --git a/tests/EarlyIdentificationTest.php b/tests/EarlyIdentificationTest.php index 48ac4d12..a95bac0b 100644 --- a/tests/EarlyIdentificationTest.php +++ b/tests/EarlyIdentificationTest.php @@ -344,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); @@ -364,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 @@ -374,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 { From d1f12f594d8aeb1831b1f9e830140d399c827224 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Tue, 1 Jul 2025 17:23:13 +0200 Subject: [PATCH 37/42] Instead of assigning $innerMiddleware during group MW unpacking, merge it (#1371) --- src/Concerns/DealsWithRouteContexts.php | 2 +- tests/RouteMiddlewareTest.php | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Concerns/DealsWithRouteContexts.php b/src/Concerns/DealsWithRouteContexts.php index f9df834d..9a9b0871 100644 --- a/src/Concerns/DealsWithRouteContexts.php +++ b/src/Concerns/DealsWithRouteContexts.php @@ -110,7 +110,7 @@ trait DealsWithRouteContexts foreach ($middleware as $inner) { if (! $inner instanceof Closure && isset($middlewareGroups[$inner])) { - $innerMiddleware = Arr::wrap($middlewareGroups[$inner]); + $innerMiddleware = array_merge($innerMiddleware, Arr::wrap($middlewareGroups[$inner])); } } diff --git a/tests/RouteMiddlewareTest.php b/tests/RouteMiddlewareTest.php index ab0a46be..c5c84a04 100644 --- a/tests/RouteMiddlewareTest.php +++ b/tests/RouteMiddlewareTest.php @@ -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]); From 4ead17a56ba09e1ebab1f03aa0291721f7193ea9 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Thu, 3 Jul 2025 21:12:04 +0200 Subject: [PATCH 38/42] [4.x] TableRLSManager refactor, comment constraints (#1354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add option to provide constraint information in column comment * Fix code style (php-cs-fixer) * Correct comment functionality, add comment constraint exception * Simplify and clarify comment-related TableRLSManager code * Make path skipping logic more explicit * Correct terminology, add test for throwing exceptions * Fix code style (php-cs-fixer) * Improve comments * Refactor TableRLSManagerr (dynamic programming, deal with recursive relationships, determine shortest paths while generating the paths) * Fix code style (php-cs-fixer) * Improve TableRLSManager comments * Test uncovered edge cases * Improve code for determining the shortest path * Improve readability * Fix code style (php-cs-fixer) * Update the tree terminology * Use consistent shortest path terminology * Improve comment * Improve method name * Simplify and clarify core shortest path generation test * Clarify and simplify tests, add comments * Delete excessive test * Test data separation with comment constraints * Use tenant id instead of getTenantKey() * Make higher-level code clearer, improve comments * Improve comments, delete excessive methods, make methods more concise, position helper methods more appropriately * Fix code style (php-cs-fixer) * Add a "single source of truth" for path array format, make lower-level code more concise, improve comments * Fix code style (php-cs-fixer) * Correct terminology and comments in TableRLSManager * Correct terminology in table manager test file * Improve comments and method name * Fix typo * bump php memory limit when running tests * Delete findShortestPath, merge the code into shortestPathToTenantsTabke * Minor shortestPathToTenantsTable improvement * Improve docblocks,as discussed * Move RLSCommentConstraintException to src/RLS/Exceptions * Fully cover shouldSkipPathLeadingThrough in tests * test improvements * tests: add comment to clarify the chosen path * formatting * Fix typo * Use `===` instead of `Str::is()` * Refactor constraint formatting in TableRLSManager * Fix code style (php-cs-fixer) * Update key names of the formatted constraints * Rename shouldSkipPathLeadingThrough() to shouldSkipPathLeadingThroughConstraint() * misc improvements * code improvements --------- Co-authored-by: github-actions[bot] Co-authored-by: Samuel Štancl --- .../RLSCommentConstraintException.php | 15 + src/RLS/PolicyManagers/TableRLSManager.php | 645 ++++++++++++++---- tests/RLS/TableManagerTest.php | 470 +++++++------ tests/TestCase.php | 2 + 4 files changed, 777 insertions(+), 355 deletions(-) create mode 100644 src/RLS/Exceptions/RLSCommentConstraintException.php diff --git a/src/RLS/Exceptions/RLSCommentConstraintException.php b/src/RLS/Exceptions/RLSCommentConstraintException.php new file mode 100644 index 00000000..7ca133f9 --- /dev/null +++ b/src/RLS/Exceptions/RLSCommentConstraintException.php @@ -0,0 +1,15 @@ +generateQueries(); + * + * // Generate the shortest path from table X to the tenants table. + * // Calls shortestPathToTenantsTable() recursively. + * // The paths will be returned in this format: + * // [ + * // 'foo_table' => [...$stepsLeadingToTenantsTable], + * // 'bar_table' => [ + * // [ + * // 'localColumn' => 'post_id', + * // 'foreignTable' => 'posts', + * // 'foreignColumn' => 'id' + * // ], + * // [ + * // 'localColumn' => 'tenant_id', + * // 'foreignTable' => 'tenants', + * // 'foreignColumn' => 'id' + * // ], + * // ], + * // This is used in the CreateUserWithRLSPolicies command. + * $shortestPath = app(TableRLSManager::class)->shortestPaths(); + * + * generateQueries() and shortestPaths() methods are the only public methods of this class. + * The rest of the methods are protected, and only used internally. + * To see how they're structured and how they work, you can check their annotations. + */ class TableRLSManager implements RLSPolicyManager { + /** + * When true, all valid constraints are considered while generating paths for RLS policies, + * unless explicitly marked with a 'no-rls' comment. + * + * When false, only columns explicitly marked with 'rls' or 'rls table.column' comments are considered. + */ public static bool $scopeByDefault = true; public function __construct( protected DatabaseManager $database ) {} - public function generateQueries(array $trees = []): array + /** + * Generate queries that will be executed by the tenants:rls command + * for creating RLS policies for all tables related to the tenants table + * or for a passed array of paths. + * + * The passed paths should be formatted like this: + * [ + * 'table_name' => [...$stepsLeadingToTenantsTable] + * ] + */ + public function generateQueries(array $paths = []): array { $queries = []; - foreach ($trees ?: $this->shortestPaths() as $table => $path) { + foreach ($paths ?: $this->shortestPaths() as $table => $path) { $queries[$table] = $this->generateQuery($table, $path); } @@ -28,185 +96,415 @@ class TableRLSManager implements RLSPolicyManager } /** - * Reduce trees to shortest paths (structured like ['table_foo' => $shortestPathForFoo, 'table_bar' => $shortestPathForBar]). + * Generate shortest paths from each table to the tenants table, + * structured like ['table_foo' => $shortestPathFromFoo, 'table_bar' => $shortestPathFromBar]. * * For example: * * 'posts' => [ * [ - * 'foreignKey' => 'tenant_id', + * 'localColumn' => 'tenant_id', * 'foreignTable' => 'tenants', - * 'foreignId' => 'id' + * 'foreignColumn' => 'id' * ], * ], * 'comments' => [ * [ - * 'foreignKey' => 'post_id', + * 'localColumn' => 'post_id', * 'foreignTable' => 'posts', - * 'foreignId' => 'id' + * 'foreignColumn' => 'id' * ], * [ - * 'foreignKey' => 'tenant_id', + * 'localColumn' => 'tenant_id', * 'foreignTable' => 'tenants', - * 'foreignId' => 'id' + * 'foreignColumn' => 'id' * ], * ], + * + * @throws RecursiveRelationshipException When tables have recursive relationships and no other valid paths + * @throws RLSCommentConstraintException When comment constraints are malformed */ - public function shortestPaths(array $trees = []): array + public function shortestPaths(): array { - $reducedTrees = []; + $shortestPaths = []; - foreach ($trees ?: $this->generateTrees() as $table => $tree) { - $reducedTrees[$table] = $this->findShortestPath($this->filterNonNullablePaths($tree) ?: $tree); + foreach ($this->getTableNames() as $tableName) { + // Generate the shortest path from table named $tableName to the tenants table + $shortestPath = $this->shortestPathToTenantsTable($tableName); + + if ($this->isValidPath($shortestPath)) { + // Format path steps to a more readable format (keep only the needed data) + $shortestPaths[$tableName] = array_map(fn (array $step) => [ + 'localColumn' => $step['localColumn'], + 'foreignTable' => $step['foreignTable'], + 'foreignColumn' => $step['foreignColumn'], + ], $shortestPath['steps']); + } + + // No valid path found. The shortest path either + // doesn't lead to the tenants table (ignore), + // or leads through a recursive relationship (throw an exception). + if ($shortestPath['recursive_relationship']) { + throw new RecursiveRelationshipException( + "Table '{$tableName}' has recursive relationships with no other valid paths to the tenants table." + ); + } } - return $reducedTrees; + return $shortestPaths; } /** - * Generate trees of paths that lead to the tenants table - * for the foreign keys of all tables – only the paths that lead to the tenants table are included. + * Create a path array with the given parameters. + * This method serves as a 'single source of truth' for the path array structure. * - * Also unset the 'comment' key from the retrieved path steps. + * The 'steps' key contains the path steps returned by shortestPaths(). + * The 'dead_end' and 'recursive_relationship' keys are just internal metadata. + * + * @param bool $deadEnd Whether the path is a dead end (no valid constraints leading to tenants table) + * @param bool $recursive Whether the path has recursive relationships + * @param array $steps Steps to the tenants table, each step being a formatted constraint */ - public function generateTrees(): array + protected function buildPath(bool $deadEnd = false, bool $recursive = false, array $steps = []): array { - $trees = []; - $builder = $this->database->getSchemaBuilder(); - - // We loop through each table in the database - foreach ($builder->getTableListing(schema: $this->database->getConfig('search_path')) as $table) { - // E.g. "public.table_name" -> "table_name" - $table = str($table)->afterLast('.')->toString(); - - // For each table, we get a list of all foreign key columns - $foreignKeys = collect($builder->getForeignKeys($table))->map(function ($foreign) use ($table) { - return $this->formatForeignKey($foreign, $table); - }); - - // We loop through each foreign key column and find - // all possible paths that lead to the tenants table - foreach ($foreignKeys as $foreign) { - $paths = []; - - $this->generatePaths($table, $foreign, $paths); - - foreach ($paths as &$path) { - foreach ($path as &$step) { - unset($step['comment']); - } - } - - if (count($paths)) { - $trees[$table][$foreign['foreignKey']] = $paths; - } - } - } - - return $trees; - } - - protected function generatePaths(string $table, array $foreign, array &$paths, array $currentPath = []): void - { - // If the foreign key has a comment of 'no-rls', we skip it - // Also skip the foreign key if implicit scoping is off and the foreign key has no comment - if ($foreign['comment'] === 'no-rls' || (! static::$scopeByDefault && $foreign['comment'] === null)) { - return; - } - - if (in_array($foreign['foreignTable'], array_column($currentPath, 'foreignTable'))) { - throw new RecursiveRelationshipException; - } - - $currentPath[] = $foreign; - - if ($foreign['foreignTable'] === tenancy()->model()->getTable()) { - $paths[] = $currentPath; - } else { - // If not, recursively generate paths for the foreign table - foreach ($this->database->getSchemaBuilder()->getForeignKeys($foreign['foreignTable']) as $nextConstraint) { - $this->generatePaths($table, $this->formatForeignKey($nextConstraint, $foreign['foreignTable']), $paths, $currentPath); - } - } - } - - /** Get tree's non-nullable paths. */ - protected function filterNonNullablePaths(array $tree): array - { - $nonNullablePaths = []; - - foreach ($tree as $foreignKey => $paths) { - foreach ($paths as $path) { - $pathIsNullable = false; - - foreach ($path as $step) { - if ($step['nullable']) { - $pathIsNullable = true; - break; - } - } - - if (! $pathIsNullable) { - $nonNullablePaths[$foreignKey][] = $path; - } - } - } - - return $nonNullablePaths; - } - - /** Find the shortest path in a tree and unset the 'nullable' key from the path steps. */ - protected function findShortestPath(array $tree): array - { - $shortestPath = []; - - foreach ($tree as $pathsForForeignKey) { - foreach ($pathsForForeignKey as $path) { - if (empty($shortestPath) || count($shortestPath) > count($path)) { - $shortestPath = $path; - - foreach ($shortestPath as &$step) { - unset($step['nullable']); - } - } - } - } - - return $shortestPath; + return [ + 'dead_end' => $deadEnd, + 'recursive_relationship' => $recursive, + 'steps' => $steps, + ]; } /** - * Formats the foreign key array retrieved by Postgres to a more readable format. + * Formats the retrieved constraint to a more readable format. * - * Also provides information about whether the foreign key is nullable, - * and the foreign key column comment. These additional details are removed - * from the foreign keys/path steps before returning the final shortest paths. + * Also provides internal metadata about + * - the constraint's nullability (the 'nullable' key), + * - the constraint's comment * - * The 'comment' key gets deleted while generating the full trees (in generateTrees()), - * and the 'nullable' key gets deleted while generating the shortest paths (in findShortestPath()). + * These internal details are then omitted + * from the constraints (or the "path steps") + * before returning the shortest paths in shortestPath(). * * [ - * 'foreignKey' => 'tenant_id', + * 'localColumn' => 'tenant_id', * 'foreignTable' => 'tenants', - * 'foreignId' => 'id', - * 'comment' => 'no-rls', // Foreign key comment – used to explicitly enable/disable RLS - * 'nullable' => false, // Whether the foreign key is nullable + * 'foreignColumn' => 'id', + * 'comment' => 'no-rls', // Used to explicitly enable/disable RLS or to create a comment constraint (internal metadata) + * 'nullable' => false, // Used to determine if the constraint is nullable (internal metadata) * ]. */ - protected function formatForeignKey(array $foreignKey, string $table): array + protected function formatForeignKey(array $constraint, string $table): array { - // $foreignKey is one of the foreign keys retrieved by $this->database->getSchemaBuilder()->getForeignKeys($table) + assert(count($constraint['columns']) === 1); + + $localColumn = $constraint['columns'][0]; + + $comment = collect($this->database->getSchemaBuilder()->getColumns($table)) + ->filter(fn ($column) => $column['name'] === $localColumn) + ->first()['comment'] ?? null; + + $columnIsNullable = $this->database->selectOne( + 'SELECT is_nullable FROM information_schema.columns WHERE table_name = ? AND column_name = ?', + [$table, $localColumn] + )->is_nullable === 'YES'; + + assert(count($constraint['foreign_columns']) === 1); + + return $this->formatConstraint( + localColumn: $localColumn, + foreignTable: $constraint['foreign_table'], + foreignColumn: $constraint['foreign_columns'][0], + comment: $comment, + nullable: $columnIsNullable + ); + } + + /** Single source of truth for our constraint format. */ + protected function formatConstraint( + string $localColumn, + string $foreignTable, + string $foreignColumn, + string|null $comment, + bool $nullable + ): array { return [ - 'foreignKey' => $foreignKeyName = $foreignKey['columns'][0], - 'foreignTable' => $foreignKey['foreign_table'], - 'foreignId' => $foreignKey['foreign_columns'][0], - // Deleted in generateTrees() - 'comment' => $this->getComment($table, $foreignKeyName), - // Deleted in shortestPaths() - 'nullable' => $this->database->selectOne("SELECT is_nullable FROM information_schema.columns WHERE table_name = '{$table}' AND column_name = '{$foreignKeyName}'")->is_nullable === 'YES', + 'localColumn' => $localColumn, + 'foreignTable' => $foreignTable, + 'foreignColumn' => $foreignColumn, + // Internal metadata omitted in shortestPaths() + 'comment' => $comment, + 'nullable' => $nullable, ]; } + /** + * Recursively traverse a table's constraints to find + * the shortest path to the tenants table. + * + * The shortest paths are cached in $cachedPaths to avoid + * generating them for already visited tables repeatedly. + * + * @param string $table The table to find a path from + * @param array &$cachedPaths Reference to array where discovered shortest paths are cached (including dead ends) + * @param array $visitedTables Already visited tables (used for detecting recursive relationships) + * @return array Paths with 'steps' (arrays of formatted constraints), 'dead_end' flag (bool), and 'recursive_relationship' flag (bool). + */ + protected function shortestPathToTenantsTable( + string $table, + array &$cachedPaths = [], + array $visitedTables = [] + ): array { + // Return the shortest path for this table if it was already found and cached + if (isset($cachedPaths[$table])) { + return $cachedPaths[$table]; + } + + // Reached tenants table (last step) + if ($table === tenancy()->model()->getTable()) { + // This pretty much just means we set $cachedPaths['tenants'] to an + // empty path. The significance of an empty path is that this class + // considers it to mean "you are at the tenants table". + $cachedPaths[$table] = $this->buildPath(); + + return $cachedPaths[$table]; + } + + $constraints = $this->getConstraints($table); + + if (empty($constraints)) { + // Dead end + $cachedPaths[$table] = $this->buildPath(deadEnd: true); + + return $cachedPaths[$table]; + } + + /** + * Find the optimal path from a table to the tenants table. + * + * Gather table's constraints (both foreign key constraints and comment constraints) + * and recursively find shortest paths through each constraint (non-nullable paths are preferred for reliability). + * + * Handle recursive relationships by skipping paths that would create loops. + * If there's no valid path in the end, and the table has recursive relationships, + * an appropriate exception is thrown. + * + * At the end, it returns the shortest non-nullable path if available, + * fall back to the overall shortest path. + */ + $visitedTables = [...$visitedTables, $table]; + $shortestPath = []; + $hasRecursiveRelationships = false; + $hasValidPaths = false; + + foreach ($constraints as $constraint) { + $foreignTable = $constraint['foreignTable']; + + // Skip constraints that would create loops + if (in_array($foreignTable, $visitedTables)) { + $hasRecursiveRelationships = true; + continue; + } + + // Recursive call + $pathThroughConstraint = $this->shortestPathToTenantsTable( + $foreignTable, + $cachedPaths, + $visitedTables + ); + + if ($pathThroughConstraint['recursive_relationship']) { + $hasRecursiveRelationships = true; + continue; + } + + // Skip dead ends + if ($pathThroughConstraint['dead_end']) { + continue; + } + + $hasValidPaths = true; + $path = $this->buildPath(steps: array_merge([$constraint], $pathThroughConstraint['steps'])); + + if ($this->isPathPreferable($path, $shortestPath)) { + $shortestPath = $path; + } + } + + // Handle tables with only recursive relationships + if ($hasRecursiveRelationships && ! $hasValidPaths) { + // Don't cache paths that cause recursion - return right away. + // This allows tables with recursive relationships to be processed again. + // Example: + // - posts table has highlighted_comment_id that leads to the comments table + // - comments table has recursive_post_id that leads to the posts table (recursive relationship), + // - comments table also has tenant_id which leads to the tenants table (a valid path). + // If the recursive path got cached first, the path leading directly through tenants would never be found. + return $this->buildPath(recursive: true); + } + + $cachedPaths[$table] = $shortestPath ?: $this->buildPath(deadEnd: true); + + return $cachedPaths[$table]; + } + + /** + * Get all valid relationship constraints for a table. The constraints are also formatted. + * Combines both standard foreign key constraints and comment constraints. + * + * The schema builder retrieves foreign keys in the following format: + * [ + * 'name' => 'posts_tenant_id_foreign', + * 'columns' => ['tenant_id'], + * 'foreign_table' => 'tenants', + * 'foreign_columns' => ['id'], + * ... + * ] + * + * We format that into a more readable format using formatForeignKey(), + * and that method uses formatConstraint(), which serves as a single source of truth + * for our constraint formatting. A formatted constraint looks like this: + * [ + * 'localColumn' => 'tenant_id', + * 'foreignTable' => 'tenants', + * 'foreignColumn' => 'id', + * 'comment' => 'no-rls', + * 'nullable' => false + * ] + * + * The comment constraints are retrieved using getFormattedCommentConstraints(). + * These constraints are formatted in the method itself. + */ + protected function getConstraints(string $table): array + { + $formattedConstraints = array_merge( + array_map( + fn ($schemaStructure) => $this->formatForeignKey($schemaStructure, $table), + $this->database->getSchemaBuilder()->getForeignKeys($table) + ), + $this->getFormattedCommentConstraints($table) + ); + + $validConstraints = []; + + foreach ($formattedConstraints as $constraint) { + if (! $this->shouldSkipPathLeadingThroughConstraint($constraint)) { + $validConstraints[] = $constraint; + } + } + + return $validConstraints; + } + + /** + * Determine if a path leading through the passed constraint + * should be excluded from choosing the shortest path + * based on the constraint's comment. + * + * If $scopeByDefault is true, only skip paths leading through constraints flagged with the 'no-rls' comment. + * If $scopeByDefault is false, skip paths leading through any constraint, unless the key has explicit 'rls' or 'rls table.column' comments. + * + * @param array $constraint Formatted constraint + */ + protected function shouldSkipPathLeadingThroughConstraint(array $constraint): bool + { + $comment = $constraint['comment'] ?? null; + + // Always skip constraints with the 'no-rls' comment + if ($comment === 'no-rls') { + return true; + } + + if (static::$scopeByDefault) { + return false; + } + + // When $scopeByDefault is false, skip every constraint + // with a comment that doesn't start with 'rls'. + if (! is_string($comment)) { + return true; + } + + // Explicit scoping + if ($comment === 'rls') { + return false; + } + + // Comment constraint + if (Str::startsWith($comment, 'rls ')) { + return false; + } + + return true; + } + + /** + * Retrieve a table's comment constraints. + * + * Comment constraints are columns with comments + * structured like "rls .". + * + * Returns an array of formatted comment constraints (check formatConstraint() to see the format). + */ + protected function getFormattedCommentConstraints(string $tableName): array + { + $commentConstraints = array_filter($this->database->getSchemaBuilder()->getColumns($tableName), function ($column) { + return (isset($column['comment']) && is_string($column['comment'])) + && Str::startsWith($column['comment'], 'rls '); + }); + + // Validate and format the comment constraints + $commentConstraints = array_map( + fn ($commentConstraint) => $this->parseCommentConstraint($commentConstraint, $tableName), + $commentConstraints + ); + + return $commentConstraints; + } + + /** + * Parse and validate a comment constraint. + * + * This method validates that the table and column referenced + * in the comment exist, formats and returns the constraint. + * + * @throws RLSCommentConstraintException When comment format is invalid or references don't exist + */ + protected function parseCommentConstraint(array $commentConstraint, string $tableName): array + { + $comment = $commentConstraint['comment']; + $columnName = $commentConstraint['name']; + + $builder = $this->database->getSchemaBuilder(); + $constraint = explode('.', Str::after($comment, 'rls ')); + + // Validate comment constraint format + if (count($constraint) !== 2 || empty($constraint[0]) || empty($constraint[1])) { + throw new RLSCommentConstraintException("Malformed comment constraint on {$tableName}.{$columnName}: '{$comment}'"); + } + + $foreignTable = $constraint[0]; + $foreignColumn = $constraint[1]; + + // Validate table existence + if (! $builder->hasTable($foreignTable)) { + throw new RLSCommentConstraintException("Comment constraint on {$tableName}.{$columnName} references non-existent table '{$foreignTable}'"); + } + + // Validate column existence + if (! $builder->hasColumn($foreignTable, $foreignColumn)) { + throw new RLSCommentConstraintException("Comment constraint on {$tableName}.{$columnName} references non-existent column '{$foreignTable}.{$foreignColumn}'"); + } + + // Return the formatted constraint + return $this->formatConstraint( + localColumn: $commentConstraint['name'], + foreignTable: $foreignTable, + foreignColumn: $foreignColumn, + comment: $commentConstraint['comment'], + nullable: $commentConstraint['nullable'] + ); + } + /** Generates a query that creates a row-level security policy for the passed table. */ protected function generateQuery(string $table, array $path): string { @@ -215,9 +513,9 @@ class TableRLSManager implements RLSPolicyManager $sessionTenantKey = config('tenancy.rls.session_variable_name'); foreach ($path as $index => $relation) { - $column = $relation['foreignKey']; + $column = $relation['localColumn']; $table = $relation['foreignTable']; - $foreignKey = $relation['foreignId']; + $foreignKey = $relation['foreignColumn']; $indentation = str_repeat(' ', ($index + 1) * 4); @@ -250,12 +548,65 @@ class TableRLSManager implements RLSPolicyManager return $query; } - protected function getComment(string $tableName, string $columnName): string|null + /** Returns unprefixed table names. */ + protected function getTableNames(): array { - $column = collect($this->database->getSchemaBuilder()->getColumns($tableName)) - ->filter(fn ($column) => $column['name'] === $columnName) - ->first(); + $builder = $this->database->getSchemaBuilder(); + $tables = []; - return $column['comment'] ?? null; + foreach ($builder->getTableListing(schema: $this->database->getConfig('search_path')) as $table) { + // E.g. "public.table_name" -> "table_name" + $tables[] = str($table)->afterLast('.')->toString(); + } + + return $tables; + } + + /** + * Check if discovered path is valid for RLS policy generation. + * + * A valid path: + * - leads to tenants table (isn't dead end) + * - has at least one step (the tenants table itself will have no steps) + */ + protected function isValidPath(array $path): bool + { + return ! $path['dead_end'] && ! empty($path['steps']); + } + + /** + * Determine if the passed path is preferred to the current shortest path. + * + * Non-nullable paths are preferred to nullable paths. + * From paths of the same nullability, the shorter will be preferred. + */ + protected function isPathPreferable(array $path, array $shortestPath): bool + { + if (! $shortestPath) { + return true; + } + + $pathIsNullable = $this->isPathNullable($path['steps']); + $shortestPathIsNullable = $this->isPathNullable($shortestPath['steps']); + + // Prefer non-nullable + if ($pathIsNullable !== $shortestPathIsNullable) { + return ! $pathIsNullable; + } + + // Prefer shorter + return count($path['steps']) < count($shortestPath['steps']); + } + + /** Determine if any step in the path is nullable. */ + protected function isPathNullable(array $path): bool + { + foreach ($path as $step) { + if ($step['nullable']) { + return true; + } + } + + return false; } } diff --git a/tests/RLS/TableManagerTest.php b/tests/RLS/TableManagerTest.php index c0520a1d..c5c8d254 100644 --- a/tests/RLS/TableManagerTest.php +++ b/tests/RLS/TableManagerTest.php @@ -15,11 +15,13 @@ 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; @@ -164,15 +166,22 @@ test('correct rls policies get created with the correct hash using table manager } }); -test('queries are correctly scoped using RLS', function (bool $forceRls) { +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(); }); @@ -188,9 +197,9 @@ test('queries are correctly scoped using RLS', function (bool $forceRls) { $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]); @@ -201,9 +210,9 @@ test('queries are correctly scoped using RLS', function (bool $forceRls) { $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]); @@ -319,7 +328,7 @@ test('queries are correctly scoped using RLS', function (bool $forceRls) { 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})")) @@ -327,95 +336,90 @@ test('queries are correctly scoped using RLS', function (bool $forceRls) { expect(fn () => DB::statement("INSERT INTO notes (text, comment_id) VALUES ('baz', {$post1Comment->id})")) ->toThrow(QueryException::class); -})->with([true, false]); +})->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 @@ -423,123 +427,52 @@ 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); - - $expectedShortestPaths = [ - 'authors' => [ - [ - 'foreignKey' => 'tenant_id', - 'foreignTable' => 'tenants', - 'foreignId' => 'id', - ], - ], - 'posts' => [ - [ - 'foreignKey' => 'author_id', - 'foreignTable' => 'authors', - 'foreignId' => 'id', - ], - [ - 'foreignKey' => 'tenant_id', - 'foreignTable' => 'tenants', - 'foreignId' => 'id', - ], - ], - 'comments' => [ - [ - 'foreignKey' => 'post_id', - 'foreignTable' => 'posts', - 'foreignId' => 'id', - ], - [ - 'foreignKey' => 'author_id', - 'foreignTable' => 'authors', - 'foreignId' => 'id', - ], - [ - 'foreignKey' => 'tenant_id', - 'foreignTable' => 'tenants', - 'foreignId' => 'id', - ], - ], - ]; - expect($manager->shortestPaths())->toEqual($expectedShortestPaths); - // Only related to the tenants table through nullable columns – tenant_id and indirectly through post_id - Schema::create('ratings', function (Blueprint $table) { - $table->id(); - $table->integer('stars')->default(0); - - $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->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(); - - $expectedShortestPath = $scopeByDefault ? [ - [ - 'foreignKey' => 'tenant_id', - 'foreignTable' => 'tenants', - 'foreignId' => 'id', - ], - ] : [ - [ - 'foreignKey' => 'post_id', - 'foreignTable' => 'posts', - 'foreignId' => 'id', - ], - [ - 'foreignKey' => 'tenant_id', - 'foreignTable' => 'tenants', - 'foreignId' => 'id', - ], - ]; - - expect($shortestPaths['ratings'])->toBe($expectedShortestPath); - - // Add non-nullable comment_id foreign key + // Add non-nullable comment_id comment constraint Schema::table('ratings', function (Blueprint $table) { - $table->foreignId('comment_id')->comment('rls')->constrained('comments')->onUpdate('cascade')->onDelete('cascade'); + $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 - // The shortest paths should include a path for the ratings table - // That leads through comment_id instead of tenant_id - $shortestPaths = $manager->shortestPaths(); - - expect($shortestPaths['ratings'])->toBe([ + $expectedShortestPaths['ratings'] = [ [ - 'foreignKey' => 'comment_id', + 'localColumn' => 'comment_id', 'foreignTable' => 'comments', - 'foreignId' => 'id', + 'foreignColumn' => 'id', ], [ - 'foreignKey' => 'post_id', + 'localColumn' => 'post_id', 'foreignTable' => 'posts', - 'foreignId' => 'id', + 'foreignColumn' => 'id', ], [ - 'foreignKey' => 'author_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', - 'foreignId' => 'id', + 'foreignColumn' => 'id', ], [ - 'foreignKey' => 'tenant_id', + 'localColumn' => 'tenant_id', 'foreignTable' => 'tenants', - 'foreignId' => 'id', + '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]); // https://github.com/archtechx/tenancy/pull/1293 @@ -576,7 +509,7 @@ test('forceRls prevents even the table owner from querying his own tables if he // Create RLS policy for the orders table pest()->artisan('tenants:rls'); - $tenant1->run(fn () => Order::create(['name' => 'order1', 'tenant_id' => $tenant1->getTenantKey()])); + $tenant1->run(fn () => Order::create(['name' => 'order1', 'tenant_id' => $tenant1->id])); // We are still using the 'administrator' user - owner of the orders table @@ -623,7 +556,7 @@ test('users with BYPASSRLS privilege can bypass RLS regardless of forceRls setti // Create RLS policy for the orders table pest()->artisan('tenants:rls'); - $tenant1->run(fn () => Order::create(['name' => 'order1', 'tenant_id' => $tenant1->getTenantKey()])); + $tenant1->run(fn () => Order::create(['name' => 'order1', 'tenant_id' => $tenant1->id])); // We are still using the 'administrator' user @@ -680,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', ], ], ]; @@ -747,18 +680,61 @@ 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')->nullable()->comment('rls')->constrained('comments'); }); - Schema::table('comments', function (Blueprint $table) { - $table->foreignId('recursive_post_id')->comment('rls')->constrained('recursive_posts'); + Schema::create('recursive_comments', function (Blueprint $table) { + $table->id(); }); - expect(fn () => app(TableRLSManager::class)->generateTrees())->toThrow(RecursiveRelationshipException::class); -}); + 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) { @@ -766,13 +742,91 @@ test('table manager ignores recursive relationship if the foreign key responsibl $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')->comment('rls')->constrained('recursive_posts'); }); - expect(fn () => app(TableRLSManager::class)->generateTrees())->not()->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 { diff --git a/tests/TestCase.php b/tests/TestCase.php index 6a167c46..47af9e7d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -36,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 From 393f263f032c0177560cfead9838f642d49fe0b6 Mon Sep 17 00:00:00 2001 From: lukinovec Date: Fri, 4 Jul 2025 12:08:43 +0200 Subject: [PATCH 39/42] [4.x] Update route cloning example in TenancyServiceProvider stub (#1372) * Update cloning example * Delete double `//` from cloning example in TSP stub --- assets/TenancyServiceProvider.stub.php | 28 ++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index d9cfaef9..84787c0d 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -216,7 +216,9 @@ class TenancyServiceProvider extends ServiceProvider } /** - * Clone universal routes as tenant. + * Clone routes as tenant. + * + * This is used primarily for integrating packages. * * @see CloneRoutesAsTenant */ @@ -225,13 +227,23 @@ class TenancyServiceProvider extends ServiceProvider /** @var CloneRoutesAsTenant $cloneRoutes */ $cloneRoutes = $this->app->make(CloneRoutesAsTenant::class); - // // You can provide a closure for cloning a specific route, e.g.: - // $cloneRoutes->cloneUsing('welcome', function () { - // RouteFacade::get('/tenant-welcome', fn () => 'Current tenant: ' . tenant()->getTenantKey()) - // ->middleware(['universal', InitializeTenancyByPath::class]) - // ->name('tenant.welcome'); - // }); - // // To see the default behavior of cloning the universal routes, check out the cloneRoute() method in CloneRoutesAsTenant. + // The cloning action has two modes: + // 1. Clone all routes that have the middleware present in the action's $cloneRoutesWithMiddleware property. + // You can customize the middleware that triggers cloning by using cloneRoutesWithMiddleware() on the action. + // + // By default, the middleware is ['clone'], but using $cloneRoutes->cloneRoutesWithMiddleware(['clone', 'universal'])->handle() + // will clone all routes that have either 'clone' or 'universal' middleware (mentioning 'universal' since that's a common use case). + // + // Also, you can use the shouldClone() method to provide a custom closure that determines if a route should be cloned. + // + // 2. Clone only the routes that were manually added to the action using cloneRoute(). + // + // Regardless of the mode, you can provide a custom closure for defining the cloned route, e.g.: + // $cloneRoutesAction->cloneUsing(function (Route $route) { + // RouteFacade::get('/cloned/' . $route->uri(), fn () => 'cloned route')->name('cloned.' . $route->getName()); + // })->handle(); + // This will make all cloned routes use the custom closure to define the cloned route instead of the default behavior. + // See Stancl\Tenancy\Actions\CloneRoutesAsTenant for more details. $cloneRoutes->handle(); } From d8af9b4b43e58b319b271e011d7b0befec7b937d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Thu, 10 Jul 2025 01:08:49 +0200 Subject: [PATCH 40/42] remove JobBatchBootstrapper --- src/Bootstrappers/JobBatchBootstrapper.php | 39 ---------------------- 1 file changed, 39 deletions(-) delete mode 100644 src/Bootstrappers/JobBatchBootstrapper.php diff --git a/src/Bootstrappers/JobBatchBootstrapper.php b/src/Bootstrappers/JobBatchBootstrapper.php deleted file mode 100644 index db4d8157..00000000 --- a/src/Bootstrappers/JobBatchBootstrapper.php +++ /dev/null @@ -1,39 +0,0 @@ -deprecatedNotice(); - } - - protected function deprecatedNotice(): void - { - if ($this->app->environment() == 'local' && $this->app->hasDebugModeEnabled()) { - throw new Exception("JobBatchBootstrapper is not supported anymore, please remove it from your tenancy config. Job batches should work out of the box in Laravel 11. If they don't, please open a bug report."); - } - } - - public function revert(): void - { - $this->deprecatedNotice(); - } -} From 91295f01e221b8a44a6e36a2d7c613d60a2d42ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 14 Jul 2025 21:44:12 +0200 Subject: [PATCH 41/42] fix origin identification: parse hostname when full URL is used --- src/Middleware/InitializeTenancyByOriginHeader.php | 6 +++++- tests/OriginHeaderIdentificationTest.php | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Middleware/InitializeTenancyByOriginHeader.php b/src/Middleware/InitializeTenancyByOriginHeader.php index 4d85dc5c..4aa4f342 100644 --- a/src/Middleware/InitializeTenancyByOriginHeader.php +++ b/src/Middleware/InitializeTenancyByOriginHeader.php @@ -10,6 +10,10 @@ class InitializeTenancyByOriginHeader extends InitializeTenancyByDomainOrSubdoma { public function getDomain(Request $request): string { - return $request->header('Origin', ''); + if ($origin = $request->header('Origin', '')) { + return parse_url($origin, PHP_URL_HOST) ?? $origin; + } + + return ''; } } diff --git a/tests/OriginHeaderIdentificationTest.php b/tests/OriginHeaderIdentificationTest.php index 071aa493..1d2eb4dc 100644 --- a/tests/OriginHeaderIdentificationTest.php +++ b/tests/OriginHeaderIdentificationTest.php @@ -36,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 () { From 62624275cc5ebf136808e31448941931a974f5a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Mon, 14 Jul 2025 21:48:30 +0200 Subject: [PATCH 42/42] phpstan fix --- src/Middleware/InitializeTenancyByOriginHeader.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Middleware/InitializeTenancyByOriginHeader.php b/src/Middleware/InitializeTenancyByOriginHeader.php index 4aa4f342..de016fca 100644 --- a/src/Middleware/InitializeTenancyByOriginHeader.php +++ b/src/Middleware/InitializeTenancyByOriginHeader.php @@ -11,7 +11,10 @@ class InitializeTenancyByOriginHeader extends InitializeTenancyByDomainOrSubdoma public function getDomain(Request $request): string { if ($origin = $request->header('Origin', '')) { - return parse_url($origin, PHP_URL_HOST) ?? $origin; + $host = parse_url($origin, PHP_URL_HOST) ?? $origin; + assert(is_string($host) && strlen($host) > 0); + + return $host; } return '';