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/25] [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/25] [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/25] 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/25] 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/25] 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/25] 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/25] [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/25] [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/25] 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/25] [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/25] 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/25] 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/25] 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/25] 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/25] 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/25] [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/25] [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/25] [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/25] [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/25] [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/25] [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/25] [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/25] [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/25] 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/25] [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 = [];