diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 76af44d9..6e6055af 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,5 +53,5 @@ If you want to use XDebug, use `composer docker-rebuild-with-xdebug`. ## PHP 8.5 To use PHP 8.5 during development, run: -- `PHP_VERSION=8.5.0RC2 composer docker-rebuild` to build the `test` container with PHP 8.5 +- `PHP_VERSION=8.5.0 composer docker-rebuild` to build the `test` container with PHP 8.5 - `composer php85-patch` to get rid of some deprecation errors coming from `config/database.php` from within testbench-core diff --git a/README.md b/README.md index 799dc11f..1f51b1bf 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ You won't have to change a thing in your application's code. - :heavy_check_mark: No replacing of Laravel classes (`Cache`, `Storage`, ...) with tenancy-aware classes - :heavy_check_mark: Built-in tenant identification based on hostname (including second level domains) -### [Documentation](https://tenancy-v4.pages.dev/) +### [Documentation](https://v4.tenancyforlaravel.com) -Documentation can be found here: https://tenancy-v4.pages.dev/ +Documentation can be found here: https://v4.tenancyforlaravel.com ### [Need help?](https://github.com/stancl/tenancy/blob/3.x/SUPPORT.md) diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index e0b69e6e..603e44e7 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Providers; -use Illuminate\Routing\Route; use Stancl\Tenancy\Jobs; use Stancl\Tenancy\Events; use Stancl\Tenancy\ResourceSyncing; @@ -14,12 +13,8 @@ use Stancl\JobPipeline\JobPipeline; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; use Stancl\Tenancy\Actions\CloneRoutesAsTenant; -use Stancl\Tenancy\Overrides\TenancyUrlGenerator; use Illuminate\Contracts\Database\Eloquent\Builder; -use Illuminate\Support\Facades\Route as RouteFacade; -use Stancl\Tenancy\Middleware\InitializeTenancyByPath; -use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData; -use Stancl\Tenancy\Bootstrappers\Integrations\FortifyRouteBootstrapper; +use Illuminate\Support\Facades\Route; /** * Tenancy for Laravel. @@ -81,6 +76,8 @@ class TenancyServiceProvider extends ServiceProvider ])->send(function (Events\TenantDeleted $event) { return $event->tenant; })->shouldBeQueued(false), + + // ResourceSyncing\Listeners\DeleteAllTenantMappings::class, ], Events\TenantMaintenanceModeEnabled::class => [], @@ -129,6 +126,9 @@ class TenancyServiceProvider extends ServiceProvider ResourceSyncing\Events\SyncedResourceSaved::class => [ ResourceSyncing\Listeners\UpdateOrCreateSyncedResource::class, ], + ResourceSyncing\Events\SyncedResourceDeleted::class => [ + ResourceSyncing\Listeners\DeleteResourceMapping::class, + ], ResourceSyncing\Events\SyncMasterDeleted::class => [ ResourceSyncing\Listeners\DeleteResourcesInTenants::class, ], @@ -141,7 +141,9 @@ class TenancyServiceProvider extends ServiceProvider ResourceSyncing\Events\CentralResourceDetachedFromTenant::class => [ ResourceSyncing\Listeners\DeleteResourceInTenant::class, ], - // Fired only when a synced resource is changed in a different DB than the origin DB (to avoid infinite loops) + + // Fired only when a synced resource is changed (as a result of syncing) + // in a different DB than DB from which the change originates (to avoid infinite loops) ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase::class => [], // Storage symlinks @@ -200,7 +202,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\Tenancy::defaultMiddleware()]); + // return Route::post('/livewire/update', $handle)->middleware(['web', 'universal', \Stancl\Tenancy\Tenancy::defaultMiddleware()]); // }); } @@ -221,7 +223,7 @@ class TenancyServiceProvider extends ServiceProvider { $this->app->booted(function () { if (file_exists(base_path('routes/tenant.php'))) { - RouteFacade::namespace(static::$controllerNamespace) + Route::namespace(static::$controllerNamespace) ->middleware('tenant') ->group(base_path('routes/tenant.php')); } @@ -242,24 +244,7 @@ class TenancyServiceProvider extends ServiceProvider /** @var CloneRoutesAsTenant $cloneRoutes */ $cloneRoutes = $this->app->make(CloneRoutesAsTenant::class); - // The cloning action has two modes: - // 1. Clone all routes that have the middleware present in the action's $cloneRoutesWithMiddleware property. - // You can customize the middleware that triggers cloning by using cloneRoutesWithMiddleware() on the action. - // - // By default, the middleware is ['clone'], but using $cloneRoutes->cloneRoutesWithMiddleware(['clone', 'universal'])->handle() - // will clone all routes that have either 'clone' or 'universal' middleware (mentioning 'universal' since that's a common use case). - // - // Also, you can use the shouldClone() method to provide a custom closure that determines if a route should be cloned. - // - // 2. Clone only the routes that were manually added to the action using cloneRoute(). - // - // Regardless of the mode, you can provide a custom closure for defining the cloned route, e.g.: - // $cloneRoutesAction->cloneUsing(function (Route $route) { - // RouteFacade::get('/cloned/' . $route->uri(), fn () => 'cloned route')->name('cloned.' . $route->getName()); - // })->handle(); - // This will make all cloned routes use the custom closure to define the cloned route instead of the default behavior. - // See Stancl\Tenancy\Actions\CloneRoutesAsTenant for more details. - + /** See CloneRoutesAsTenant for usage details. */ $cloneRoutes->handle(); } diff --git a/assets/config.php b/assets/config.php index d01cbff7..2a3a07e2 100644 --- a/assets/config.php +++ b/assets/config.php @@ -48,6 +48,8 @@ return [ * SECURITY NOTE: Keep in mind that autoincrement IDs come with potential enumeration issues (such as tenant storage URLs). * * @see \Stancl\Tenancy\UniqueIdentifierGenerators\UUIDGenerator + * @see \Stancl\Tenancy\UniqueIdentifierGenerators\ULIDGenerator + * @see \Stancl\Tenancy\UniqueIdentifierGenerators\UUIDv7Generator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomHexGenerator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomIntGenerator * @see \Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator @@ -62,7 +64,7 @@ return [ * Only relevant if you're using the domain or subdomain identification middleware. */ 'central_domains' => [ - str(env('APP_URL'))->after('://')->before('/')->toString(), + str(env('APP_URL'))->after('://')->before('/')->before(':')->toString(), ], /** @@ -311,7 +313,7 @@ return [ * * Note: This will implicitly add your configured session store to the list of prefixed stores above. */ - 'scope_sessions' => true, + 'scope_sessions' => in_array(env('SESSION_DRIVER'), ['redis', 'memcached', 'dynamodb', 'apc'], true), 'tag_base' => 'tenant', // This tag_base, followed by the tenant_id, will form a tag that will be applied on each cache call. ], @@ -444,7 +446,6 @@ return [ /** * Pending tenants config. - * This is useful if you're looking for a way to always have a tenant ready to be used. */ 'pending' => [ /** @@ -453,6 +454,7 @@ return [ * Note: when disabled, this will also ignore pending tenants when running the tenant commands (migration, seed, etc.) */ 'include_in_queries' => true, + /** * Defines how many pending tenants you want to have ready in the pending tenant pool. * This depends on the volume of tenants you're creating. diff --git a/phpstan.neon b/phpstan.neon index 2c6e3d69..bb97e3a0 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -46,10 +46,6 @@ parameters: message: '#PHPDoc tag \@param has invalid value \(dynamic#' paths: - src/helpers.php - - - message: '#Illuminate\\Routing\\UrlGenerator#' - paths: - - src/Bootstrappers/FilesystemTenancyBootstrapper.php - '#Method Stancl\\Tenancy\\Tenancy::cachedResolvers\(\) should return array#' - '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$tenancy#' - '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$resolver#' diff --git a/src/Actions/CloneRoutesAsTenant.php b/src/Actions/CloneRoutesAsTenant.php index f1cb1450..6e988907 100644 --- a/src/Actions/CloneRoutesAsTenant.php +++ b/src/Actions/CloneRoutesAsTenant.php @@ -30,6 +30,8 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver; * By providing a callback to shouldClone(), you can change how it's determined if a route should be cloned if you don't want to use middleware flags. * * Cloned routes are prefixed with '/{tenant}', flagged with 'tenant' middleware, and have their names prefixed with 'tenant.'. + * The addition of the 'tenant' middleware can be controlled using addTenantMiddleware(array). You can specify the identification + * middleware to be used on the cloned route using that method -- instead of using the approach that "inherits" it from a universal route. * * The addition of the tenant parameter can be controlled using addTenantParameter(true|false). Note that if you decide to disable * tenant parameter addition, the routes MUST differ in domains. This can be controlled using the domain(string|null) method. The @@ -39,7 +41,7 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver; * Routes with names that are already prefixed won't be cloned - but that's just the default behavior. * The cloneUsing() method allows you to completely override the default behavior and customize how the cloned routes will be defined. * - * After cloning, only top-level middleware in $cloneRoutesWithMiddleware will be removed + * After cloning, only top-level middleware in $cloneRoutesWithMiddleware (as well as any route context flags) will be removed * from the new route (so by default, 'clone' will be omitted from the new route's MW). * Middleware groups are preserved as-is, even if they contain cloning middleware. * @@ -71,7 +73,7 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver; * // cloned route can be customized using domain(string|null). By default, the cloned route will not be scoped to a domain, * // unless a domain() call is used. It's important to keep in mind that: * // 1. When addTenantParameter(false) is used, the paths will be the same, thus domains must differ. - * // 2. If the original route (with the same path) has no domain, the cloned route will never be used due to registration order. + * // 2. If the original route has no domain, the cloned route will override the original route as they will directly conflict. * $cloneAction->addTenantParameter(false)->cloneRoutesWithMiddleware(['clone'])->cloneRoute('no-tenant-parameter')->handle(); * ``` * @@ -84,27 +86,50 @@ use Stancl\Tenancy\Resolvers\PathTenantResolver; */ class CloneRoutesAsTenant { + /** @var list */ protected array $routesToClone = []; + protected bool $addTenantParameter = true; protected bool $tenantParameterBeforePrefix = true; protected string|null $domain = null; - protected Closure|null $cloneUsing = null; // The callback should accept Route instance or the route name (string) + + /** + * The callback should accept a Route instance or the route name (string). + * + * @var ?Closure(Route|string): void + */ + protected Closure|null $cloneUsing = null; + + /** @var ?Closure(Route): bool */ protected Closure|null $shouldClone = null; + + /** @var list */ protected array $cloneRoutesWithMiddleware = ['clone']; + /** @var list */ + protected array $addTenantMiddleware = ['tenant']; + public function __construct( protected Router $router, ) {} + public static function make(): static + { + return app(static::class); + } + /** Clone routes. This resets routesToClone() but not other config. */ public function handle(): void { // If no routes were specified using cloneRoute(), get all routes // and for each, determine if it should be cloned if (! $this->routesToClone) { - $this->routesToClone = collect($this->router->getRoutes()->get()) + /** @var list */ + $routesToClone = collect($this->router->getRoutes()->get()) ->filter(fn (Route $route) => $this->shouldBeCloned($route)) ->all(); + + $this->routesToClone = $routesToClone; } foreach ($this->routesToClone as $route) { @@ -118,7 +143,9 @@ class CloneRoutesAsTenant if (is_string($route)) { $this->router->getRoutes()->refreshNameLookups(); - $route = $this->router->getRoutes()->getByName($route); + $routeName = $route; + $route = $this->router->getRoutes()->getByName($routeName); + assert(! is_null($route), "Route [{$routeName}] was meant to be cloned but does not exist."); } $this->createNewRoute($route); @@ -143,6 +170,20 @@ class CloneRoutesAsTenant return $this; } + /** + * The tenant middleware to be added to the cloned route. + * + * If used with early identification, make sure to include 'tenant' in this array. + * + * @param list $middleware + */ + public function addTenantMiddleware(array $middleware): static + { + $this->addTenantMiddleware = $middleware; + + return $this; + } + /** The domain the cloned route should use. Set to null if it shouldn't be scoped to a domain. */ public function domain(string|null $domain): static { @@ -151,7 +192,11 @@ class CloneRoutesAsTenant return $this; } - /** Provide a custom callback for cloning routes, instead of the default behavior. */ + /** + * Provide a custom callback for cloning routes, instead of the default behavior. + * + * @param ?Closure(Route|string): void $cloneUsing + */ public function cloneUsing(Closure|null $cloneUsing): static { $this->cloneUsing = $cloneUsing; @@ -159,7 +204,11 @@ class CloneRoutesAsTenant return $this; } - /** Specify which middleware should serve as "flags" telling this action to clone those routes. */ + /** + * Specify which middleware should serve as "flags" telling this action to clone those routes. + * + * @param list $middleware + */ public function cloneRoutesWithMiddleware(array $middleware): static { $this->cloneRoutesWithMiddleware = $middleware; @@ -170,7 +219,9 @@ class CloneRoutesAsTenant /** * Provide a custom callback for determining whether a route should be cloned. * Overrides the default middleware-based detection. - * */ + * + * @param Closure(Route): bool $shouldClone + */ public function shouldClone(Closure|null $shouldClone): static { $this->shouldClone = $shouldClone; @@ -193,6 +244,18 @@ class CloneRoutesAsTenant return $this; } + /** + * Clone individual routes. + * + * @param list $routes + */ + public function cloneRoutes(array $routes): static + { + $this->routesToClone = array_merge($this->routesToClone, $routes); + + return $this; + } + protected function shouldBeCloned(Route $route): bool { // Don't clone routes that already have tenant parameter or prefix @@ -258,17 +321,15 @@ class CloneRoutesAsTenant return $newRoute; } - /** Removes top-level cloneRoutesWithMiddleware and adds 'tenant' middleware. */ + /** Removes top-level cloneRoutesWithMiddleware and context flags, adds 'tenant' middleware. */ protected function processMiddlewareForCloning(array $middleware): array { $processedMiddleware = array_filter( $middleware, - fn ($mw) => ! in_array($mw, $this->cloneRoutesWithMiddleware) + fn ($mw) => ! in_array($mw, $this->cloneRoutesWithMiddleware) && ! in_array($mw, ['central', 'tenant', 'universal']) ); - $processedMiddleware[] = 'tenant'; - - return array_unique($processedMiddleware); + return array_unique(array_merge($processedMiddleware, $this->addTenantMiddleware)); } /** Check if route already has tenant parameter or name prefix. */ diff --git a/src/Bootstrappers/BroadcastingConfigBootstrapper.php b/src/Bootstrappers/BroadcastingConfigBootstrapper.php index 32bc54bf..66fee704 100644 --- a/src/Bootstrappers/BroadcastingConfigBootstrapper.php +++ b/src/Bootstrappers/BroadcastingConfigBootstrapper.php @@ -15,7 +15,7 @@ use Stancl\Tenancy\Overrides\TenancyBroadcastManager; class BroadcastingConfigBootstrapper implements TenancyBootstrapper { /** - * Tenant properties to be mapped to config (similarly to the TenantConfig feature). + * Tenant properties to be mapped to config (similarly to the TenantConfigBootstrapper). * * For example: * [ diff --git a/src/Bootstrappers/CacheTenancyBootstrapper.php b/src/Bootstrappers/CacheTenancyBootstrapper.php index 20e09816..97bd7d24 100644 --- a/src/Bootstrappers/CacheTenancyBootstrapper.php +++ b/src/Bootstrappers/CacheTenancyBootstrapper.php @@ -102,14 +102,7 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper if ($this->config->get('tenancy.cache.scope_sessions', true)) { // These are the only cache driven session backends (see Laravel's config/session.php) if (! in_array($this->config->get('session.driver'), ['redis', 'memcached', 'dynamodb', 'apc'], true)) { - if (app()->environment('production')) { - // We only throw this exception in prod to make configuration a little easier. Developers - // may have scope_sessions set to true while using different session drivers e.g. in tests. - // Previously we just silently ignored this, however since session scoping is of high importance - // in production, we make sure to notify the developer, by throwing an exception, that session - // scoping isn't happening as expected/configured due to an incompatible session driver. - throw new Exception('Session driver [' . $this->config->get('session.driver') . '] cannot be scoped by tenancy.cache.scope_session'); - } + throw new Exception('Session driver [' . $this->config->get('session.driver') . '] cannot be scoped by tenancy.cache.scope_sessions'); } else { // Scoping sessions using this bootstrapper implicitly adds the session store to $names $names[] = $this->getSessionCacheStoreName(); diff --git a/src/Bootstrappers/DatabaseCacheBootstrapper.php b/src/Bootstrappers/DatabaseCacheBootstrapper.php index ae547471..0e41849f 100644 --- a/src/Bootstrappers/DatabaseCacheBootstrapper.php +++ b/src/Bootstrappers/DatabaseCacheBootstrapper.php @@ -63,13 +63,17 @@ class DatabaseCacheBootstrapper implements TenancyBootstrapper $stores = $this->scopedStoreNames(); foreach ($stores as $storeName) { - $this->originalConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.connection"); - $this->originalLockConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.lock_connection"); + $this->originalConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.connection") ?? config('tenancy.database.central_connection'); + $this->originalLockConnections[$storeName] = $this->config->get("cache.stores.{$storeName}.lock_connection") ?? config('tenancy.database.central_connection'); $this->config->set("cache.stores.{$storeName}.connection", 'tenant'); $this->config->set("cache.stores.{$storeName}.lock_connection", 'tenant'); - $this->cache->purge($storeName); + /** @var DatabaseStore $store */ + $store = $this->cache->store($storeName)->getStore(); + + $store->setConnection(DB::connection('tenant')); + $store->setLockConnection(DB::connection('tenant')); } if (static::$adjustGlobalCacheManager) { @@ -78,8 +82,8 @@ class DatabaseCacheBootstrapper implements TenancyBootstrapper // *from here* being executed repeatedly in a loop on reinitialization. For that reason we do not do that // (this is our only use of $adjustCacheManagerUsing anyway) but ideally at some point we'd have a better solution. $originalConnections = array_combine($stores, array_map(fn (string $storeName) => [ - 'connection' => $this->originalConnections[$storeName] ?? config('tenancy.database.central_connection'), - 'lockConnection' => $this->originalLockConnections[$storeName] ?? config('tenancy.database.central_connection'), + 'connection' => $this->originalConnections[$storeName], + 'lockConnection' => $this->originalLockConnections[$storeName], ], $stores)); TenancyServiceProvider::$adjustCacheManagerUsing = static function (CacheManager $manager) use ($originalConnections) { @@ -100,7 +104,11 @@ class DatabaseCacheBootstrapper implements TenancyBootstrapper $this->config->set("cache.stores.{$storeName}.connection", $originalConnection); $this->config->set("cache.stores.{$storeName}.lock_connection", $this->originalLockConnections[$storeName]); - $this->cache->purge($storeName); + /** @var DatabaseStore $store */ + $store = $this->cache->store($storeName)->getStore(); + + $store->setConnection(DB::connection($this->originalConnections[$storeName])); + $store->setLockConnection(DB::connection($this->originalLockConnections[$storeName])); } TenancyServiceProvider::$adjustCacheManagerUsing = null; diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index f8410454..af2b809f 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Stancl\Tenancy\Bootstrappers; use Illuminate\Foundation\Application; -use Illuminate\Routing\UrlGenerator; use Illuminate\Session\FileSessionHandler; use Illuminate\Support\Facades\Storage; use Stancl\Tenancy\Contracts\TenancyBootstrapper; @@ -22,13 +21,6 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper ) { $this->originalAssetUrl = $this->app['config']['app.asset_url']; $this->originalStoragePath = $app->storagePath(); - - $this->app['url']->macro('setAssetRoot', function ($root) { - /** @var UrlGenerator $this */ - $this->assetRoot = $root; - - return $this; - }); } public function bootstrap(Tenant $tenant): void @@ -78,6 +70,15 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper return; } + $path = $suffix + ? $this->tenantStoragePath($suffix) . '/framework/cache' + : $this->originalStoragePath . '/framework/cache'; + + if (! is_dir($path)) { + // Create tenant framework/cache directory if it does not exist + mkdir($path, 0750, true); + } + if ($suffix === false) { $this->app->useStoragePath($this->originalStoragePath); } else { @@ -98,16 +99,16 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper if ($suffix === false) { $this->app['config']['app.asset_url'] = $this->originalAssetUrl; - $this->app['url']->setAssetRoot($this->originalAssetUrl); + $this->app['url']->useAssetOrigin($this->originalAssetUrl); return; } if ($this->originalAssetUrl) { $this->app['config']['app.asset_url'] = $this->originalAssetUrl . "/$suffix"; - $this->app['url']->setAssetRoot($this->app['config']['app.asset_url']); + $this->app['url']->useAssetOrigin($this->app['config']['app.asset_url']); } else { - $this->app['url']->setAssetRoot($this->app['url']->route('stancl.tenancy.asset', ['path' => ''])); + $this->app['url']->useAssetOrigin($this->app['url']->route('stancl.tenancy.asset', ['path' => ''])); } } @@ -124,7 +125,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper } } - Storage::forgetDisk([...$tenantDisks, ...$scopedDisks]); + Storage::forgetDisk(array_merge($tenantDisks, $scopedDisks)); } protected function diskRoot(string $disk, Tenant|false $tenant): void @@ -222,7 +223,7 @@ class FilesystemTenancyBootstrapper implements TenancyBootstrapper if (! is_dir($path)) { // Create tenant framework/sessions directory if it does not exist - mkdir($path, 0755, true); + mkdir($path, 0750, true); } $this->app['config']['session.files'] = $path; diff --git a/src/Bootstrappers/MailConfigBootstrapper.php b/src/Bootstrappers/MailConfigBootstrapper.php index 60028cc1..dcbf46d2 100644 --- a/src/Bootstrappers/MailConfigBootstrapper.php +++ b/src/Bootstrappers/MailConfigBootstrapper.php @@ -12,7 +12,7 @@ use Stancl\Tenancy\Contracts\Tenant; class MailConfigBootstrapper implements TenancyBootstrapper { /** - * Tenant properties to be mapped to config (similarly to the TenantConfig feature). + * Tenant properties to be mapped to config (similarly to the TenantConfigBootstrapper). * * For example: * [ diff --git a/src/Bootstrappers/UrlGeneratorBootstrapper.php b/src/Bootstrappers/UrlGeneratorBootstrapper.php index 6c923d21..3708d636 100644 --- a/src/Bootstrappers/UrlGeneratorBootstrapper.php +++ b/src/Bootstrappers/UrlGeneratorBootstrapper.php @@ -7,6 +7,7 @@ namespace Stancl\Tenancy\Bootstrappers; use Illuminate\Contracts\Foundation\Application; use Illuminate\Routing\UrlGenerator; use Illuminate\Support\Facades\URL; +use Illuminate\Support\Str; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Overrides\TenancyUrlGenerator; @@ -78,6 +79,10 @@ class UrlGeneratorBootstrapper implements TenancyBootstrapper } } + // Inherit scheme (http/https) from the original generator + $originalScheme = Str::before($this->originalUrlGenerator->formatScheme(), '://'); + $newGenerator->forceScheme($originalScheme); + $newGenerator->defaults($defaultParameters); $newGenerator->setSessionResolver(function () { diff --git a/src/Commands/PurgeImpersonationTokens.php b/src/Commands/PurgeImpersonationTokens.php new file mode 100644 index 00000000..b64b29f8 --- /dev/null +++ b/src/Commands/PurgeImpersonationTokens.php @@ -0,0 +1,38 @@ +components->info('Deleting expired impersonation tokens.'); + + $expirationDate = now()->subSeconds(UserImpersonation::$ttl); + + $impersonationTokenModel = UserImpersonation::modelClass(); + + $deletedTokenCount = $impersonationTokenModel::where('created_at', '<', $expirationDate) + ->delete(); + + $this->components->info($deletedTokenCount . ' expired impersonation ' . str('token')->plural($deletedTokenCount) . ' deleted.'); + + return 0; + } +} diff --git a/src/Concerns/HasTenantOptions.php b/src/Concerns/HasTenantOptions.php index 8cd105ba..5beb3268 100644 --- a/src/Concerns/HasTenantOptions.php +++ b/src/Concerns/HasTenantOptions.php @@ -18,7 +18,7 @@ trait HasTenantOptions { 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'], + ['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/Concerns/ManagesRLSPolicies.php b/src/Concerns/ManagesRLSPolicies.php index 6b804fb7..f6329d0e 100644 --- a/src/Concerns/ManagesRLSPolicies.php +++ b/src/Concerns/ManagesRLSPolicies.php @@ -26,7 +26,7 @@ trait ManagesRLSPolicies $policies = static::getRLSPolicies($table); foreach ($policies as $policy) { - DB::statement('DROP POLICY ? ON ?', [$policy, $table]); + DB::statement("DROP POLICY {$policy} ON {$table}"); } return count($policies); diff --git a/src/Database/Concerns/PendingScope.php b/src/Database/Concerns/PendingScope.php index d83a37dd..712de6c7 100644 --- a/src/Database/Concerns/PendingScope.php +++ b/src/Database/Concerns/PendingScope.php @@ -10,13 +10,6 @@ use Illuminate\Database\Eloquent\Scope; class PendingScope implements Scope { - /** - * All of the extensions to be added to the builder. - * - * @var string[] - */ - protected $extensions = ['WithPending', 'WithoutPending', 'OnlyPending']; - /** * Apply the scope to a given Eloquent query builder. * @@ -32,26 +25,21 @@ class PendingScope implements Scope } /** - * Extend the query builder with the needed functions. + * Add methods to the query builder. * * @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder - * - * @return void */ - public function extend(Builder $builder) + public function extend(Builder $builder): void { - foreach ($this->extensions as $extension) { - $this->{"add{$extension}"}($builder); - } + $this->addWithPending($builder); + $this->addWithoutPending($builder); + $this->addOnlyPending($builder); } + /** - * Add the with-pending extension to the builder. - * * @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder - * - * @return void */ - protected function addWithPending(Builder $builder) + protected function addWithPending(Builder $builder): void { $builder->macro('withPending', function (Builder $builder, $withPending = true) { if (! $withPending) { @@ -63,13 +51,9 @@ class PendingScope implements Scope } /** - * Add the without-pending extension to the builder. - * * @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder - * - * @return void */ - protected function addWithoutPending(Builder $builder) + protected function addWithoutPending(Builder $builder): void { $builder->macro('withoutPending', function (Builder $builder) { $builder->withoutGlobalScope(static::class) @@ -81,13 +65,9 @@ class PendingScope implements Scope } /** - * Add the only-pending extension to the builder. - * * @param Builder<\Stancl\Tenancy\Contracts\Tenant&Model> $builder - * - * @return void */ - protected function addOnlyPending(Builder $builder) + protected function addOnlyPending(Builder $builder): void { $builder->macro('onlyPending', function (Builder $builder) { $builder->withoutGlobalScope(static::class)->whereNotNull($builder->getModel()->getColumnForQuery('pending_since')); diff --git a/src/Features/UserImpersonation.php b/src/Features/UserImpersonation.php index ac478d07..d286b8ba 100644 --- a/src/Features/UserImpersonation.php +++ b/src/Features/UserImpersonation.php @@ -44,12 +44,20 @@ class UserImpersonation implements Feature $tokenExpired = $token->created_at->diffInSeconds(now()) > $ttl; - abort_if($tokenExpired, 403); + if ($tokenExpired) { + $token->delete(); + + abort(403); + } $tokenTenantId = (string) $token->getAttribute(Tenancy::tenantKeyColumn()); $currentTenantId = (string) tenant()->getTenantKey(); - abort_unless($tokenTenantId === $currentTenantId, 403); + if ($tokenTenantId !== $currentTenantId) { + $token->delete(); + + abort(403); + } Auth::guard($token->auth_guard)->loginUsingId($token->user_id, $token->remember); diff --git a/src/Listeners/CreateTenantStorage.php b/src/Listeners/CreateTenantStorage.php index 73da89fc..3bebb731 100644 --- a/src/Listeners/CreateTenantStorage.php +++ b/src/Listeners/CreateTenantStorage.php @@ -4,18 +4,25 @@ declare(strict_types=1); namespace Stancl\Tenancy\Listeners; -use Stancl\Tenancy\Events\TenantCreated; +use Stancl\Tenancy\Events\Contracts\TenantEvent; +/** + * Can be used to manually create framework directories in the tenant storage when storage_path() is scoped. + * + * Useful when using real-time facades which use the framework/cache directory. + * + * Generally not needed anymore as the directory is also created by the FilesystemTenancyBootstrapper. + */ class CreateTenantStorage { - public function handle(TenantCreated $event): void + public function handle(TenantEvent $event): void { $storage_path = tenancy()->run($event->tenant, fn () => storage_path()); $cache_path = "$storage_path/framework/cache"; if (! is_dir($cache_path)) { // Create the tenant's storage directory and /framework/cache within (used for e.g. real-time facades) - mkdir($cache_path, 0777, true); + mkdir($cache_path, 0750, true); } } } diff --git a/src/Listeners/DeleteTenantStorage.php b/src/Listeners/DeleteTenantStorage.php index 25adc4f4..ec360073 100644 --- a/src/Listeners/DeleteTenantStorage.php +++ b/src/Listeners/DeleteTenantStorage.php @@ -5,11 +5,11 @@ declare(strict_types=1); namespace Stancl\Tenancy\Listeners; use Illuminate\Support\Facades\File; -use Stancl\Tenancy\Events\DeletingTenant; +use Stancl\Tenancy\Events\Contracts\TenantEvent; class DeleteTenantStorage { - public function handle(DeletingTenant $event): void + public function handle(TenantEvent $event): void { $path = tenancy()->run($event->tenant, fn () => storage_path()); diff --git a/src/ResourceSyncing/CentralResourceNotAvailableInPivotException.php b/src/ResourceSyncing/CentralResourceNotAvailableInPivotException.php index d20415be..fbb918dd 100644 --- a/src/ResourceSyncing/CentralResourceNotAvailableInPivotException.php +++ b/src/ResourceSyncing/CentralResourceNotAvailableInPivotException.php @@ -13,7 +13,7 @@ class CentralResourceNotAvailableInPivotException extends Exception parent::__construct( 'Central resource is not accessible in pivot model. To attach a resource to a tenant, use $centralResource->tenants()->attach($tenant) instead of $tenant->resources()->attach($centralResource) (same for detaching). - To make this work both ways, you can make your pivot implement PivotWithRelation and return the related model in getRelatedModel() or extend MorphPivot.' + To make this work both ways, you can make your pivot implement PivotWithCentralResource and return the related model in getCentralResourceClass() or extend MorphPivot.' ); } } diff --git a/src/ResourceSyncing/Events/SyncedResourceDeleted.php b/src/ResourceSyncing/Events/SyncedResourceDeleted.php new file mode 100644 index 00000000..941e1841 --- /dev/null +++ b/src/ResourceSyncing/Events/SyncedResourceDeleted.php @@ -0,0 +1,18 @@ + 'tenant_key_column'] format. + * + * Since we cannot automatically detect which pivot tables + * are being used, they have to be specified here manually. + * + * The default value follows the polymorphic table used by default. + */ + public static array $pivotTables = ['tenant_resources' => 'tenant_id']; + + public function handle(TenantDeleted $event): void + { + foreach (static::$pivotTables as $table => $tenantKeyColumn) { + DB::table($table)->where($tenantKeyColumn, $event->tenant->getKey())->delete(); + } + } +} diff --git a/src/ResourceSyncing/Listeners/DeleteResourceMapping.php b/src/ResourceSyncing/Listeners/DeleteResourceMapping.php new file mode 100644 index 00000000..53754324 --- /dev/null +++ b/src/ResourceSyncing/Listeners/DeleteResourceMapping.php @@ -0,0 +1,60 @@ +getCentralResource($event->model); + + if (! $centralResource) { + return; + } + + // Delete pivot records if the central resource doesn't use soft deletes + // or the central resource was deleted using forceDelete() + if ($event->forceDelete || ! in_array(SoftDeletes::class, class_uses_recursive($centralResource::class), true)) { + Pivot::withoutEvents(function () use ($centralResource, $event) { + // If detach() is called with null -- if $event->tenant is null -- this means a central resource was deleted and detaches all tenants. + // If detach() is called with a specific tenant, it means the resource was deleted in that tenant, and we only delete that single mapping. + $centralResource->tenants()->detach($event->tenant); + }); + } + } + + public function getCentralResource(Syncable&Model $resource): SyncMaster|null + { + if ($resource instanceof SyncMaster) { + return $resource; + } + + $centralResourceClass = $resource->getCentralModelName(); + + /** @var (SyncMaster&Model)|null $centralResource */ + $centralResource = $centralResourceClass::firstWhere( + $resource->getGlobalIdentifierKeyName(), + $resource->getGlobalIdentifierKey() + ); + + return $centralResource; + } +} diff --git a/src/ResourceSyncing/Listeners/DeleteResourcesInTenants.php b/src/ResourceSyncing/Listeners/DeleteResourcesInTenants.php index 6876f476..7b071a27 100644 --- a/src/ResourceSyncing/Listeners/DeleteResourcesInTenants.php +++ b/src/ResourceSyncing/Listeners/DeleteResourcesInTenants.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Stancl\Tenancy\ResourceSyncing\Listeners; -use Illuminate\Database\Eloquent\SoftDeletes; use Stancl\Tenancy\Listeners\QueueableListener; use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterDeleted; @@ -21,12 +20,6 @@ class DeleteResourcesInTenants extends QueueableListener tenancy()->runForMultiple($centralResource->tenants()->cursor(), function () use ($centralResource, $forceDelete) { $this->deleteSyncedResource($centralResource, $forceDelete); - - // Delete pivot records if the central resource doesn't use soft deletes - // or the central resource was deleted using forceDelete() - if ($forceDelete || ! in_array(SoftDeletes::class, class_uses_recursive($centralResource::class), true)) { - $centralResource->tenants()->detach(tenant()); - } }); } } diff --git a/src/ResourceSyncing/PivotWithCentralResource.php b/src/ResourceSyncing/PivotWithCentralResource.php new file mode 100644 index 00000000..07efcc2e --- /dev/null +++ b/src/ResourceSyncing/PivotWithCentralResource.php @@ -0,0 +1,11 @@ + */ + public function getCentralResourceClass(): string; +} diff --git a/src/ResourceSyncing/PivotWithRelation.php b/src/ResourceSyncing/PivotWithRelation.php deleted file mode 100644 index 4936d1fe..00000000 --- a/src/ResourceSyncing/PivotWithRelation.php +++ /dev/null @@ -1,15 +0,0 @@ -users()->getModel(). - */ - public function getRelatedModel(): Model; -} diff --git a/src/ResourceSyncing/ResourceSyncing.php b/src/ResourceSyncing/ResourceSyncing.php index f0d8cc12..272b7bd7 100644 --- a/src/ResourceSyncing/ResourceSyncing.php +++ b/src/ResourceSyncing/ResourceSyncing.php @@ -11,6 +11,7 @@ use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceAttachedToTenant; use Stancl\Tenancy\ResourceSyncing\Events\CentralResourceDetachedFromTenant; +use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted; use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSaved; use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterDeleted; use Stancl\Tenancy\ResourceSyncing\Events\SyncMasterRestored; @@ -19,37 +20,34 @@ trait ResourceSyncing { public static function bootResourceSyncing(): void { - static::saved(function (Syncable&Model $model) { + static::saved(static function (Syncable&Model $model) { if ($model->shouldSync() && ($model->wasRecentlyCreated || $model->wasChanged($model->getSyncedAttributeNames()))) { $model->triggerSyncEvent(); } }); - static::deleting(function (Syncable&Model $model) { - if ($model->shouldSync() && $model instanceof SyncMaster) { + static::deleted(static function (Syncable&Model $model) { + if ($model->shouldSync()) { $model->triggerDeleteEvent(); } }); - static::creating(function (Syncable&Model $model) { - if (! $model->getAttribute($model->getGlobalIdentifierKeyName()) && app()->bound(UniqueIdentifierGenerator::class)) { - $model->setAttribute( - $model->getGlobalIdentifierKeyName(), - app(UniqueIdentifierGenerator::class)->generate($model) - ); + static::creating(static function (Syncable&Model $model) { + if (! $model->getAttribute($model->getGlobalIdentifierKeyName())) { + $model->generateGlobalIdentifierKey(); } }); if (in_array(SoftDeletes::class, class_uses_recursive(static::class), true)) { - static::forceDeleting(function (Syncable&Model $model) { - if ($model->shouldSync() && $model instanceof SyncMaster) { + static::forceDeleting(static function (Syncable&Model $model) { + if ($model->shouldSync()) { $model->triggerDeleteEvent(true); } }); - static::restoring(function (Syncable&Model $model) { - if ($model->shouldSync() && $model instanceof SyncMaster) { - $model->triggerRestoredEvent(); + static::restoring(static function (Syncable&Model $model) { + if ($model instanceof SyncMaster && $model->shouldSync()) { + $model->triggerRestoreEvent(); } }); } @@ -67,9 +65,11 @@ trait ResourceSyncing /** @var SyncMaster&Model $this */ event(new SyncMasterDeleted($this, $forceDelete)); } + + event(new SyncedResourceDeleted($this, tenant(), $forceDelete)); } - public function triggerRestoredEvent(): void + public function triggerRestoreEvent(): void { if ($this instanceof SyncMaster && in_array(SoftDeletes::class, class_uses_recursive($this), true)) { /** @var SyncMaster&Model $this */ @@ -116,8 +116,18 @@ trait ResourceSyncing return 'global_id'; } - public function getGlobalIdentifierKey(): string + public function getGlobalIdentifierKey(): string|int { return $this->getAttribute($this->getGlobalIdentifierKeyName()); } + + protected function generateGlobalIdentifierKey(): void + { + if (! app()->bound(UniqueIdentifierGenerator::class)) return; + + $this->setAttribute( + $this->getGlobalIdentifierKeyName(), + app(UniqueIdentifierGenerator::class)->generate($this), + ); + } } diff --git a/src/ResourceSyncing/SyncMaster.php b/src/ResourceSyncing/SyncMaster.php index 882aeb54..290546cb 100644 --- a/src/ResourceSyncing/SyncMaster.php +++ b/src/ResourceSyncing/SyncMaster.php @@ -25,7 +25,5 @@ interface SyncMaster extends Syncable public function triggerAttachEvent(TenantWithDatabase&Model $tenant): void; - public function triggerDeleteEvent(bool $forceDelete = false): void; - - public function triggerRestoredEvent(): void; + public function triggerRestoreEvent(): void; } diff --git a/src/ResourceSyncing/Syncable.php b/src/ResourceSyncing/Syncable.php index 3d5288f1..c38b02ea 100644 --- a/src/ResourceSyncing/Syncable.php +++ b/src/ResourceSyncing/Syncable.php @@ -16,6 +16,8 @@ interface Syncable public function triggerSyncEvent(): void; + public function triggerDeleteEvent(bool $forceDelete = false): void; + /** * Get the attributes used for creating the *other* model (i.e. tenant if this is the central one, and central if this is the tenant one). * diff --git a/src/ResourceSyncing/TriggerSyncingEvents.php b/src/ResourceSyncing/TriggerSyncingEvents.php index eec1b13d..059eb579 100644 --- a/src/ResourceSyncing/TriggerSyncingEvents.php +++ b/src/ResourceSyncing/TriggerSyncingEvents.php @@ -7,6 +7,7 @@ namespace Stancl\Tenancy\ResourceSyncing; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphPivot; use Illuminate\Database\Eloquent\Relations\Pivot; +use Illuminate\Database\Eloquent\Relations\Relation; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; @@ -20,14 +21,14 @@ trait TriggerSyncingEvents { public static function bootTriggerSyncingEvents(): void { - static::saving(function (self $pivot) { + static::saving(static function (self $pivot) { // Try getting the central resource to see if it is available // If it is not available, throw an exception to interrupt the saving process // And prevent creating a pivot record without a central resource $pivot->getCentralResourceAndTenant(); }); - static::saved(function (self $pivot) { + static::saved(static function (self $pivot) { /** * @var static&Pivot $pivot * @var SyncMaster|null $centralResource @@ -40,7 +41,7 @@ trait TriggerSyncingEvents } }); - static::deleting(function (self $pivot) { + static::deleting(static function (self $pivot) { /** * @var static&Pivot $pivot * @var SyncMaster|null $centralResource @@ -79,13 +80,13 @@ trait TriggerSyncingEvents */ protected function getResourceClass(): string { - /** @var $this&(Pivot|MorphPivot|((Pivot|MorphPivot)&PivotWithRelation)) $this */ - if ($this instanceof PivotWithRelation) { - return $this->getRelatedModel()::class; + /** @var $this&(Pivot|MorphPivot|((Pivot|MorphPivot)&PivotWithCentralResource)) $this */ + if ($this instanceof PivotWithCentralResource) { + return $this->getCentralResourceClass(); } if ($this instanceof MorphPivot) { - return $this->morphClass; + return Relation::getMorphedModel($this->morphClass) ?? $this->morphClass; } throw new CentralResourceNotAvailableInPivotException; diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index a7f27e63..afd20fb6 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -6,6 +6,7 @@ namespace Stancl\Tenancy; use Closure; use Illuminate\Cache\CacheManager; +use Illuminate\Contracts\Container\Container; use Illuminate\Database\Console\Migrations\FreshCommand; use Illuminate\Routing\Events\RouteMatched; use Illuminate\Support\Facades\Event; @@ -119,6 +120,7 @@ class TenancyServiceProvider extends ServiceProvider Commands\MigrateFresh::class, Commands\ClearPendingTenants::class, Commands\CreatePendingTenants::class, + Commands\PurgeImpersonationTokens::class, Commands\CreateUserWithRLSPolicies::class, ]); @@ -156,12 +158,13 @@ class TenancyServiceProvider extends ServiceProvider $this->loadRoutesFrom(__DIR__ . '/../assets/routes.php'); } - $this->app->singleton('globalUrl', function ($app) { + $this->app->singleton('globalUrl', function (Container $app) { if ($app->bound(FilesystemTenancyBootstrapper::class)) { - $instance = clone $app['url']; - $instance->setAssetRoot($app[FilesystemTenancyBootstrapper::class]->originalAssetUrl); + /** @var \Illuminate\Routing\UrlGenerator */ + $instance = clone $app->make('url'); + $instance->useAssetOrigin($app->make(FilesystemTenancyBootstrapper::class)->originalAssetUrl); } else { - $instance = $app['url']; + $instance = $app->make('url'); } return $instance; diff --git a/src/UniqueIdentifierGenerators/ULIDGenerator.php b/src/UniqueIdentifierGenerators/ULIDGenerator.php index 17b62898..d099c824 100644 --- a/src/UniqueIdentifierGenerators/ULIDGenerator.php +++ b/src/UniqueIdentifierGenerators/ULIDGenerator.php @@ -9,7 +9,7 @@ use Illuminate\Support\Str; use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; /** - * Generates a UUID for the tenant key. + * Generates a ULID for the tenant key. */ class ULIDGenerator implements UniqueIdentifierGenerator { diff --git a/src/UniqueIdentifierGenerators/UUIDGenerator.php b/src/UniqueIdentifierGenerators/UUIDGenerator.php index f8bf4b9c..a537b666 100644 --- a/src/UniqueIdentifierGenerators/UUIDGenerator.php +++ b/src/UniqueIdentifierGenerators/UUIDGenerator.php @@ -9,7 +9,7 @@ use Ramsey\Uuid\Uuid; use Stancl\Tenancy\Contracts\UniqueIdentifierGenerator; /** - * Generates a UUID for the tenant key. + * Generates a UUIDv4 for the tenant key. */ class UUIDGenerator implements UniqueIdentifierGenerator { diff --git a/src/UniqueIdentifierGenerators/UUIDv7Generator.php b/src/UniqueIdentifierGenerators/UUIDv7Generator.php new file mode 100644 index 00000000..274b17b8 --- /dev/null +++ b/src/UniqueIdentifierGenerators/UUIDv7Generator.php @@ -0,0 +1,20 @@ +toString(); + } +} diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index bf62f3d0..628b974e 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -201,6 +201,27 @@ test('tenant storage can get deleted after the tenant when DeletingTenant listen expect(File::isDirectory($tenantStoragePath))->toBeFalse(); }); +test('the framework/cache directory is created when storage_path is scoped', function (bool $suffixStoragePath) { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + ], + 'tenancy.filesystem.suffix_storage_path' => $suffixStoragePath + ]); + + $centralStoragePath = storage_path(); + + tenancy()->initialize($tenant = Tenant::create()); + + if ($suffixStoragePath) { + expect(storage_path('framework/cache'))->toBe($centralStoragePath . "/tenant{$tenant->id}/framework/cache"); + expect(is_dir($centralStoragePath . "/tenant{$tenant->id}/framework/cache"))->toBeTrue(); + } else { + expect(storage_path('framework/cache'))->toBe($centralStoragePath . '/framework/cache'); + expect(is_dir($centralStoragePath . "/tenant{$tenant->id}/framework/cache"))->toBeFalse(); + } +})->with([true, false]); + test('scoped disks are scoped per tenant', function () { config([ 'tenancy.bootstrappers' => [ @@ -215,16 +236,24 @@ test('scoped disks are scoped per tenant', function () { $tenant = Tenant::create(); - $storagePath = storage_path() . "/tenant{$tenant->id}"; - - // Resolve scoped_disk before initializing tenancy - Storage::disk('scoped_disk'); + Storage::disk('scoped_disk')->put('foo.txt', 'central'); + expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('central'); + expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('central'); tenancy()->initialize($tenant); - Storage::disk('scoped_disk')->put('foo.txt', 'foo text'); + expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe(null); + Storage::disk('scoped_disk')->put('foo.txt', 'tenant'); + expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('tenant'); + expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('tenant'); tenancy()->end(); - expect(File::exists($storagePath . '/app/public/scoped_disk_prefix/foo.txt'))->toBeTrue(); + expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('central'); + Storage::disk('scoped_disk')->put('foo.txt', 'central2'); + expect(Storage::disk('scoped_disk')->get('foo.txt'))->toBe('central2'); + + expect(file_get_contents(storage_path() . "/app/public/scoped_disk_prefix/foo.txt"))->toBe('central2'); + expect(file_get_contents(storage_path() . "/tenant{$tenant->id}/app/public/scoped_disk_prefix/foo.txt"))->toBe('tenant'); }); + diff --git a/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php b/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php index 39fcc475..647422da 100644 --- a/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php +++ b/tests/Bootstrappers/UrlGeneratorBootstrapperTest.php @@ -18,7 +18,6 @@ use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByRequestDataException; use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData; use Stancl\Tenancy\Resolvers\RequestDataTenantResolver; - use function Stancl\Tenancy\Tests\pest; beforeEach(function () { @@ -80,6 +79,44 @@ test('tenancy url generator can prefix route names passed to the route helper', expect(route('home'))->toBe('http://localhost/central/home'); }); +test('tenancy url generator inherits scheme from original url generator', function() { + config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); + + Route::get('/home', fn () => '')->name('home'); + + // No scheme forced, default is HTTP + expect(app('url')->formatScheme())->toBe('http://'); + + $tenant = Tenant::create(); + + // Force the original URL generator to use HTTPS + app('url')->forceScheme('https'); + + // Original generator uses HTTPS + expect(app('url')->formatScheme())->toBe('https://'); + + // Check that TenancyUrlGenerator inherits the HTTPS scheme + tenancy()->initialize($tenant); + expect(app('url')->formatScheme())->toBe('https://'); // Should inherit HTTPS + expect(route('home'))->toBe('https://localhost/home'); + + tenancy()->end(); + + // After ending tenancy, the original generator should still have the original scheme (HTTPS) + expect(route('home'))->toBe('https://localhost/home'); + + // Use HTTP scheme + app('url')->forceScheme('http'); + expect(app('url')->formatScheme())->toBe('http://'); + + tenancy()->initialize($tenant); + expect(app('url')->formatScheme())->toBe('http://'); // Should inherit scheme (HTTP) + expect(route('home'))->toBe('http://localhost/home'); + + tenancy()->end(); + expect(route('home'))->toBe('http://localhost/home'); +}); + test('path identification route helper behavior', function (bool $addTenantParameterToDefaults, bool $passTenantParameterToRoutes) { config(['tenancy.bootstrappers' => [UrlGeneratorBootstrapper::class]]); diff --git a/tests/CloneActionTest.php b/tests/CloneActionTest.php index 28a8ccd3..ab9c5e9b 100644 --- a/tests/CloneActionTest.php +++ b/tests/CloneActionTest.php @@ -401,3 +401,77 @@ test('tenant parameter addition can be controlled by setting addTenantParameter' $this->withoutExceptionHandling()->get('http://central.localhost/foo')->assertSee('central'); } })->with([true, false]); + +test('existing context flags are removed during cloning', function () { + RouteFacade::get('/foo', fn () => true)->name('foo')->middleware(['clone', 'central']); + RouteFacade::get('/bar', fn () => true)->name('bar')->middleware(['clone', 'universal']); + + $cloneAction = app(CloneRoutesAsTenant::class); + + // Clone foo route + $cloneAction->handle(); + expect(collect(RouteFacade::getRoutes()->get())->map->getName()) + ->toContain('tenant.foo'); + expect(tenancy()->getRouteMiddleware(RouteFacade::getRoutes()->getByName('tenant.foo'))) + ->not()->toContain('central'); + + // Clone bar route + $cloneAction->handle(); + expect(collect(RouteFacade::getRoutes()->get())->map->getName()) + ->toContain('tenant.foo', 'tenant.bar'); + expect(tenancy()->getRouteMiddleware(RouteFacade::getRoutes()->getByName('tenant.foo'))) + ->not()->toContain('universal'); +}); + +test('cloning a route without a prefix or differing domains overrides the original route', function () { + RouteFacade::get('/foo', fn () => true)->name('foo')->middleware(['clone']); + + expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('foo'); + + $cloneAction = CloneRoutesAsTenant::make(); + $cloneAction->cloneRoute('foo') + ->addTenantParameter(false) + ->tenantParameterBeforePrefix(false) + ->handle(); + + expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.foo'); + expect(collect(RouteFacade::getRoutes()->get())->map->getName())->not()->toContain('foo'); +}); + +test('addTenantMiddleware can be used to specify the tenant middleware for the cloned route', function () { + RouteFacade::get('/foo', fn () => true)->name('foo')->middleware(['clone']); + RouteFacade::get('/bar', fn () => true)->name('bar')->middleware(['clone']); + + $cloneAction = app(CloneRoutesAsTenant::class); + + $cloneAction->cloneRoute('foo')->addTenantMiddleware([InitializeTenancyByPath::class])->handle(); + expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.foo'); + $cloned = RouteFacade::getRoutes()->getByName('tenant.foo'); + expect($cloned->uri())->toBe('{tenant}/foo'); + expect($cloned->getName())->toBe('tenant.foo'); + expect(tenancy()->getRouteMiddleware($cloned))->toBe([InitializeTenancyByPath::class]); + + $cloneAction->cloneRoute('bar') + ->addTenantMiddleware([InitializeTenancyByDomain::class]) + ->domain('foo.localhost') + ->addTenantParameter(false) + ->tenantParameterBeforePrefix(false) + ->handle(); + expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.bar'); + $cloned = RouteFacade::getRoutes()->getByName('tenant.bar'); + expect($cloned->uri())->toBe('bar'); + expect($cloned->getName())->toBe('tenant.bar'); + expect($cloned->getDomain())->toBe('foo.localhost'); + expect(tenancy()->getRouteMiddleware($cloned))->toBe([InitializeTenancyByDomain::class]); +}); + +test('cloneRoutes can be used to clone multiple routes', function () { + RouteFacade::get('/foo', fn () => true)->name('foo'); + $bar = RouteFacade::get('/bar', fn () => true)->name('bar'); + RouteFacade::get('/baz', fn () => true)->name('baz'); + + CloneRoutesAsTenant::make()->cloneRoutes(['foo', $bar])->handle(); + expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.foo'); + expect(collect(RouteFacade::getRoutes()->get())->map->getName())->toContain('tenant.bar'); + expect(collect(RouteFacade::getRoutes()->get())->map->getName())->not()->toContain('tenant.baz'); +}); diff --git a/tests/Etc/ResourceSyncing/CentralUser.php b/tests/Etc/ResourceSyncing/CentralUser.php index 1533bd21..ece09550 100644 --- a/tests/Etc/ResourceSyncing/CentralUser.php +++ b/tests/Etc/ResourceSyncing/CentralUser.php @@ -12,6 +12,7 @@ use Stancl\Tenancy\ResourceSyncing\SyncMaster; class CentralUser extends Model implements SyncMaster { use ResourceSyncing, CentralConnection; + protected $guarded = []; public $timestamps = false; diff --git a/tests/Etc/ResourceSyncing/CustomPivot.php b/tests/Etc/ResourceSyncing/CustomPivot.php index 00a019c9..2ffca4c0 100644 --- a/tests/Etc/ResourceSyncing/CustomPivot.php +++ b/tests/Etc/ResourceSyncing/CustomPivot.php @@ -4,20 +4,13 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests\Etc\ResourceSyncing; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsToMany; -use Stancl\Tenancy\ResourceSyncing\PivotWithRelation; +use Stancl\Tenancy\ResourceSyncing\PivotWithCentralResource; use Stancl\Tenancy\ResourceSyncing\TenantPivot; -class CustomPivot extends TenantPivot implements PivotWithRelation +class CustomPivot extends TenantPivot implements PivotWithCentralResource { - public function users(): BelongsToMany + public function getCentralResourceClass(): string { - return $this->belongsToMany(CentralUser::class); - } - - public function getRelatedModel(): Model - { - return $this->users()->getModel(); + return CentralUser::class; } } diff --git a/tests/Etc/synced_resource_migrations/2020_05_11_000002_create_tenant_users_table.php b/tests/Etc/synced_resource_migrations/2020_05_11_000002_create_tenant_users_table.php index 0aafd23c..dcd667a6 100644 --- a/tests/Etc/synced_resource_migrations/2020_05_11_000002_create_tenant_users_table.php +++ b/tests/Etc/synced_resource_migrations/2020_05_11_000002_create_tenant_users_table.php @@ -16,9 +16,6 @@ class CreateTenantUsersTable extends Migration $table->string('global_user_id'); $table->unique(['tenant_id', 'global_user_id']); - - $table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade'); - $table->foreign('global_user_id')->references('global_id')->on('users')->onUpdate('cascade')->onDelete('cascade'); }); } diff --git a/tests/RLS/PolicyTest.php b/tests/RLS/PolicyTest.php index ee9bf5cc..b790343e 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 Stancl\Tenancy\Tenancy; use function Stancl\Tenancy\Tests\pest; beforeEach(function () { @@ -189,6 +190,22 @@ test('rls command recreates policies if the force option is passed', function (s TraitRLSManager::class, ]); +test('dropRLSPolicies only drops RLS policies', function () { + DB::statement('CREATE POLICY "comments_dummy_rls_policy" ON comments USING (true)'); + DB::statement('CREATE POLICY "comments_foo_policy" ON comments USING (true)'); // non-RLS policy + + $policyCount = fn () => count(DB::select("SELECT policyname FROM pg_policies WHERE tablename = 'comments'")); + + expect($policyCount())->toBe(2); + + $removed = Tenancy::dropRLSPolicies('comments'); + + expect($removed)->toBe(1); + + // Only the non-RLS policy remains + expect($policyCount())->toBe(1); +}); + test('queries will stop working when the tenant session variable is not set', function(string $manager, bool $forceRls) { CreateUserWithRLSPolicies::$forceRls = $forceRls; diff --git a/tests/ResourceSyncingTest.php b/tests/ResourceSyncingTest.php index 3250c37a..11a172c5 100644 --- a/tests/ResourceSyncingTest.php +++ b/tests/ResourceSyncingTest.php @@ -46,6 +46,13 @@ use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceSavedInForeignDatabase; use Illuminate\Database\Eloquent\Scope; use Illuminate\Database\QueryException; use function Stancl\Tenancy\Tests\pest; +use Illuminate\Support\Facades\Schema; +use Illuminate\Database\Schema\Blueprint; +use Stancl\Tenancy\Events\TenantDeleted; +use Stancl\Tenancy\ResourceSyncing\Events\SyncedResourceDeleted; +use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteAllTenantMappings; +use Stancl\Tenancy\ResourceSyncing\Listeners\DeleteResourceMapping; +use Illuminate\Database\Eloquent\Relations\Relation; beforeEach(function () { config(['tenancy.bootstrappers' => [ @@ -69,6 +76,7 @@ beforeEach(function () { CreateTenantResource::$shouldQueue = false; DeleteResourceInTenant::$shouldQueue = false; UpdateOrCreateSyncedResource::$scopeGetModelQuery = null; + DeleteAllTenantMappings::$pivotTables = ['tenant_resources' => 'tenant_id']; // Reset global scopes on models (should happen automatically but to make this more explicit) Model::clearBootedModels(); @@ -92,6 +100,7 @@ beforeEach(function () { CentralUser::$creationAttributes = $creationAttributes; Event::listen(SyncedResourceSaved::class, UpdateOrCreateSyncedResource::class); + Event::listen(SyncedResourceDeleted::class, DeleteResourceMapping::class); Event::listen(SyncMasterDeleted::class, DeleteResourcesInTenants::class); Event::listen(SyncMasterRestored::class, RestoreResourcesInTenants::class); Event::listen(CentralResourceAttachedToTenant::class, CreateTenantResource::class); @@ -255,7 +264,7 @@ test('attaching central resources to tenants or vice versa creates synced tenant expect(TenantUser::all())->toHaveCount(0); }); - // Attaching resources to tenants requires using a pivot that implements the PivotWithRelation interface + // Attaching resources to tenants requires using a pivot that implements the PivotWithCentralResource interface $tenant->customPivotUsers()->attach($createCentralUser()); $createCentralUser()->tenants()->attach($tenant); @@ -279,7 +288,7 @@ test('detaching central users from tenants or vice versa force deletes the synce migrateUsersTableForTenants(); if ($attachUserToTenant) { - // Attaching resources to tenants requires using a pivot that implements the PivotWithRelation interface + // Attaching resources to tenants requires using a pivot that implements the PivotWithCentralResource interface $tenant->customPivotUsers()->attach($centralUser); } else { $centralUser->tenants()->attach($tenant); @@ -290,7 +299,7 @@ test('detaching central users from tenants or vice versa force deletes the synce }); if ($attachUserToTenant) { - // Detaching resources from tenants requires using a pivot that implements the PivotWithRelation interface + // Detaching resources from tenants requires using a pivot that implements the PivotWithCentralResource interface $tenant->customPivotUsers()->detach($centralUser); } else { $centralUser->tenants()->detach($tenant); @@ -325,7 +334,7 @@ test('detaching central users from tenants or vice versa force deletes the synce }); if ($attachUserToTenant) { - // Detaching resources from tenants requires using a pivot that implements the PivotWithRelation interface + // Detaching resources from tenants requires using a pivot that implements the PivotWithCentralResource interface $tenant->customPivotUsers()->detach($centralUserWithSoftDeletes); } else { $centralUserWithSoftDeletes->tenants()->detach($tenant); @@ -890,7 +899,54 @@ test('deleting SyncMaster automatically deletes its Syncables', function (bool $ 'basic pivot' => false, ]); -test('tenant pivot records are deleted along with the tenants to which they belong to', function() { +test('tenant pivot records are deleted along with the tenants to which they belong', function (bool $dbLevelOnCascadeDelete, bool $morphPivot) { + [$tenant] = createTenantsAndRunMigrations(); + + if ($morphPivot) { + config(['tenancy.models.tenant' => MorphTenant::class]); + $centralUserModel = BaseCentralUser::class; + + // The default pivot table, no need to configure the listener + $pivotTable = 'tenant_resources'; + } else { + $centralUserModel = CentralUser::class; + + // Custom pivot table + $pivotTable = 'tenant_users'; + } + + if ($dbLevelOnCascadeDelete) { + addTenantIdConstraintToPivot($pivotTable); + } else { + // Event-based cleanup + Event::listen(TenantDeleted::class, DeleteAllTenantMappings::class); + + DeleteAllTenantMappings::$pivotTables = [$pivotTable => 'tenant_id']; + } + + $syncMaster = $centralUserModel::create([ + 'global_id' => 'user', + 'name' => 'Central user', + 'email' => 'central@localhost', + 'password' => 'password', + 'role' => 'user', + ]); + + $syncMaster->tenants()->attach($tenant); + + // Pivot records should be deleted along with the tenant + $tenant->delete(); + + expect(DB::select("SELECT * FROM {$pivotTable} WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0); +})->with([ + 'db level on cascade delete' => true, + 'event-based on cascade delete' => false, +])->with([ + 'polymorphic pivot' => true, + 'basic pivot' => false, +]); + +test('pivot record is automatically deleted with the tenant resource', function() { [$tenant] = createTenantsAndRunMigrations(); $syncMaster = CentralUser::create([ @@ -903,10 +959,54 @@ test('tenant pivot records are deleted along with the tenants to which they belo $syncMaster->tenants()->attach($tenant); - $tenant->delete(); + expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(1); - // Deleting tenant deletes its pivot records - expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->getTenantKey()]))->toHaveCount(0); + $tenant->run(function () { + TenantUser::firstWhere('global_id', 'cascade_user')->delete(); + }); + + // Deleting tenant resource deletes its pivot record + expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(0); + + // The same works with forceDelete + addExtraColumns(true); + + $syncMaster = CentralUserWithSoftDeletes::create([ + 'global_id' => 'force_cascade_user', + 'name' => 'Central user', + 'email' => 'central2@localhost', + 'password' => 'password', + 'role' => 'force_cascade_user', + 'foo' => 'bar', + ]); + + $syncMaster->tenants()->attach($tenant); + + expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(1); + + $tenant->run(function () { + TenantUserWithSoftDeletes::firstWhere('global_id', 'force_cascade_user')->forceDelete(); + }); + + expect(DB::select("SELECT * FROM tenant_users WHERE tenant_id = ?", [$tenant->id]))->toHaveCount(0); +}); + +test('DeleteAllTenantMappings handles incorrect configuration correctly', function() { + Event::listen(TenantDeleted::class, DeleteAllTenantMappings::class); + + [$tenant1, $tenant2] = createTenantsAndRunMigrations(); + + // Existing table, non-existent tenant key column + // The listener should throw an 'unknown column' exception + DeleteAllTenantMappings::$pivotTables = ['tenant_users' => 'non_existent_column']; + + // Should throw an exception when tenant is deleted + expect(fn() => $tenant1->delete())->toThrow(QueryException::class, "Unknown column 'non_existent_column' in 'where clause'"); + + // Non-existent table + DeleteAllTenantMappings::$pivotTables = ['nonexistent_pivot' => 'non_existent_column']; + + expect(fn() => $tenant2->delete())->toThrow(QueryException::class, "Table 'main.nonexistent_pivot' doesn't exist"); }); test('trashed resources are synced correctly', function () { @@ -1265,6 +1365,60 @@ test('global scopes on syncable models can break resource syncing', function () expect($tenant1->run(fn () => TenantUser::first()->name))->toBe('tenant2 user'); }); +test('attach and detach events are handled correctly when using morph maps', function() { + config(['tenancy.models.tenant' => MorphTenant::class]); + [$tenant] = createTenantsAndRunMigrations(); + migrateCompaniesTableForTenants(); + + Relation::morphMap([ + 'users' => BaseCentralUser::class, + 'companies' => CentralCompany::class, + ]); + + $centralUser = BaseCentralUser::create([ + 'global_id' => 'user', + 'name' => 'Central user', + 'email' => 'central@localhost', + 'password' => 'password', + 'role' => 'user', + ]); + + $centralCompany = CentralCompany::create([ + 'global_id' => 'company', + 'name' => 'Central company', + 'email' => 'company@localhost', + ]); + + $tenant->users()->attach($centralUser); + $tenant->companies()->attach($centralCompany); + + // Assert all tenant_resources mappings actually use the configured morph map + expect(DB::table('tenant_resources')->count()) + ->toBe(DB::table('tenant_resources')->whereIn('tenant_resources_type', ['users', 'companies'])->count()); + + tenancy()->initialize($tenant); + + expect(BaseTenantUser::whereGlobalId('user')->first())->not()->toBeNull(); + expect(TenantCompany::whereGlobalId('company')->first())->not()->toBeNull(); + + tenancy()->end(); + + $tenant->users()->detach($centralUser); + $tenant->companies()->detach($centralCompany); + + tenancy()->initialize($tenant); + + expect(BaseTenantUser::whereGlobalId('user')->first())->toBeNull(); + expect(TenantCompany::whereGlobalId('company')->first())->toBeNull(); +}); + +function addTenantIdConstraintToPivot(string $pivotTable): void +{ + Schema::table($pivotTable, function (Blueprint $table) { + $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade'); + }); +} + /** * Create two tenants and run migrations for those tenants. * diff --git a/tests/SessionSeparationTest.php b/tests/SessionSeparationTest.php index 02b018d1..d699bc61 100644 --- a/tests/SessionSeparationTest.php +++ b/tests/SessionSeparationTest.php @@ -56,6 +56,7 @@ test('file sessions are separated', function (bool $scopeSessions) { if ($scopeSessions) { expect($sessionPath())->toBe(storage_path('tenant' . $tenant->getTenantKey() . '/framework/sessions')); + expect(is_dir(storage_path('tenant' . $tenant->getTenantKey() . '/framework/sessions')))->toBeTrue(); } else { expect($sessionPath())->toBe(storage_path('framework/sessions')); } diff --git a/tests/TenantModelTest.php b/tests/TenantModelTest.php index 4c6e77e1..8ee2ae78 100644 --- a/tests/TenantModelTest.php +++ b/tests/TenantModelTest.php @@ -23,6 +23,7 @@ use Stancl\Tenancy\UniqueIdentifierGenerators\RandomHexGenerator; use Stancl\Tenancy\UniqueIdentifierGenerators\RandomIntGenerator; use Stancl\Tenancy\UniqueIdentifierGenerators\RandomStringGenerator; use Stancl\Tenancy\UniqueIdentifierGenerators\ULIDGenerator; +use Stancl\Tenancy\UniqueIdentifierGenerators\UUIDv7Generator; use function Stancl\Tenancy\Tests\pest; @@ -94,6 +95,20 @@ test('ulid ids are supported', function () { expect($tenant2->id > $tenant1->id)->toBeTrue(); }); +test('uuidv7 ids are supported', function () { + app()->bind(UniqueIdentifierGenerator::class, UUIDv7Generator::class); + + $tenant1 = Tenant::create(); + expect($tenant1->id)->toBeString(); + expect(strlen($tenant1->id))->toBe(36); + + $tenant2 = Tenant::create(); + expect($tenant2->id)->toBeString(); + expect(strlen($tenant2->id))->toBe(36); + + expect($tenant2->id > $tenant1->id)->toBeTrue(); +}); + test('hex ids are supported', function () { app()->bind(UniqueIdentifierGenerator::class, RandomHexGenerator::class); diff --git a/tests/TenantUserImpersonationTest.php b/tests/TenantUserImpersonationTest.php index 48fbe691..ea679357 100644 --- a/tests/TenantUserImpersonationTest.php +++ b/tests/TenantUserImpersonationTest.php @@ -26,6 +26,7 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Exceptions\StatefulGuardRequiredException; use function Stancl\Tenancy\Tests\pest; +use Symfony\Component\HttpKernel\Exception\HttpException; beforeEach(function () { pest()->artisan('migrate', [ @@ -294,6 +295,117 @@ test('impersonation tokens can be created only with stateful guards', function ( ->toBeInstanceOf(ImpersonationToken::class); }); +test('expired tokens are cleaned up before aborting', function () { + $tenant = Tenant::create(); + migrateTenants(); + + $user = $tenant->run(function () { + return ImpersonationUser::create([ + 'name' => 'foo', + 'email' => 'foo@bar', + 'password' => bcrypt('password'), + ]); + }); + + $token = tenancy()->impersonate($tenant, $user->id, '/dashboard'); + + // Make the token expired + $token->update([ + 'created_at' => Carbon::now()->subSeconds(100), + ]); + + expect(ImpersonationToken::find($token->token))->not()->toBeNull(); + + tenancy()->initialize($tenant); + + // Try to use the expired token - should clean up and abort + expect(fn() => UserImpersonation::makeResponse($token->token)) + ->toThrow(HttpException::class); // Abort with 403 + + expect(ImpersonationToken::find($token->token))->toBeNull(); +}); + +test('tokens are cleaned up when in wrong tenant context before aborting', function () { + $tenant1 = Tenant::create(); + $tenant2 = Tenant::create(); + + migrateTenants(); + + $user = $tenant1->run(function () { + return ImpersonationUser::create([ + 'name' => 'foo', + 'email' => 'foo@bar', + 'password' => bcrypt('password'), + ]); + }); + + $token = tenancy()->impersonate($tenant1, $user->id, '/dashboard'); + + expect(ImpersonationToken::find($token->token))->not->toBeNull(); + + tenancy()->initialize($tenant2); + + // Try to use the token in wrong tenant context - should clean up and abort + expect(fn() => UserImpersonation::makeResponse($token->token)) + ->toThrow(HttpException::class); // Abort with 403 + + expect(ImpersonationToken::find($token->token))->toBeNull(); +}); + +test('expired impersonation tokens can be cleaned up using a command', function () { + $tenant = Tenant::create(); + migrateTenants(); + $user = $tenant->run(function () { + return ImpersonationUser::create([ + 'name' => 'foo', + 'email' => 'foo@bar', + 'password' => bcrypt('password'), + ]); + }); + + // Create tokens + $oldToken = tenancy()->impersonate($tenant, $user->id, '/dashboard'); + $anotherOldToken = tenancy()->impersonate($tenant, $user->id, '/dashboard'); + $activeToken = tenancy()->impersonate($tenant, $user->id, '/dashboard'); + + // Make two of the tokens expired by updating their created_at + $oldToken->update([ + 'created_at' => Carbon::now()->subSeconds(UserImpersonation::$ttl + 10), + ]); + + $anotherOldToken->update([ + 'created_at' => Carbon::now()->subSeconds(UserImpersonation::$ttl + 10), + ]); + + // All tokens exist + expect(ImpersonationToken::find($activeToken->token))->not()->toBeNull(); + expect(ImpersonationToken::find($oldToken->token))->not()->toBeNull(); + expect(ImpersonationToken::find($anotherOldToken->token))->not()->toBeNull(); + + pest()->artisan('tenants:purge-impersonation-tokens') + ->assertExitCode(0) + ->expectsOutputToContain('2 expired impersonation tokens deleted'); + + // The expired tokens were deleted + expect(ImpersonationToken::find($oldToken->token))->toBeNull(); + expect(ImpersonationToken::find($anotherOldToken->token))->toBeNull(); + // The active token still exists + expect(ImpersonationToken::find($activeToken->token))->not()->toBeNull(); + + // Update the active token to make it expired according to the default ttl (60s) + $activeToken->update([ + 'created_at' => Carbon::now()->subSeconds(70), + ]); + + // With ttl set to 80s, the active token should not be deleted (token is only considered expired if older than 80s) + UserImpersonation::$ttl = 80; + pest()->artisan('tenants:purge-impersonation-tokens') + ->assertExitCode(0) + ->expectsOutputToContain('0 expired impersonation tokens deleted'); + + expect(ImpersonationToken::find($activeToken->token))->not()->toBeNull(); +}); + function migrateTenants() { pest()->artisan('tenants:migrate')->assertExitCode(0);