diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca7c20f6..48b1ffd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: matrix: include: - laravel: "^12.0" + - laravel: "^13.0" steps: - name: Checkout diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 603e44e7..1cb358de 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -167,6 +167,10 @@ class TenancyServiceProvider extends ServiceProvider // ? $tenant->domain // : $tenant->domains->first()->domain; // + // if (is_null($tenantDomain)) { + // return $originalRootUrl; + // } + // // $scheme = str($originalRootUrl)->before('://'); // // if (str_contains($tenantDomain, '.')) { diff --git a/composer.json b/composer.json index 1ced01a5..180cbaab 100644 --- a/composer.json +++ b/composer.json @@ -18,17 +18,17 @@ "require": { "php": "^8.4", "ext-json": "*", - "illuminate/support": "^12.0", - "laravel/tinker": "^2.0", + "illuminate/support": "^12.0|^13.0", + "laravel/tinker": "^2.0|^3.0", "ramsey/uuid": "^4.7.3", - "stancl/jobpipeline": "2.0.0-rc6", + "stancl/jobpipeline": "2.0.0-rc7", "stancl/virtualcolumn": "^1.5.0", "spatie/invade": "*", "laravel/prompts": "0.*" }, "require-dev": { - "laravel/framework": "^12.0", - "orchestra/testbench": "^10.0", + "laravel/framework": "^13.0", + "orchestra/testbench": "^10.0|^11.0", "league/flysystem-aws-s3-v3": "^3.12.2", "doctrine/dbal": "^3.6.0", "spatie/valuestore": "^1.2.5", diff --git a/src/Commands/TenantDump.php b/src/Commands/TenantDump.php index 32677efc..97f9d539 100644 --- a/src/Commands/TenantDump.php +++ b/src/Commands/TenantDump.php @@ -63,7 +63,7 @@ class TenantDump extends DumpCommand protected function getOptions(): array { return array_merge([ - ['tenant', null, InputOption::VALUE_OPTIONAL, '', null], + new InputOption('tenant', null, InputOption::VALUE_OPTIONAL, '', null), ], parent::getOptions()); } } diff --git a/src/Concerns/HasTenantOptions.php b/src/Concerns/HasTenantOptions.php index 5beb3268..c1ea221f 100644 --- a/src/Concerns/HasTenantOptions.php +++ b/src/Concerns/HasTenantOptions.php @@ -17,8 +17,8 @@ trait HasTenantOptions protected function getOptions() { return array_merge([ - ['tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, 'The tenants to run this command for. Leave empty for all tenants', null], - ['with-pending', null, InputOption::VALUE_NONE, 'Include pending tenants in query'], // todo@pending should we also offer without-pending? if we add this, mention in docs + new InputOption('tenants', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_OPTIONAL, 'The tenants to run this command for. Leave empty for all tenants', null), + new InputOption('with-pending', null, InputOption::VALUE_NONE, 'Include pending tenants in query'), // todo@pending should we also offer without-pending? if we add this, mention in docs ], parent::getOptions()); } diff --git a/src/Database/Concerns/BelongsToPrimaryModel.php b/src/Database/Concerns/BelongsToPrimaryModel.php index 2c8c435f..ca3ba66f 100644 --- a/src/Database/Concerns/BelongsToPrimaryModel.php +++ b/src/Database/Concerns/BelongsToPrimaryModel.php @@ -12,6 +12,17 @@ trait BelongsToPrimaryModel abstract public function getRelationshipToPrimaryModel(): string; public static function bootBelongsToPrimaryModel(): void + { + if (method_exists(static::class, 'whenBooted')) { + // Laravel 13 + // For context see https://github.com/calebporzio/sushi/commit/62ff7f432cac736cb1da9f46d8f471cb78914b92 + static::whenBooted(fn () => static::configureBelongsToPrimaryModelScope()); + } else { + static::configureBelongsToPrimaryModelScope(); + } + } + + protected static function configureBelongsToPrimaryModelScope() { $implicitRLS = config('tenancy.rls.manager') === TraitRLSManager::class && TraitRLSManager::$implicitRLS; diff --git a/src/Database/Concerns/BelongsToTenant.php b/src/Database/Concerns/BelongsToTenant.php index da5dc84a..5c0f50fb 100644 --- a/src/Database/Concerns/BelongsToTenant.php +++ b/src/Database/Concerns/BelongsToTenant.php @@ -26,6 +26,17 @@ trait BelongsToTenant } public static function bootBelongsToTenant(): void + { + if (method_exists(static::class, 'whenBooted')) { + // Laravel 13 + // For context see https://github.com/calebporzio/sushi/commit/62ff7f432cac736cb1da9f46d8f471cb78914b92 + static::whenBooted(fn () => static::configureBelongsToTenantScope()); + } else { + static::configureBelongsToTenantScope(); + } + } + + protected static function configureBelongsToTenantScope(): void { // If TraitRLSManager::$implicitRLS is true or this model implements RLSModel // Postgres RLS is used for scoping, so we don't enable the scope used with single-database tenancy. diff --git a/src/Overrides/TenancyUrlGenerator.php b/src/Overrides/TenancyUrlGenerator.php index 88ae54f3..f7ed9a84 100644 --- a/src/Overrides/TenancyUrlGenerator.php +++ b/src/Overrides/TenancyUrlGenerator.php @@ -129,7 +129,15 @@ class TenancyUrlGenerator extends UrlGenerator throw new InvalidArgumentException('Attribute [name] expects a string backed enum.'); } - [$name, $parameters] = $this->prepareRouteInputs($name, Arr::wrap($parameters)); // @phpstan-ignore argument.type + $wrappedParameters = Arr::wrap($parameters); + + [$name, $parameters] = $this->prepareRouteInputs($name, $wrappedParameters); // @phpstan-ignore argument.type + + if (isset($wrappedParameters[static::$bypassParameter])) { + // If the bypass parameter was passed, we need to add it back to the parameters after prepareRouteInputs() removes it, + // so that the underlying route() call in parent::temporarySignedRoute() can bypass the behavior modification as well. + $parameters[static::$bypassParameter] = $wrappedParameters[static::$bypassParameter]; + } return parent::temporarySignedRoute($name, $expiration, $parameters, $absolute); } diff --git a/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php b/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php index 647422da..f089207a 100644 --- a/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php +++ b/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php @@ -2,6 +2,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Routing\UrlGenerator; +use Illuminate\Support\Facades\URL; use Stancl\Tenancy\Tests\Etc\Tenant; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; @@ -25,12 +26,16 @@ beforeEach(function () { Event::listen(TenancyEnded::class, RevertToCentralContext::class); TenancyUrlGenerator::$prefixRouteNames = false; TenancyUrlGenerator::$passTenantParameterToRoutes = false; + TenancyUrlGenerator::$overrides = []; + TenancyUrlGenerator::$bypassParameter = 'central'; UrlGeneratorBootstrapper::$addTenantParameterToDefaults = false; }); afterEach(function () { TenancyUrlGenerator::$prefixRouteNames = false; TenancyUrlGenerator::$passTenantParameterToRoutes = false; + TenancyUrlGenerator::$overrides = []; + TenancyUrlGenerator::$bypassParameter = 'central'; UrlGeneratorBootstrapper::$addTenantParameterToDefaults = false; }); @@ -359,3 +364,40 @@ test('both the name prefixing and the tenant parameter logic gets skipped when b expect(route('home', ['bypassParameter' => false, 'tenant' => $tenant->getTenantKey()]))->toBe($tenantRouteUrl) ->not()->toContain('bypassParameter'); }); + +test('the temporarySignedRoute method can automatically prefix the passed route name', function() { + config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); + + Route::get('/{tenant}/foo', fn () => 'foo')->name('tenant.foo')->middleware([InitializeTenancyByPath::class]); + + TenancyUrlGenerator::$prefixRouteNames = true; + + $tenant = Tenant::create(); + + tenancy()->initialize($tenant); + + // Route name ('foo') gets prefixed automatically (will be 'tenant.foo') + $tenantSignedUrl = URL::temporarySignedRoute('foo', now()->addMinutes(2), ['tenant' => $tenantKey = $tenant->getTenantKey()]); + + expect($tenantSignedUrl)->toContain("localhost/{$tenantKey}/foo"); +}); + +test('the bypass parameter works correctly with temporarySignedRoute', function() { + config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); + + Route::get('/foo', fn () => 'foo')->name('central.foo'); + + TenancyUrlGenerator::$prefixRouteNames = true; + TenancyUrlGenerator::$bypassParameter = 'central'; + + $tenant = Tenant::create(); + + tenancy()->initialize($tenant); + + // Bypass parameter allows us to generate URL for the 'central.foo' route in tenant context + $centralSignedUrl = URL::temporarySignedRoute('central.foo', now()->addMinutes(2), ['central' => true]); + + expect($centralSignedUrl) + ->toContain('localhost/foo') + ->not()->toContain('central='); // Bypass parameter gets removed from the generated URL +}); diff --git a/tests/SessionSeparationTest.php b/tests/SessionSeparationTest.php index d699bc61..6c7a8aa1 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 @@ -100,7 +101,7 @@ test('redis sessions are separated using the redis bootstrapper', function (bool expect($redisClient->getOption($redisClient::OPT_PREFIX) === "tenant_{$tenant->id}_")->toBe($bootstrappedEnabled); expect(array_filter(Redis::keys('*'), function (string $key) use ($tenant) { - return str($key)->startsWith("tenant_{$tenant->id}_laravel_cache_"); + return str($key)->startsWith(formatLaravelCacheKey(prefix: "tenant_{$tenant->id}_")); }))->toHaveCount($bootstrappedEnabled ? 1 : 0); })->with([true, false]); @@ -118,13 +119,13 @@ test('redis sessions are separated using the cache bootstrapper', function (bool Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar'); pest()->get("/{$tenant->id}/foo"); - expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions); + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions); tenancy()->end(); - expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_'); + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey()); expect(array_filter(Redis::keys('*'), function (string $key) use ($tenant) { - return str($key)->startsWith("foolaravel_cache_tenant_{$tenant->id}"); + return str($key)->startsWith(formatLaravelCacheKey(prefix: 'foo', suffix: "tenant_{$tenant->id}")); }))->toHaveCount($scopeSessions ? 1 : 0); })->with([true, false]); @@ -148,14 +149,14 @@ test('memcached sessions are separated using the cache bootstrapper', function ( Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar'); pest()->get("/{$tenant->id}/foo"); - expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions); + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions); tenancy()->end(); - expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_'); + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey()); sleep(1.1); // 1s+ sleep is necessary for getAllKeys() to work. if this causes race conditions or we want to avoid the delay, we can refactor this to some type of a mock expect(array_filter($allMemcachedKeys(), function (string $key) use ($tenant) { - return str($key)->startsWith("laravel_cache_tenant_{$tenant->id}"); + return str($key)->startsWith(formatLaravelCacheKey("tenant_{$tenant->id}")); }))->toHaveCount($scopeSessions ? 1 : 0); Artisan::call('cache:clear memcached'); @@ -177,13 +178,13 @@ test('dynamodb sessions are separated using the cache bootstrapper', function (b Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar'); pest()->get("/{$tenant->id}/foo"); - expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions); + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions); tenancy()->end(); - expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_'); + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey()); expect(array_filter($allDynamodbKeys(), function (string $key) use ($tenant) { - return str($key)->startsWith("laravel_cache_tenant_{$tenant->id}"); + return str($key)->startsWith(formatLaravelCacheKey("tenant_{$tenant->id}")); }))->toHaveCount($scopeSessions ? 1 : 0); })->with([true, false]); @@ -202,13 +203,13 @@ test('apc sessions are separated using the cache bootstrapper', function (bool $ Route::middleware(StartSession::class, InitializeTenancyByPath::class)->get('/{tenant}/foo', fn () => 'bar'); pest()->get("/{$tenant->id}/foo"); - expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === "laravel_cache_tenant_{$tenant->id}_")->toBe($scopeSessions); + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix() === formatLaravelCacheKey("tenant_{$tenant->id}_"))->toBe($scopeSessions); tenancy()->end(); - expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe('laravel_cache_'); + expect(app('session')->driver()->getHandler()->getCache()->getStore()->getPrefix())->toBe(formatLaravelCacheKey()); expect(array_filter($allApcuKeys(), function (string $key) use ($tenant) { - return str($key)->startsWith("laravel_cache_tenant_{$tenant->id}"); + return str($key)->startsWith(formatLaravelCacheKey("tenant_{$tenant->id}")); }))->toHaveCount($scopeSessions ? 1 : 0); })->with([true, false]); @@ -250,3 +251,13 @@ test('database sessions are separated regardless of whether the session bootstra // [false, true], // when the connection IS set, the session bootstrapper becomes necessary [false, false], ]); + +function formatLaravelCacheKey(string $suffix = '', string $prefix = ''): string +{ + // todo@release if we drop Laravel 12 support we can just switch to - syntax everywhere + if (version_compare(app()->version(), '13.0.0') >= 0) { + return $prefix . 'laravel-cache-' . $suffix; + } else { + return $prefix . 'laravel_cache_' . $suffix; + } +}