diff --git a/.gitattributes b/.gitattributes index 3736c54d..513bd7da 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,6 +10,7 @@ /.nvim.lua export-ignore /art export-ignore /coverage export-ignore +/CLAUDE.md export-ignore /CONTRIBUTING.md export-ignore /INTERNAL.md export-ignore /SUPPORT.md export-ignore @@ -19,6 +20,7 @@ /Dockerfile export-ignore /doctum export-ignore /phpunit.xml export-ignore +/static_properties.nu export-ignore /t export-ignore /test export-ignore /tests export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91699f08..48b1ffd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: matrix: include: - laravel: "^12.0" - php: "8.4" + - laravel: "^13.0" steps: - name: Checkout diff --git a/.nvim.lua b/.nvim.lua index c9b5d9cb..5e7c5249 100644 --- a/.nvim.lua +++ b/.nvim.lua @@ -1,4 +1,3 @@ -- The tailwindcss LSP doesn't play nice with testbench due to the recursive --- `vendor` symlink in `testbench-core/laravel/vendor`, so we nuke its setup method here. --- This prevents the setup() call in neovim config from starting the client (or doing anything at all). -require('lspconfig').tailwindcss.setup = function () end +-- `vendor` symlink in `testbench-core/laravel/vendor`, so we disable it here. +vim.lsp.enable('tailwindcss', false) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee451d20..6e6055af 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,3 +49,9 @@ Use `composer phpstan` to run our phpstan suite. Create `.env` with `PROJECT_PATH=/full/path/to/this/directory`. Configure a Docker-based interpreter for tests (with exec, not run). 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.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/Dockerfile b/Dockerfile index fb1620cd..2123e727 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,8 @@ ARG PHP_VERSION=8.4 - FROM php:${PHP_VERSION}-cli-bookworm SHELL ["/bin/bash", "-c"] -RUN apt-get update && apt-get install -y --no-install-recommends \ - git unzip libzip-dev libicu-dev libmemcached-dev zlib1g-dev libssl-dev sqlite3 libsqlite3-dev libpq-dev mariadb-client +RUN apt-get update RUN apt-get install -y gnupg2 \ && curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \ @@ -12,18 +10,40 @@ RUN apt-get install -y gnupg2 \ && apt-get update \ && ACCEPT_EULA=Y apt-get install -y unixodbc-dev msodbcsql18 +RUN apt-get install -y --no-install-recommends \ + git unzip libzip-dev libicu-dev libmemcached-dev zlib1g-dev libssl-dev sqlite3 libsqlite3-dev libpq-dev mariadb-client + RUN apt autoremove && apt clean RUN pecl install apcu && docker-php-ext-enable apcu RUN pecl install pcov && docker-php-ext-enable pcov -RUN pecl install redis && docker-php-ext-enable redis +RUN pecl install redis-6.3.0RC1 && docker-php-ext-enable redis RUN pecl install memcached && docker-php-ext-enable memcached -RUN pecl install pdo_sqlsrv && docker-php-ext-enable pdo_sqlsrv RUN docker-php-ext-install zip && docker-php-ext-enable zip RUN docker-php-ext-install intl && docker-php-ext-enable intl RUN docker-php-ext-install pdo_mysql && docker-php-ext-enable pdo_mysql RUN docker-php-ext-install pdo_pgsql && docker-php-ext-enable pdo_pgsql +RUN if [[ "${PHP_VERSION}" == *"8.5"* ]]; then \ + mkdir sqlsrv \ + && cd sqlsrv \ + && pecl download pdo_sqlsrv-5.12.0 \ + && tar xzf pdo_sqlsrv-5.12.0.tgz \ + && cd pdo_sqlsrv-5.12.0 \ + && sed -i 's/= dbh->error_mode;/= static_cast(dbh->error_mode);/' pdo_dbh.cpp \ + && sed -i 's/zval_ptr_dtor( &dbh->query_stmt_zval );/OBJ_RELEASE(dbh->query_stmt_obj);dbh->query_stmt_obj=NULL;/' php_pdo_sqlsrv_int.h \ + && phpize \ + && ./configure --with-php-config=$(which php-config) \ + && make -j$(nproc) \ + && cp modules/pdo_sqlsrv.so $(php -r 'echo ini_get("extension_dir");') \ + && cd / \ + && rm -rf /sqlsrv; \ +else \ + pecl install pdo_sqlsrv; \ +fi + +RUN docker-php-ext-enable pdo_sqlsrv + RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" RUN echo "apc.enable_cli=1" >> "$PHP_INI_DIR/php.ini" 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 84787c0d..1cb358de 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,13 +13,24 @@ 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. + * + * Documentation: https://tenancyforlaravel.com + * + * We can sustainably develop Tenancy for Laravel thanks to our sponsors. + * Big thanks to everyone listed here: https://github.com/sponsors/stancl + * + * You can also support us, and save time, by purchasing these products: + * Exclusive content for sponsors: https://sponsors.tenancyforlaravel.com + * Multi-Tenant SaaS boilerplate: https://portal.archte.ch/boilerplate + * Multi-Tenant Laravel in Production e-book: https://portal.archte.ch/book + * + * All of these products can also be accessed at https://portal.archte.ch + */ class TenancyServiceProvider extends ServiceProvider { // By default, no namespace is used to support the callable array syntax. @@ -42,7 +52,7 @@ class TenancyServiceProvider extends ServiceProvider // Provision API keys, create S3 buckets, anything you want! ])->send(function (Events\TenantCreated $event) { return $event->tenant; - })->shouldBeQueued(false), // `false` by default, but you likely want to make this `true` in production. + })->shouldBeQueued(false), // Listeners\CreateTenantStorage::class, ], @@ -65,7 +75,9 @@ class TenancyServiceProvider extends ServiceProvider Jobs\DeleteDatabase::class, ])->send(function (Events\TenantDeleted $event) { return $event->tenant; - })->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production. + })->shouldBeQueued(false), + + // ResourceSyncing\Listeners\DeleteAllTenantMappings::class, ], Events\TenantMaintenanceModeEnabled::class => [], @@ -114,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, ], @@ -126,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 @@ -150,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, '.')) { @@ -185,7 +206,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()]); // }); } @@ -206,7 +227,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')); } @@ -227,24 +248,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 ba503aad..2a3a07e2 100644 --- a/assets/config.php +++ b/assets/config.php @@ -8,6 +8,21 @@ use Stancl\Tenancy\Bootstrappers; use Stancl\Tenancy\Enums\RouteMode; use Stancl\Tenancy\UniqueIdentifierGenerators; +/** + * Tenancy for Laravel. + * + * Documentation: https://tenancyforlaravel.com + * + * We can sustainably develop Tenancy for Laravel thanks to our sponsors. + * Big thanks to everyone listed here: https://github.com/sponsors/stancl + * + * You can also support us, and save time, by purchasing these products: + * Exclusive content for sponsors: https://sponsors.tenancyforlaravel.com + * Multi-Tenant SaaS boilerplate: https://portal.archte.ch/boilerplate + * Multi-Tenant Laravel in Production e-book: https://portal.archte.ch/book + * + * All of these products can also be accessed at https://portal.archte.ch + */ return [ /** * Configuration for the models used by Tenancy. @@ -33,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 @@ -47,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(), ], /** @@ -155,6 +172,7 @@ return [ Bootstrappers\DatabaseTenancyBootstrapper::class, Bootstrappers\CacheTenancyBootstrapper::class, // Bootstrappers\CacheTagsBootstrapper::class, // Alternative to CacheTenancyBootstrapper + // Bootstrappers\DatabaseCacheBootstrapper::class, // Separates cache by DB rather than by prefix, must run after DatabaseTenancyBootstrapper Bootstrappers\FilesystemTenancyBootstrapper::class, Bootstrappers\QueueTenancyBootstrapper::class, // Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed @@ -163,9 +181,10 @@ return [ Bootstrappers\DatabaseSessionBootstrapper::class, // Configurable bootstrappers + // Bootstrappers\TenantConfigBootstrapper::class, // Bootstrappers\RootUrlBootstrapper::class, // Bootstrappers\UrlGeneratorBootstrapper::class, - // Bootstrappers\MailConfigBootstrapper::class, // Note: Queueing mail requires using QueueTenancyBootstrapper with $forceRefresh set to true + // Bootstrappers\MailConfigBootstrapper::class, // Bootstrappers\BroadcastingConfigBootstrapper::class, // Bootstrappers\BroadcastChannelPrefixBootstrapper::class, @@ -294,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. ], @@ -404,7 +423,6 @@ return [ 'features' => [ // Stancl\Tenancy\Features\UserImpersonation::class, // Stancl\Tenancy\Features\TelescopeTags::class, - // Stancl\Tenancy\Features\TenantConfig::class, // Stancl\Tenancy\Features\CrossDomainRedirect::class, // Stancl\Tenancy\Features\ViteBundler::class, // Stancl\Tenancy\Features\DisallowSqliteAttach::class, @@ -428,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' => [ /** @@ -437,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/composer.json b/composer.json index 2eab8837..180cbaab 100644 --- a/composer.json +++ b/composer.json @@ -18,22 +18,23 @@ "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", - "pestphp/pest": "^3.0", - "larastan/larastan": "^3.0" + "pestphp/pest": "^4.0", + "larastan/larastan": "^3.0", + "league/flysystem-path-prefixing": "^3.0" }, "autoload": { "psr-4": { @@ -65,13 +66,16 @@ "docker-restart": "docker compose down && docker compose up -d", "docker-rebuild": [ "Composer\\Config::disableProcessTimeout", - "PHP_VERSION=8.4 docker compose up -d --no-deps --build" + "docker compose up -d --no-deps --build" ], "docker-rebuild-with-xdebug": [ "Composer\\Config::disableProcessTimeout", - "PHP_VERSION=8.4 XDEBUG_ENABLED=true docker compose up -d --no-deps --build" + "XDEBUG_ENABLED=true docker compose up -d --no-deps --build" ], "docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml", + "php85-patch": [ + "php -r '$file=\"vendor/orchestra/testbench-core/laravel/config/database.php\"; file_put_contents($file, str_replace(\"PDO::MYSQL_ATTR_SSL_CA\", \"Pdo\\\\Mysql::ATTR_SSL_CA\", file_get_contents($file)));'" + ], "testbench-unlink": "rm ./vendor/orchestra/testbench-core/laravel/vendor", "testbench-link": "ln -s /var/www/html/vendor ./vendor/orchestra/testbench-core/laravel/vendor", "testbench-repair": "mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/sessions && mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/views && mkdir -p ./vendor/orchestra/testbench-core/laravel/storage/framework/cache", diff --git a/docker-compose.yml b/docker-compose.yml index 2d7a6e9f..70a68019 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,7 @@ services: build: context: . args: + PHP_VERSION: ${PHP_VERSION:-8.4} XDEBUG_ENABLED: ${XDEBUG_ENABLED:-false} depends_on: mysql: @@ -80,7 +81,7 @@ services: image: mcr.microsoft.com/mssql/server:2022-latest environment: - ACCEPT_EULA=Y - - SA_PASSWORD=P@ssword # todo reuse env from above + - SA_PASSWORD=P@ssword # must be the same as TENANCY_TEST_SQLSRV_PASSWORD healthcheck: # https://github.com/Microsoft/mssql-docker/issues/133#issuecomment-1995615432 test: timeout 2 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/1433' interval: 10s 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 ec60d880..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,26 +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) { @@ -117,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); @@ -142,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 { @@ -150,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; @@ -158,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; @@ -169,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; @@ -177,6 +229,13 @@ class CloneRoutesAsTenant return $this; } + public function tenantParameterBeforePrefix(bool $tenantParameterBeforePrefix): static + { + $this->tenantParameterBeforePrefix = $tenantParameterBeforePrefix; + + return $this; + } + /** Clone an individual route. */ public function cloneRoute(Route|string $route): static { @@ -185,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 @@ -226,7 +297,13 @@ class CloneRoutesAsTenant $action->put('middleware', $middleware); if ($this->addTenantParameter) { - $action->put('prefix', $prefix . '/{' . PathTenantResolver::tenantParameterName() . '}'); + $tenantParameter = '{' . PathTenantResolver::tenantParameterName() . '}'; + + $newPrefix = $this->tenantParameterBeforePrefix + ? $tenantParameter . '/' . $prefix + : $prefix . '/' . $tenantParameter; + + $action->put('prefix', $newPrefix); } /** @var Route $newRoute */ @@ -244,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 a66aa9f8..97bd7d24 100644 --- a/src/Bootstrappers/CacheTenancyBootstrapper.php +++ b/src/Bootstrappers/CacheTenancyBootstrapper.php @@ -99,11 +99,14 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper { $names = $this->config->get('tenancy.cache.stores'); - if ( - $this->config->get('tenancy.cache.scope_sessions', true) && - in_array($this->config->get('session.driver'), ['redis', 'memcached', 'dynamodb', 'apc'], true) - ) { - $names[] = $this->getSessionCacheStoreName(); + 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)) { + 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(); + } } $names = array_unique($names); @@ -112,6 +115,7 @@ class CacheTenancyBootstrapper implements TenancyBootstrapper $store = $this->config->get("cache.stores.{$name}"); if ($store === null || $store['driver'] === 'file') { + // 'file' stores are ignored here and instead handled by FilesystemTenancyBootstrapper return false; } 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/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index 8cc8127b..7f0bce0a 100644 --- a/src/Bootstrappers/DatabaseTenancyBootstrapper.php +++ b/src/Bootstrappers/DatabaseTenancyBootstrapper.php @@ -32,10 +32,10 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper throw new Exception('The template connection must NOT have URL defined. Specify the connection using individual parts instead of a database URL.'); } - // Better debugging, but breaks cached lookup in prod - if (app()->environment('local') || app()->environment('testing')) { // todo@docs mention this change in v4 upgrade guide https://github.com/archtechx/tenancy/pull/945#issuecomment-1268206149 + // Better debugging, but breaks cached lookup, so we disable this in prod + if (app()->environment('local') || app()->environment('testing')) { $database = $tenant->database()->getName(); - if (! $tenant->database()->manager()->databaseExists($database)) { // todo@samuel does this call correctly use the host connection? + if (! $tenant->database()->manager()->databaseExists($database)) { throw new TenantDatabaseDoesNotExistException($database); } } diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index d5088c5c..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,22 +99,33 @@ 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' => ''])); } } protected function forgetDisks(): void { - Storage::forgetDisk($this->app['config']['tenancy.filesystem.disks']); + $tenantDisks = $this->app['config']['tenancy.filesystem.disks']; + $scopedDisks = []; + + foreach ($this->app['config']['filesystems.disks'] as $name => $disk) { + if (isset($disk['driver'], $disk['disk']) + && $disk['driver'] === 'scoped' + && in_array($disk['disk'], $tenantDisks, true)) { + $scopedDisks[] = $name; + } + } + + Storage::forgetDisk(array_merge($tenantDisks, $scopedDisks)); } protected function diskRoot(string $disk, Tenant|false $tenant): void @@ -211,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/TenantConfigBootstrapper.php b/src/Bootstrappers/TenantConfigBootstrapper.php new file mode 100644 index 00000000..98ec2cb0 --- /dev/null +++ b/src/Bootstrappers/TenantConfigBootstrapper.php @@ -0,0 +1,54 @@ + */ + public static array $storageToConfigMap = [ + // 'paypal_api_key' => 'services.paypal.api_key', + ]; + + public function __construct( + protected Repository $config, + ) {} + + public function bootstrap(Tenant $tenant): void + { + foreach (static::$storageToConfigMap as $storageKey => $configKey) { + /** @var Tenant&Model $tenant */ + $override = Arr::get($tenant, $storageKey); + + if (! is_null($override)) { + if (is_array($configKey)) { + foreach ($configKey as $key) { + $this->originalConfig[$key] = $this->originalConfig[$key] ?? $this->config->get($key); + + $this->config->set($key, $override); + } + } else { + $this->originalConfig[$configKey] = $this->originalConfig[$configKey] ?? $this->config->get($configKey); + + $this->config->set($configKey, $override); + } + } + } + } + + public function revert(): void + { + foreach ($this->originalConfig as $key => $value) { + $this->config->set($key, $value); + } + } +} 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/CreatePendingTenants.php b/src/Commands/CreatePendingTenants.php index c37b8bd7..11bdae63 100644 --- a/src/Commands/CreatePendingTenants.php +++ b/src/Commands/CreatePendingTenants.php @@ -8,7 +8,7 @@ use Illuminate\Console\Command; class CreatePendingTenants extends Command { - protected $signature = 'tenants:pending-create {--count= : The number of pending tenants to be created}'; + protected $signature = 'tenants:pending-create {--count= : The number of pending tenants to maintain}'; protected $description = 'Create pending tenants.'; diff --git a/src/Commands/CreateUserWithRLSPolicies.php b/src/Commands/CreateUserWithRLSPolicies.php index 420df935..3998dc48 100644 --- a/src/Commands/CreateUserWithRLSPolicies.php +++ b/src/Commands/CreateUserWithRLSPolicies.php @@ -81,12 +81,19 @@ class CreateUserWithRLSPolicies extends Command #[\SensitiveParameter] string $password, ): DatabaseConfig { + // This is a bit of a hack. We want to use our existing createUser() logic. + // That logic needs a DatabaseConfig instance. However, we aren't really working + // with any specific tenant here. We also *don't* want to use anything tenant-specific + // here. We are creating the SHARED "RLS user". Therefore, we need a custom DatabaseConfig + // instance for this purpose. The easiest way to do that is to grab an empty Tenant model + // (we use TenantWithDatabase in RLS) and manually create the host connection, just like + // DatabaseConfig::manager() would. We don't call that method since we want to use our existing + // PermissionControlledPostgreSQLSchemaManager $manager instance, rather than the "tenant's manager". + /** @var TenantWithDatabase $tenantModel */ $tenantModel = tenancy()->model(); - // Use a temporary DatabaseConfig instance to set the host connection $temporaryDbConfig = $tenantModel->database(); - $temporaryDbConfig->purgeHostConnection(); $tenantHostConnectionName = $temporaryDbConfig->getTenantHostConnectionName(); diff --git a/src/Commands/Install.php b/src/Commands/Install.php index 9f6a9c31..8521de5a 100644 --- a/src/Commands/Install.php +++ b/src/Commands/Install.php @@ -6,6 +6,7 @@ namespace Stancl\Tenancy\Commands; use Closure; use Illuminate\Console\Command; +use Illuminate\Support\Facades\Process; class Install extends Command { @@ -128,14 +129,27 @@ class Install extends Command public function askForSupport(): void { if ($this->components->confirm('Would you like to show your support by starring the project on GitHub?', true)) { - if (PHP_OS_FAMILY === 'Darwin') { - exec('open https://github.com/archtechx/tenancy'); + $ghVersion = Process::run('gh --version'); + $starred = false; + + // Make sure the `gh` binary is the actual GitHub CLI and not an unrelated tool + if ($ghVersion->successful() && str_contains($ghVersion->output(), 'https://github.com/cli/cli')) { + $starRequest = Process::run('gh api -X PUT user/starred/archtechx/tenancy'); + $starred = $starRequest->successful(); } - if (PHP_OS_FAMILY === 'Windows') { - exec('start https://github.com/archtechx/tenancy'); - } - if (PHP_OS_FAMILY === 'Linux') { - exec('xdg-open https://github.com/archtechx/tenancy'); + + if ($starred) { + $this->components->success('Repository starred via gh CLI, thank you!'); + } else { + if (PHP_OS_FAMILY === 'Darwin') { + exec('open https://github.com/archtechx/tenancy'); + } + if (PHP_OS_FAMILY === 'Windows') { + exec('start https://github.com/archtechx/tenancy'); + } + if (PHP_OS_FAMILY === 'Linux') { + exec('xdg-open https://github.com/archtechx/tenancy'); + } } } } 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/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/DealsWithRouteContexts.php b/src/Concerns/DealsWithRouteContexts.php index 9a9b0871..fdf49cb1 100644 --- a/src/Concerns/DealsWithRouteContexts.php +++ b/src/Concerns/DealsWithRouteContexts.php @@ -14,10 +14,9 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\Route as RouteFacade; use Stancl\Tenancy\Enums\RouteMode; -// todo@refactor move this logic to some dedicated static class? - /** * @mixin \Stancl\Tenancy\Tenancy + * @internal The public methods in this trait should not be understood to be a public stable API. */ trait DealsWithRouteContexts { diff --git a/src/Concerns/HasTenantOptions.php b/src/Concerns/HasTenantOptions.php index 8cd105ba..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'], + 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/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/Contracts/Feature.php b/src/Contracts/Feature.php index 74289981..25363cf5 100644 --- a/src/Contracts/Feature.php +++ b/src/Contracts/Feature.php @@ -4,10 +4,8 @@ declare(strict_types=1); namespace Stancl\Tenancy\Contracts; -use Stancl\Tenancy\Tenancy; - /** Additional features, like Telescope tags and tenant redirects. */ interface Feature { - public function bootstrap(Tenancy $tenancy): void; + public function bootstrap(): void; } 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 f26a7ff8..5c0f50fb 100644 --- a/src/Database/Concerns/BelongsToTenant.php +++ b/src/Database/Concerns/BelongsToTenant.php @@ -17,12 +17,26 @@ trait BelongsToTenant { use FillsCurrentTenant; + /** + * @return BelongsTo<\Illuminate\Database\Eloquent\Model&\Stancl\Tenancy\Contracts\Tenant, $this> + */ public function tenant(): BelongsTo { return $this->belongsTo(config('tenancy.models.tenant'), Tenancy::tenantKeyColumn()); } 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/Database/Concerns/HasDatabase.php b/src/Database/Concerns/HasDatabase.php index e1f4a55f..9388f168 100644 --- a/src/Database/Concerns/HasDatabase.php +++ b/src/Database/Concerns/HasDatabase.php @@ -28,7 +28,8 @@ trait HasDatabase } if ($key === $this->internalPrefix() . 'db_connection') { - // Remove DB connection because that's not used here + // Remove DB connection because that's not used for the connection *contents*. + // Instead the code uses getInternal('db_connection'). continue; } diff --git a/src/Database/Concerns/HasDomains.php b/src/Database/Concerns/HasDomains.php index ae3aed42..1c185a27 100644 --- a/src/Database/Concerns/HasDomains.php +++ b/src/Database/Concerns/HasDomains.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\Concerns; +use Illuminate\Database\Eloquent\Relations\HasMany; use Stancl\Tenancy\Contracts\Domain; use Stancl\Tenancy\Tenancy; @@ -14,7 +15,10 @@ use Stancl\Tenancy\Tenancy; */ trait HasDomains { - public function domains() + /** + * @return HasMany<\Illuminate\Database\Eloquent\Model&\Stancl\Tenancy\Contracts\Domain, $this> + */ + public function domains(): HasMany { return $this->hasMany(config('tenancy.models.domain'), Tenancy::tenantKeyColumn()); } diff --git a/src/Database/Concerns/HasPending.php b/src/Database/Concerns/HasPending.php index ffb35f0c..0a572680 100644 --- a/src/Database/Concerns/HasPending.php +++ b/src/Database/Concerns/HasPending.php @@ -6,14 +6,13 @@ namespace Stancl\Tenancy\Database\Concerns; use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\DB; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Events\CreatingPendingTenant; use Stancl\Tenancy\Events\PendingTenantCreated; use Stancl\Tenancy\Events\PendingTenantPulled; use Stancl\Tenancy\Events\PullingPendingTenant; -// todo consider adding a method that sets pending_since to null — to flag tenants as not-pending - /** * @property ?Carbon $pending_since * @@ -50,46 +49,75 @@ trait HasPending */ public static function createPending(array $attributes = []): Model&Tenant { - $tenant = static::create($attributes); + $tenant = null; - event(new CreatingPendingTenant($tenant)); - - // Update the pending_since value only after the tenant is created so it's - // Not marked as pending until finishing running the migrations, seeders, etc. - $tenant->update([ - 'pending_since' => now()->timestamp, - ]); + try { + $tenant = static::create(array_merge(static::getPendingAttributes($attributes), $attributes)); + event(new CreatingPendingTenant($tenant)); + } finally { + // Update the pending_since value only after the tenant is created so it's + // not marked as pending until after migrations, seeders, etc are run. + $tenant?->update([ + 'pending_since' => now()->timestamp, + ]); + } event(new PendingTenantCreated($tenant)); return $tenant; } - /** Pull a pending tenant. */ - public static function pullPending(): Model&Tenant + /** + * Attributes to be set when a pending tenant is initially created. + * + * @param array $attributes The attributes passed to createPending() (will be merged with the returned array) + * @return array + */ + public static function getPendingAttributes(array $attributes): array + { + return []; + } + + /** + * Pull a pending tenant from the pool or create a new one if the pool is empty. + * + * @param array $attributes The attributes to set on the tenant. + */ + public static function pullPending(array $attributes = []): Model&Tenant { /** @var Model&Tenant $pendingTenant */ - $pendingTenant = static::pullPendingFromPool(true); + $pendingTenant = static::pullPendingFromPool(true, $attributes); return $pendingTenant; } - /** Try to pull a tenant from the pool of pending tenants. */ - public static function pullPendingFromPool(bool $firstOrCreate = true, array $attributes = []): ?Tenant + /** + * Try to pull a tenant from the pool of pending tenants. + * + * @param bool $firstOrCreate If true, a tenant will be *created* if the pool is empty. Otherwise null is returned. + * @param array $attributes The attributes to set on the tenant. + */ + public static function pullPendingFromPool(bool $firstOrCreate = false, array $attributes = []): ?Tenant { - /** @var (Model&Tenant)|null $tenant */ - $tenant = static::onlyPending()->first(); + $tenant = DB::transaction(function () use ($attributes): ?Tenant { + /** @var (Model&Tenant)|null $tenant */ + $tenant = static::onlyPending()->first(); + + if ($tenant !== null) { + event(new PullingPendingTenant($tenant)); + $tenant->update(array_merge($attributes, [ + 'pending_since' => null, + ])); + } + + return $tenant; + }); if ($tenant === null) { return $firstOrCreate ? static::create($attributes) : null; } - event(new PullingPendingTenant($tenant)); - - $tenant->update(array_merge($attributes, [ - 'pending_since' => null, - ])); - + // Only triggered if a tenant that was pulled from the pool is returned event(new PendingTenantPulled($tenant)); return $tenant; 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/Database/DatabaseConfig.php b/src/Database/DatabaseConfig.php index 9a876d2d..bd167761 100644 --- a/src/Database/DatabaseConfig.php +++ b/src/Database/DatabaseConfig.php @@ -13,7 +13,6 @@ use Stancl\Tenancy\Database\Contracts\TenantWithDatabase as Tenant; use Stancl\Tenancy\Database\Exceptions\DatabaseManagerNotRegisteredException; use Stancl\Tenancy\Database\Exceptions\NoConnectionSetException; -// todo@dbRefactor refactor host connection logic to make customizing the host connection easier class DatabaseConfig { /** The tenant whose database we're dealing with. */ @@ -115,7 +114,7 @@ class DatabaseConfig { $this->tenant->setInternal('db_name', $this->getName()); - if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) { + if ($this->managerForDriver($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) { $this->tenant->setInternal('db_username', $this->getUsername() ?? (static::$usernameGenerator)($this->tenant, $this)); $this->tenant->setInternal('db_password', $this->getPassword() ?? (static::$passwordGenerator)($this->tenant, $this)); } @@ -137,7 +136,9 @@ class DatabaseConfig } if ($template = config('tenancy.database.template_tenant_connection')) { - return is_array($template) ? array_merge($this->getCentralConnection(), $template) : config("database.connections.{$template}"); + return is_array($template) + ? array_merge($this->getCentralConnection(), $template) + : config("database.connections.{$template}"); } return $this->getCentralConnection(); @@ -176,10 +177,10 @@ class DatabaseConfig $config = $this->tenantConfig; $templateConnection = $this->getTemplateConnection(); - if ($this->connectionDriverManager($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) { - // We're removing the username and password because user with these credentials is not created yet - // If you need to provide username and password when using PermissionControlledMySQLDatabaseManager, - // consider creating a new connection and use it as `tenancy_db_connection` tenant config key + if ($this->managerForDriver($this->getTemplateConnectionDriver()) instanceof Contracts\ManagesDatabaseUsers) { + // We remove the username and password because the user with these credentials is not yet created. + // If you need to provide a username and a password when using a permission controlled database manager, + // consider creating a new connection and use it as `tenancy_db_connection`. unset($config['username'], $config['password']); } @@ -191,7 +192,7 @@ class DatabaseConfig } /** - * Purge the previous tenant connection before opening it for another tenant. + * Purge the previous host connection before opening it for another tenant. */ public function purgeHostConnection(): void { @@ -199,20 +200,20 @@ class DatabaseConfig } /** - * Get the TenantDatabaseManager for this tenant's connection. + * Get the TenantDatabaseManager for this tenant's host connection. * * @throws NoConnectionSetException|DatabaseManagerNotRegisteredException */ public function manager(): Contracts\TenantDatabaseManager { - // Laravel caches the previous PDO connection, so we purge it to be able to change the connection details + // Laravel persists the PDO connection, so we purge it to be able to change the connection details $this->purgeHostConnection(); // Create the tenant host connection config $tenantHostConnectionName = $this->getTenantHostConnectionName(); config(["database.connections.{$tenantHostConnectionName}" => $this->hostConnection()]); - $manager = $this->connectionDriverManager(config("database.connections.{$tenantHostConnectionName}.driver")); + $manager = $this->managerForDriver(config("database.connections.{$tenantHostConnectionName}.driver")); if ($manager instanceof Contracts\StatefulTenantDatabaseManager) { $manager->setConnection($tenantHostConnectionName); @@ -222,12 +223,11 @@ class DatabaseConfig } /** - * todo@name come up with a better name - * Get database manager class from the given connection config's driver. + * Get the TenantDatabaseManager for a given database driver. * * @throws DatabaseManagerNotRegisteredException */ - protected function connectionDriverManager(string $driver): Contracts\TenantDatabaseManager + protected function managerForDriver(string $driver): Contracts\TenantDatabaseManager { $databaseManagers = config('tenancy.database.managers'); diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php index 8ea3e631..47ec11a2 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledMySQLDatabaseManager.php @@ -23,7 +23,6 @@ class PermissionControlledMySQLDatabaseManager extends MySQLDatabaseManager impl { $database = $databaseConfig->getName(); $username = $databaseConfig->getUsername(); - $hostname = $databaseConfig->connection()['host']; $password = $databaseConfig->getPassword(); $this->connection()->statement("CREATE USER `{$username}`@`%` IDENTIFIED BY '{$password}'"); diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php index 933740ed..b528d4e3 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php @@ -30,10 +30,6 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage $tables = $this->connection()->select("SELECT table_name FROM information_schema.tables WHERE table_schema = '{$schema}' AND table_type = 'BASE TABLE'"); // 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/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index 64b96fc1..295cf304 100644 --- a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php +++ b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Stancl\Tenancy\Database\TenantDatabaseManagers; -use AssertionError; use Closure; use Illuminate\Database\Eloquent\Model; use PDO; @@ -15,17 +14,12 @@ use Throwable; class SQLiteDatabaseManager implements TenantDatabaseManager { /** - * SQLite Database path without ending slash. + * SQLite database directory path. + * + * Defaults to database_path(). */ public static string|null $path = null; - /** - * Should the WAL journal mode be used for newly created databases. - * - * @see https://www.sqlite.org/pragma.html#pragma_journal_mode - */ - public static bool $WAL = true; - /* * If this isn't null, a connection to the tenant DB will be created * and passed to the provided closure, for the purpose of keeping the @@ -84,30 +78,13 @@ class SQLiteDatabaseManager implements TenantDatabaseManager // or creating a closure holding a reference to it and passing that to register_shutdown_function(). $name = '_tenancy_inmemory_' . $tenant->getTenantKey(); - $tenant->update(['tenancy_db_name' => "file:$name?mode=memory&cache=shared"]); + $tenant->setInternal('db_name', "file:$name?mode=memory&cache=shared"); + $tenant->save(); return true; } - try { - if (file_put_contents($path = $this->getPath($name), '') === false) { - return false; - } - - if (static::$WAL) { - $pdo = new PDO('sqlite:' . $path); - $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - - // @phpstan-ignore-next-line method.nonObject - assert($pdo->query('pragma journal_mode = wal')->fetch(PDO::FETCH_ASSOC)['journal_mode'] === 'wal', 'Unable to set journal mode to wal.'); - } - - return true; - } catch (AssertionError $e) { - throw $e; - } catch (Throwable) { - return false; - } + return file_put_contents($this->getPath($name), '') !== false; } public function deleteDatabase(TenantWithDatabase $tenant): bool @@ -122,8 +99,16 @@ class SQLiteDatabaseManager implements TenantDatabaseManager return true; } + $path = $this->getPath($name); + try { - return unlink($this->getPath($name)); + unlink($path . '-journal'); + unlink($path . '-wal'); + unlink($path . '-shm'); + } catch (Throwable) {} + + try { + return unlink($path); } catch (Throwable) { return false; } @@ -150,15 +135,10 @@ class SQLiteDatabaseManager implements TenantDatabaseManager return $baseConfig; } - public function setConnection(string $connection): void - { - // - } - public function getPath(string $name): string { if (static::$path) { - return static::$path . DIRECTORY_SEPARATOR . $name; + return rtrim(static::$path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $name; } return database_path($name); diff --git a/src/Events/PullingPendingTenant.php b/src/Events/PullingPendingTenant.php index f823bb17..26d0433d 100644 --- a/src/Events/PullingPendingTenant.php +++ b/src/Events/PullingPendingTenant.php @@ -4,4 +4,9 @@ declare(strict_types=1); namespace Stancl\Tenancy\Events; +/** + * Importantly, listeners for this event should not switch tenancy context. + * + * This event is fired from within a database transaction. + */ class PullingPendingTenant extends Contracts\TenantEvent {} diff --git a/src/Features/CrossDomainRedirect.php b/src/Features/CrossDomainRedirect.php index a48be6ea..57786274 100644 --- a/src/Features/CrossDomainRedirect.php +++ b/src/Features/CrossDomainRedirect.php @@ -6,11 +6,10 @@ namespace Stancl\Tenancy\Features; use Illuminate\Http\RedirectResponse; use Stancl\Tenancy\Contracts\Feature; -use Stancl\Tenancy\Tenancy; class CrossDomainRedirect implements Feature { - public function bootstrap(Tenancy $tenancy): void + public function bootstrap(): void { RedirectResponse::macro('domain', function (string $domain) { /** @var RedirectResponse $this */ diff --git a/src/Features/DisallowSqliteAttach.php b/src/Features/DisallowSqliteAttach.php index f428a051..5cbfbf50 100644 --- a/src/Features/DisallowSqliteAttach.php +++ b/src/Features/DisallowSqliteAttach.php @@ -4,25 +4,22 @@ declare(strict_types=1); namespace Stancl\Tenancy\Features; -use Exception; use Illuminate\Database\Connectors\ConnectionFactory; use Illuminate\Database\SQLiteConnection; use Illuminate\Support\Facades\DB; use PDO; use Stancl\Tenancy\Contracts\Feature; -use Stancl\Tenancy\Tenancy; class DisallowSqliteAttach implements Feature { - protected static bool|null $loadExtensionSupported = null; public static string|false|null $extensionPath = null; - public function bootstrap(Tenancy $tenancy): void + public function bootstrap(): void { // Handle any already resolved connections foreach (DB::getConnections() as $connection) { if ($connection instanceof SQLiteConnection) { - if (! $this->loadExtension($connection->getPdo())) { + if (! $this->setAuthorizer($connection->getPdo())) { return; } } @@ -31,42 +28,54 @@ class DisallowSqliteAttach implements Feature // Apply the change to all sqlite connections resolved in the future DB::extend('sqlite', function ($config, $name) { $conn = app(ConnectionFactory::class)->make($config, $name); - $this->loadExtension($conn->getPdo()); + $this->setAuthorizer($conn->getPdo()); return $conn; }); } - protected function loadExtension(PDO $pdo): bool + protected function setAuthorizer(PDO $pdo): bool { - if (static::$loadExtensionSupported === null) { - static::$loadExtensionSupported = method_exists($pdo, 'loadExtension'); + if (PHP_VERSION_ID >= 80500) { + $this->setNativeAuthorizer($pdo); + + return true; } - if (static::$loadExtensionSupported === false) { - return false; - } - if (static::$extensionPath === false) { - return false; - } + static $loadExtensionSupported = method_exists($pdo, 'loadExtension'); + + if ((! $loadExtensionSupported) || + (static::$extensionPath === false) || + (PHP_INT_SIZE !== 8) + ) return false; $suffix = match (PHP_OS_FAMILY) { 'Linux' => 'so', 'Windows' => 'dll', 'Darwin' => 'dylib', - default => throw new Exception("The DisallowSqliteAttach feature doesn't support your operating system: " . PHP_OS_FAMILY), + default => 'error', }; + if ($suffix === 'error') return false; + $arch = php_uname('m'); $arm = $arch === 'aarch64' || $arch === 'arm64'; static::$extensionPath ??= realpath(base_path('vendor/stancl/tenancy/extensions/lib/' . ($arm ? 'arm/' : '') . 'noattach.' . $suffix)); - if (static::$extensionPath === false) { - return false; - } + if (static::$extensionPath === false) return false; $pdo->loadExtension(static::$extensionPath); // @phpstan-ignore method.notFound return true; } + + protected function setNativeAuthorizer(PDO $pdo): void + { + // @phpstan-ignore method.notFound + $pdo->setAuthorizer(static function (int $action): int { + return $action === 24 // SQLITE_ATTACH + ? PDO\Sqlite::DENY + : PDO\Sqlite::OK; + }); + } } diff --git a/src/Features/TelescopeTags.php b/src/Features/TelescopeTags.php index 0a580d23..225049df 100644 --- a/src/Features/TelescopeTags.php +++ b/src/Features/TelescopeTags.php @@ -7,11 +7,10 @@ namespace Stancl\Tenancy\Features; use Laravel\Telescope\IncomingEntry; use Laravel\Telescope\Telescope; use Stancl\Tenancy\Contracts\Feature; -use Stancl\Tenancy\Tenancy; class TelescopeTags implements Feature { - public function bootstrap(Tenancy $tenancy): void + public function bootstrap(): void { if (! class_exists(Telescope::class)) { return; diff --git a/src/Features/TenantConfig.php b/src/Features/TenantConfig.php index 5bc84060..3e248cb6 100644 --- a/src/Features/TenantConfig.php +++ b/src/Features/TenantConfig.php @@ -12,8 +12,10 @@ use Stancl\Tenancy\Contracts\Feature; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Events\RevertedToCentralContext; use Stancl\Tenancy\Events\TenancyBootstrapped; -use Stancl\Tenancy\Tenancy; +// todo@release remove this class + +/** @deprecated Use the TenantConfigBootstrapper instead. */ class TenantConfig implements Feature { public array $originalConfig = []; @@ -27,7 +29,7 @@ class TenantConfig implements Feature protected Repository $config, ) {} - public function bootstrap(Tenancy $tenancy): void + public function bootstrap(): void { Event::listen(TenancyBootstrapped::class, function (TenancyBootstrapped $event) { /** @var Tenant $tenant */ diff --git a/src/Features/UserImpersonation.php b/src/Features/UserImpersonation.php index 3db563a4..d286b8ba 100644 --- a/src/Features/UserImpersonation.php +++ b/src/Features/UserImpersonation.php @@ -17,9 +17,9 @@ class UserImpersonation implements Feature /** The lifespan of impersonation tokens (in seconds). */ public static int $ttl = 60; - public function bootstrap(Tenancy $tenancy): void + public function bootstrap(): void { - $tenancy->macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): Model { + 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, @@ -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/Features/ViteBundler.php b/src/Features/ViteBundler.php index 987187c7..003984f7 100644 --- a/src/Features/ViteBundler.php +++ b/src/Features/ViteBundler.php @@ -7,19 +7,14 @@ namespace Stancl\Tenancy\Features; use Illuminate\Foundation\Application; use Illuminate\Support\Facades\Vite; use Stancl\Tenancy\Contracts\Feature; -use Stancl\Tenancy\Tenancy; class ViteBundler implements Feature { - /** @var Application */ - protected $app; + public function __construct( + protected Application $app, + ) {} - public function __construct(Application $app) - { - $this->app = $app; - } - - public function bootstrap(Tenancy $tenancy): void + public function bootstrap(): void { Vite::createAssetPathsUsing(function ($path, $secure = null) { return global_asset($path); diff --git a/src/Jobs/CreateDatabase.php b/src/Jobs/CreateDatabase.php index 94132b7e..decdb445 100644 --- a/src/Jobs/CreateDatabase.php +++ b/src/Jobs/CreateDatabase.php @@ -40,7 +40,8 @@ class CreateDatabase implements ShouldQueue try { $databaseManager->ensureTenantCanBeCreated($this->tenant); - $this->tenant->database()->manager()->createDatabase($this->tenant); + $databaseCreated = $this->tenant->database()->manager()->createDatabase($this->tenant); + assert($databaseCreated); event(new DatabaseCreated($this->tenant)); } catch (TenantDatabaseAlreadyExistsException | TenantDatabaseUserAlreadyExistsException $e) { 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/Listeners/ForgetTenantParameter.php b/src/Listeners/ForgetTenantParameter.php index d159b967..46bf5690 100644 --- a/src/Listeners/ForgetTenantParameter.php +++ b/src/Listeners/ForgetTenantParameter.php @@ -8,8 +8,6 @@ use Illuminate\Routing\Events\RouteMatched; use Stancl\Tenancy\Enums\RouteMode; use Stancl\Tenancy\Resolvers\PathTenantResolver; -// todo@earlyIdReview - /** * Conditionally removes the tenant parameter from matched routes when using kernel path identification. * diff --git a/src/Middleware/CheckTenantForMaintenanceMode.php b/src/Middleware/CheckTenantForMaintenanceMode.php index 58fcd184..4b399e13 100644 --- a/src/Middleware/CheckTenantForMaintenanceMode.php +++ b/src/Middleware/CheckTenantForMaintenanceMode.php @@ -6,7 +6,6 @@ namespace Stancl\Tenancy\Middleware; use Closure; use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode; -use Stancl\Tenancy\Exceptions\TenancyNotInitializedException; use Symfony\Component\HttpKernel\Exception\HttpException; class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode @@ -14,7 +13,13 @@ class CheckTenantForMaintenanceMode extends CheckForMaintenanceMode public function handle($request, Closure $next) { if (! tenant()) { - throw new TenancyNotInitializedException; + // If there's no tenant, there's no tenant to check for maintenance mode. + // Since tenant identification middleware has higher priority than this + // middleware, a missing tenant would have already lead to request termination. + // (And even if priority were misconfigured, the request would simply get + // terminated *after* this middleware.) + // Therefore, we are likely on a universal route, in central context. + return $next($request); } if (tenant('maintenance_mode')) { diff --git a/src/Middleware/PreventAccessFromUnwantedDomains.php b/src/Middleware/PreventAccessFromUnwantedDomains.php index 91ebff05..cdfa3b2c 100644 --- a/src/Middleware/PreventAccessFromUnwantedDomains.php +++ b/src/Middleware/PreventAccessFromUnwantedDomains.php @@ -11,8 +11,6 @@ use Stancl\Tenancy\Concerns\UsableWithEarlyIdentification; use Stancl\Tenancy\Enums\RouteMode; /** - * todo@name come up with a better name. - * * Prevents accessing central domains in the tenant context/tenant domains in the central context. * The access isn't prevented if the request is trying to access a route flagged as 'universal', * or if this middleware should be skipped. @@ -68,9 +66,11 @@ class PreventAccessFromUnwantedDomains return in_array($request->getHost(), config('tenancy.identification.central_domains'), true); } - // todo@samuel technically not an identification middleware but probably ok to keep this here public function requestHasTenant(Request $request): bool { + // This middleware is special in that it's not an identification middleware + // but still uses some logic from UsableWithEarlyIdentification, so we just + // need to implement this method here. It doesn't matter what it returns. return false; } } diff --git a/src/Overrides/TenancyUrlGenerator.php b/src/Overrides/TenancyUrlGenerator.php index ed14d5b5..f7ed9a84 100644 --- a/src/Overrides/TenancyUrlGenerator.php +++ b/src/Overrides/TenancyUrlGenerator.php @@ -110,7 +110,7 @@ class TenancyUrlGenerator extends UrlGenerator */ public function route($name, $parameters = [], $absolute = true) { - if ($name instanceof BackedEnum && ! is_string($name = $name->value)) { // @phpstan-ignore function.impossibleType + if ($name instanceof BackedEnum && ! is_string($name = $name->value)) { throw new InvalidArgumentException('Attribute [name] expects a string backed enum.'); } @@ -125,11 +125,19 @@ class TenancyUrlGenerator extends UrlGenerator */ public function temporarySignedRoute($name, $expiration, $parameters = [], $absolute = true) { - if ($name instanceof BackedEnum && ! is_string($name = $name->value)) { // @phpstan-ignore function.impossibleType + if ($name instanceof BackedEnum && ! is_string($name = $name->value)) { 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/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..7799e9ba 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 */ @@ -105,6 +105,9 @@ trait ResourceSyncing return true; } + /** + * @return BelongsToMany<\Illuminate\Database\Eloquent\Model&\Stancl\Tenancy\Database\Contracts\TenantWithDatabase, $this> + */ public function tenants(): BelongsToMany { return $this->morphToMany(config('tenancy.models.tenant'), 'tenant_resources', 'tenant_resources', 'resource_global_id', 'tenant_id', $this->getGlobalIdentifierKeyName()) @@ -116,8 +119,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/Tenancy.php b/src/Tenancy.php index 66173cba..a2271bed 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -11,6 +11,7 @@ use Illuminate\Foundation\Bus\PendingDispatch; use Illuminate\Support\Traits\Macroable; use Stancl\Tenancy\Concerns\DealsWithRouteContexts; use Stancl\Tenancy\Concerns\ManagesRLSPolicies; +use Stancl\Tenancy\Contracts\Feature; use Stancl\Tenancy\Contracts\TenancyBootstrapper; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByIdException; @@ -24,11 +25,15 @@ class Tenancy */ public Tenant|null $tenant = null; - // todo@docblock + /** + * Custom callback for providing a list of bootstrappers to use. + * When this is null, config('tenancy.bootstrappers') is used. + * @var ?Closure(): list + */ public ?Closure $getBootstrappersUsing = null; /** Is tenancy fully initialized? */ - public bool $initialized = false; // todo@docs document the difference between $tenant being set and $initialized being true (e.g. end of initialize() method) + public bool $initialized = false; /** * List of relations to eager load when fetching a tenant via tenancy()->find(). @@ -36,7 +41,7 @@ class Tenancy public static array $findWith = []; /** - * A list of bootstrappers that have been initialized. + * List of bootstrappers that have been initialized. * * This is used when reverting tenancy, mainly if an exception * occurs during bootstrapping, to ensure we don't revert @@ -49,6 +54,23 @@ class Tenancy */ public array $initializedBootstrappers = []; + /** + * List of features that have been bootstrapped. + * + * Since features may be bootstrapped multiple times during + * the request cycle (in TSP::boot() and any other times the user calls + * bootstrapFeatures()), we keep track of which features have already + * been bootstrapped so we do not bootstrap them again. Features are + * bootstrapped once and irreversible. + * + * The main point of this is that some features *need* to be bootstrapped + * very early (see #949), so we bootstrap them directly in TSP, but we + * also need the ability to *change* which features are used at runtime + * (mainly tests of this package) and bootstrap features again after making + * changes to config('tenancy.features'). + */ + protected array $bootstrappedFeatures = []; + /** Initialize tenancy for the passed tenant. */ public function initialize(Tenant|int|string $tenant): void { @@ -117,10 +139,12 @@ class Tenancy return; } + // We fire both of these events before unsetting tenant so that listeners + // to both events can access the current tenant. Having separate events + // still has value as it's consistent with our other events and provides + // more granularity for event listeners, e.g. for ensuring something runs + // before standard TenancyEnded listeners such as RevertToCentralContext. event(new Events\EndingTenancy($this)); - - // todo@samuel find a way to refactor these two methods - event(new Events\TenancyEnded($this)); $this->tenant = null; @@ -128,15 +152,35 @@ class Tenancy $this->initialized = false; } + /** + * End tenancy and initialize it again for the current tenant. + * + * This can be helpful when changing "dependencies" of bootstrappers such as + * attributes of the current tenant that are only read once, during bootstrap(). + * + * If tenancy is not initialized, this method is a no-op. + */ + public function reinitialize(): void + { + if ($this->tenant === null) { + return; + } + + $tenant = $this->tenant; + $this->end(); + + $this->initialize($tenant); + } + /** @return TenancyBootstrapper[] */ public function getBootstrappers(): array { - // If no callback for getting bootstrappers is set, we just return all of them. - $resolve = $this->getBootstrappersUsing ?? function (Tenant $tenant) { + // If no callback for getting bootstrappers is set, we return the ones in config. + $resolve = $this->getBootstrappersUsing ?? function (?Tenant $tenant) { return config('tenancy.bootstrappers'); }; - // Here We instantiate the bootstrappers and return them. + // Here we instantiate the bootstrappers and return them. return array_map('app', $resolve($this->tenant)); } @@ -150,6 +194,26 @@ class Tenancy return in_array($bootstrapper, static::getBootstrappers(), true); } + /** + * Bootstrap configured Tenancy features. + * + * Normally, features are bootstrapped directly in TSP::boot(). However, if + * new features are enabled at runtime (e.g. during tests), this method may + * be called to bootstrap new features. It's idempotent and keeps track of + * which features have already been bootstrapped. Keep in mind that feature + * bootstrapping is irreversible. + */ + public function bootstrapFeatures(): void + { + foreach (config('tenancy.features') ?? [] as $feature) { + /** @var class-string $feature */ + if (! in_array($feature, $this->bootstrappedFeatures)) { + app($feature)->bootstrap(); + $this->bootstrappedFeatures[] = $feature; + } + } + } + /** * @return Builder */ diff --git a/src/TenancyServiceProvider.php b/src/TenancyServiceProvider.php index 557306b2..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; @@ -40,15 +41,6 @@ class TenancyServiceProvider extends ServiceProvider // Make sure Tenancy is stateful. $this->app->singleton(Tenancy::class); - // Make sure features are bootstrapped as soon as Tenancy is instantiated. - $this->app->extend(Tenancy::class, function (Tenancy $tenancy) { - foreach ($this->app['config']['tenancy.features'] ?? [] as $feature) { - $this->app[$feature]->bootstrap($tenancy); - } - - return $tenancy; - }); - // Make it possible to inject the current tenant by type hinting the Tenant contract. $this->app->bind(Tenant::class, function ($app) { return $app[Tenancy::class]->tenant; @@ -128,6 +120,7 @@ class TenancyServiceProvider extends ServiceProvider Commands\MigrateFresh::class, Commands\ClearPendingTenants::class, Commands\CreatePendingTenants::class, + Commands\PurgeImpersonationTokens::class, Commands\CreateUserWithRLSPolicies::class, ]); @@ -165,17 +158,23 @@ 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; }); + // Bootstrap features that are already enabled in the config. + // If more features are enabled at runtime, this method may be called + // multiple times, it keeps track of which features have already been bootstrapped. + $this->app->make(Tenancy::class)->bootstrapFeatures(); + Route::middlewareGroup('clone', []); Route::middlewareGroup('universal', []); Route::middlewareGroup('tenant', []); 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/src/helpers.php b/src/helpers.php index c8f5c9b3..0b812e65 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -36,7 +36,12 @@ if (! function_exists('tenant')) { } if (! function_exists('tenant_asset')) { - // todo@docblock + /** + * Generate a URL to an asset in tenant storage. + * + * If app.asset_url is set, this helper suffixes that URL before appending the asset path. + * If it is not set, the stancl.tenancy.asset route is used. + */ function tenant_asset(string|null $asset): string { if ($assetUrl = config('app.asset_url')) { diff --git a/static_properties.nu b/static_properties.nu new file mode 100755 index 00000000..8b35e84e --- /dev/null +++ b/static_properties.nu @@ -0,0 +1,103 @@ +#!/usr/bin/env nu + +# Utility for exporting static properties used for configuration +def main []: nothing -> string { + "See --help for subcommands" +} + +# The current number of config static properties in the codebase +def "main count" [...paths: string]: nothing -> int { + props ...$paths | length +} + +# Available static properties, grouped by file, rendered as a table +def "main table" [...paths: string]: nothing -> string { + props ...$paths | table --theme rounded --expand +} + +# Plain text version of available static properties +def "main plain" [...paths: string]: nothing -> string { + props ...$paths + | each { $"// File: ($in.file)\n($in.props | str join "\n\n")"} + | str join "\n//------------------------------------------------------------\n\n" +} + +# Expressive Code formatting of available static properties, used in docs +def "main docs" [...paths: string]: nothing -> string { + (("{/* GENERATED_BEGIN */}\n" + (props ...$paths + | each { update props { each { if ($in | str ends-with "= [") { + $"($in)/* ... */];" + } else { $in }}}} + | each { $"```php /public static .*$/\n// File: ($in.file)\n($in.props | str join "\n\n")\n```"} + | str join "\n\n")) + + "\n{/* GENERATED_END */}") +} + +def props [...paths: string]: nothing -> table> { + ls ...(if ($paths | length) > 0 { + ($paths | each {|path| + if ($path | str contains "*") { + # already a glob expr + $path | into glob + } else if ($path | str ends-with ".php") { + # src/Foo/Bar.php + $path + } else { + # just 'src/Foo' passed + $"($path)/**/*.php" | into glob + } + }) + } else { + [("src/**/*.php" | into glob)] + }) + | each { { name: $in.name, content: (open $in.name) } } + | find -nr 'public static (?!.*function)' + | par-each {|file| + let lines = $file.content | lines + mut docblock_start = 0 + mut docblock_end = 0 + mut props = [] + for line in ($lines | enumerate) { + if ($line.item | str contains "/**") { + $docblock_start = $line.index + } + + if ($line.item | str contains "@internal") { + # Docblocks with @internal are ignored + $docblock_start = 0 + $docblock_end = 0 + } + + if ($line.item | str contains "*/") { + $docblock_end = $line.index + } + + if ( + ( + ( # Valid (non-internal) docblock + $docblock_start != 0 and + $docblock_end != 0 and + $docblock_end == ($line.index - 1) + ) or + ( # No docblock + $line.index != 0 and + (($lines | get ($line.index - 1)) | str index-of "*/") == -1 + ) + ) and + ($line.item | str trim | str index-of "public static") == 0 and + ($line.item | str trim | str index-of "public static function") == -1 + ) { + if ($docblock_start == 0) or ($docblock_end == 0) or ($docblock_end != ($line.index - 1)) { + $docblock_start = $line.index + $docblock_end = $line.index + } + $props = $props | append ($lines | slice $docblock_start..$line.index | each { str trim } | str join "\n") + $docblock_start = 0 + $docblock_end = 0 + } + } + + {file: $file.name, props: $props} + } + | where ($it.props | length) > 0 +} diff --git a/t b/t index 36d2d391..5b2c1f26 100755 --- a/t +++ b/t @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash if [[ "${CLAUDECODE}" != "1" ]]; then COLOR_FLAG="--colors=always" diff --git a/test b/test index 0df8f63e..b63dbdb9 100755 --- a/test +++ b/test @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash if [[ "${CLAUDECODE}" != "1" ]]; then COLOR_FLAG="--colors=always" diff --git a/tests/ActionTest.php b/tests/ActionTest.php index 63b6b377..93db0eb3 100644 --- a/tests/ActionTest.php +++ b/tests/ActionTest.php @@ -18,8 +18,6 @@ beforeEach(function () { Event::listen(TenancyEnded::class, RevertToCentralContext::class); }); -// todo@move move these to be in the same file as the other tests from this PR (#909) rather than generic "action tests" - test('create storage symlinks action works', function() { config([ 'tenancy.bootstrappers' => [ diff --git a/tests/AutomaticModeTest.php b/tests/AutomaticModeTest.php index fbeb06fc..599d14d9 100644 --- a/tests/AutomaticModeTest.php +++ b/tests/AutomaticModeTest.php @@ -103,6 +103,33 @@ test('central helper doesnt change tenancy state when called in central context' expect(tenant())->toBeNull(); }); +test('reinitialize method does nothing in the central context', function () { + expect(tenancy()->initialized)->toBe(false); + expect(fn () => tenancy()->reinitialize())->not()->toThrow(\Throwable::class); + expect(tenancy()->initialized)->toBe(false); +}); + +test('reinitialize method runs bootstrappers again for the current tenant', function () { + config(['tenancy.bootstrappers' => [ + ReinitBootstrapper::class, + ]]); + + tenancy()->initialize($tenant = Tenant::create(['reinit_bootstrapper_key' => 'foo'])); + + expect(tenant()->getKey())->toBe($tenant->getKey()); + expect(app('tenancy_reinit_bootstrapper_key'))->toBe('foo'); + + $tenant->update(['reinit_bootstrapper_key' => 'bar']); + + // Unchanged until we reinitialize... + expect(app('tenancy_reinit_bootstrapper_key'))->toBe('foo'); + + tenancy()->reinitialize(); + + expect(tenant()->getKey())->toBe($tenant->getKey()); + expect(app('tenancy_reinit_bootstrapper_key'))->toBe('bar'); +}); + class MyBootstrapper implements TenancyBootstrapper { public function bootstrap(\Stancl\Tenancy\Contracts\Tenant $tenant): void @@ -115,3 +142,16 @@ class MyBootstrapper implements TenancyBootstrapper app()->instance('tenancy_ended', true); } } + +class ReinitBootstrapper implements TenancyBootstrapper +{ + public function bootstrap(\Stancl\Tenancy\Contracts\Tenant $tenant): void + { + app()->instance('tenancy_reinit_bootstrapper_key', $tenant->getAttribute('reinit_bootstrapper_key')); + } + + public function revert(): void + { + app()->instance('tenancy_reinit_bootstrapper_key', null); + } +} diff --git a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index d6b6a231..628b974e 100644 --- a/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php +++ b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php @@ -115,8 +115,6 @@ test('files can get fetched using the storage url', function() { test('storage_path helper does not change if suffix_storage_path is off', function() { $originalStoragePath = storage_path(); - // todo@tests https://github.com/tenancy-for-laravel/v4/pull/44#issue-2228530362 - config([ 'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class], 'tenancy.filesystem.suffix_storage_path' => false, @@ -202,3 +200,60 @@ 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' => [ + FilesystemTenancyBootstrapper::class, + ], + 'filesystems.disks.scoped_disk' => [ + 'driver' => 'scoped', + 'disk' => 'public', + 'prefix' => 'scoped_disk_prefix', + ], + ]); + + $tenant = Tenant::create(); + + 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); + + 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(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..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; @@ -18,7 +19,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 () { @@ -26,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; }); @@ -80,6 +84,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]]); @@ -322,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/CloneActionTest.php b/tests/CloneActionTest.php index 656ad327..ab9c5e9b 100644 --- a/tests/CloneActionTest.php +++ b/tests/CloneActionTest.php @@ -172,7 +172,7 @@ test('the clone action can clone specific routes either using name or route inst false, ]); -test('the clone action prefixes already prefixed routes correctly', function () { +test('the clone action prefixes already prefixed routes correctly', function (bool $tenantParameterBeforePrefix) { $routes = [ RouteFacade::get('/home', fn () => true) ->middleware(['clone']) @@ -195,7 +195,12 @@ test('the clone action prefixes already prefixed routes correctly', function () ->prefix('prefix/'), ]; - app(CloneRoutesAsTenant::class)->handle(); + $cloneAction = app(CloneRoutesAsTenant::class); + $cloneAction + ->tenantParameterBeforePrefix($tenantParameterBeforePrefix) + ->handle(); + + $expectedPrefix = $tenantParameterBeforePrefix ? '{tenant}/prefix' : 'prefix/{tenant}'; $clonedRoutes = [ RouteFacade::getRoutes()->getByName('tenant.home'), @@ -206,9 +211,10 @@ test('the clone action prefixes already prefixed routes correctly', function () // The cloned route is prefixed correctly foreach ($clonedRoutes as $key => $route) { - expect($route->getPrefix())->toBe("prefix/{tenant}"); + expect($route->getPrefix())->toBe($expectedPrefix); $clonedRouteUrl = route($route->getName(), ['tenant' => $tenant = Tenant::create()]); + $expectedPrefixInUrl = $tenantParameterBeforePrefix ? "{$tenant->id}/prefix" : "prefix/{$tenant->id}"; expect($clonedRouteUrl) // Original prefix does not occur in the cloned route's URL @@ -216,14 +222,14 @@ test('the clone action prefixes already prefixed routes correctly', function () ->not()->toContain("//prefix") ->not()->toContain("prefix//") // Instead, the route is prefixed correctly - ->toBe("http://localhost/prefix/{$tenant->id}/{$routes[$key]->getName()}"); + ->toBe("http://localhost/{$expectedPrefixInUrl}/{$routes[$key]->getName()}"); // The cloned route is accessible pest()->get($clonedRouteUrl)->assertOk(); } -}); +})->with([true, false]); -test('clone action trims trailing slashes from prefixes given to nested route groups', function () { +test('clone action trims trailing slashes from prefixes given to nested route groups', function (bool $tenantParameterBeforePrefix) { RouteFacade::prefix('prefix')->group(function () { RouteFacade::prefix('')->group(function () { // This issue seems to only happen when there's a group with a prefix, then a group with an empty prefix, and then a / route @@ -237,7 +243,10 @@ test('clone action trims trailing slashes from prefixes given to nested route gr }); }); - app(CloneRoutesAsTenant::class)->handle(); + $cloneAction = app(CloneRoutesAsTenant::class); + $cloneAction + ->tenantParameterBeforePrefix($tenantParameterBeforePrefix) + ->handle(); $clonedLandingUrl = route('tenant.landing', ['tenant' => $tenant = Tenant::create()]); $clonedHomeRouteUrl = route('tenant.home', ['tenant' => $tenant]); @@ -245,17 +254,20 @@ test('clone action trims trailing slashes from prefixes given to nested route gr $landingRoute = RouteFacade::getRoutes()->getByName('tenant.landing'); $homeRoute = RouteFacade::getRoutes()->getByName('tenant.home'); - expect($landingRoute->uri())->toBe('prefix/{tenant}'); - expect($homeRoute->uri())->toBe('prefix/{tenant}/home'); + $expectedPrefix = $tenantParameterBeforePrefix ? '{tenant}/prefix' : 'prefix/{tenant}'; + $expectedPrefixInUrl = $tenantParameterBeforePrefix ? "{$tenant->id}/prefix" : "prefix/{$tenant->id}"; + + expect($landingRoute->uri())->toBe($expectedPrefix); + expect($homeRoute->uri())->toBe("{$expectedPrefix}/home"); expect($clonedLandingUrl) ->not()->toContain("prefix//") - ->toBe("http://localhost/prefix/{$tenant->id}"); + ->toBe("http://localhost/{$expectedPrefixInUrl}"); expect($clonedHomeRouteUrl) ->not()->toContain("prefix//") - ->toBe("http://localhost/prefix/{$tenant->id}/home"); -}); + ->toBe("http://localhost/{$expectedPrefixInUrl}/home"); +})->with([true, false]); test('tenant routes are ignored from cloning and clone middleware in groups causes no issues', function () { // Should NOT be cloned, already has tenant parameter @@ -389,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/EarlyIdentificationTest.php b/tests/EarlyIdentificationTest.php index a95bac0b..e6c08d26 100644 --- a/tests/EarlyIdentificationTest.php +++ b/tests/EarlyIdentificationTest.php @@ -10,6 +10,7 @@ use Illuminate\Contracts\Http\Kernel; use Illuminate\Support\Facades\Event; use Stancl\Tenancy\Events\TenancyInitialized; use Illuminate\Support\Facades\Route as RouteFacade; +use Illuminate\Support\Str; use Stancl\Tenancy\Actions\CloneRoutesAsTenant; use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; @@ -120,7 +121,7 @@ test('early identification works with path identification', function (bool $useK RouteFacade::get('/{post}/comment/{comment}/edit', [$controller, 'computePost']); }); - $tenant = Tenant::create(['tenancy_db_name' => pest()->randomString()]); + $tenant = Tenant::create(['tenancy_db_name' => Str::random(10)]); // Migrate users and comments tables on tenant connection pest()->artisan('tenants:migrate', [ 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/Tenant.php b/tests/Etc/Tenant.php index 731a179b..72570c50 100644 --- a/tests/Etc/Tenant.php +++ b/tests/Etc/Tenant.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests\Etc; +use Closure; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\Concerns\HasDatabase; use Stancl\Tenancy\Database\Concerns\HasDomains; @@ -16,6 +17,7 @@ use Stancl\Tenancy\Database\Models; class Tenant extends Models\Tenant implements TenantWithDatabase { public static array $extraCustomColumns = []; + public static ?Closure $getPendingAttributesUsing = null; use HasDatabase, HasDomains, HasPending; @@ -23,4 +25,9 @@ class Tenant extends Models\Tenant implements TenantWithDatabase { return array_merge(parent::getCustomColumns(), static::$extraCustomColumns); } + + public static function getPendingAttributes(array $attributes): array + { + return static::$getPendingAttributesUsing ? (static::$getPendingAttributesUsing)($attributes) : []; + } } 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/Features/NoAttachTest.php b/tests/Features/NoAttachTest.php index a1588a24..1ec62f2a 100644 --- a/tests/Features/NoAttachTest.php +++ b/tests/Features/NoAttachTest.php @@ -63,7 +63,7 @@ test('sqlite ATTACH statements can be blocked', function (bool $disallow) { return json_encode(DB::select(request('q2'))); }); - tenancy(); // trigger features: todo@samuel remove after feature refactor + tenancy()->bootstrapFeatures(); if ($disallow) { expect(fn () => pest()->post('/central-sqli', [ diff --git a/tests/Features/RedirectTest.php b/tests/Features/RedirectTest.php index a4102070..a871f529 100644 --- a/tests/Features/RedirectTest.php +++ b/tests/Features/RedirectTest.php @@ -12,6 +12,8 @@ test('tenant redirect macro replaces only the hostname', function () { 'tenancy.features' => [CrossDomainRedirect::class], ]); + tenancy()->bootstrapFeatures(); + Route::get('/foobar', function () { return 'Foo'; })->name('home'); diff --git a/tests/Features/TenantConfigTest.php b/tests/Features/TenantConfigTest.php index b06ddba9..483e44a6 100644 --- a/tests/Features/TenantConfigTest.php +++ b/tests/Features/TenantConfigTest.php @@ -2,29 +2,27 @@ declare(strict_types=1); -use Illuminate\Support\Facades\Event; -use Stancl\Tenancy\Events\TenancyEnded; -use Stancl\Tenancy\Events\TenancyInitialized; -use Stancl\Tenancy\Features\TenantConfig; -use Stancl\Tenancy\Listeners\BootstrapTenancy; -use Stancl\Tenancy\Listeners\RevertToCentralContext; +use Stancl\Tenancy\Bootstrappers\TenantConfigBootstrapper; use Stancl\Tenancy\Tests\Etc\Tenant; use function Stancl\Tenancy\Tests\pest; +use function Stancl\Tenancy\Tests\withBootstrapping; + +beforeEach(function () { + config([ + 'tenancy.bootstrappers' => [TenantConfigBootstrapper::class], + ]); + + withBootstrapping(); +}); afterEach(function () { - TenantConfig::$storageToConfigMap = []; + TenantConfigBootstrapper::$storageToConfigMap = []; }); test('nested tenant values are merged', function () { expect(config('whitelabel.theme'))->toBeNull(); - config([ - 'tenancy.features' => [TenantConfig::class], - 'tenancy.bootstrappers' => [], - ]); - Event::listen(TenancyInitialized::class, BootstrapTenancy::class); - Event::listen(TenancyEnded::class, RevertToCentralContext::class); - TenantConfig::$storageToConfigMap = [ + TenantConfigBootstrapper::$storageToConfigMap = [ 'whitelabel.config.theme' => 'whitelabel.theme', ]; @@ -39,14 +37,8 @@ test('nested tenant values are merged', function () { test('config is merged and removed', function () { expect(config('services.paypal'))->toBe(null); - config([ - 'tenancy.features' => [TenantConfig::class], - 'tenancy.bootstrappers' => [], - ]); - Event::listen(TenancyInitialized::class, BootstrapTenancy::class); - Event::listen(TenancyEnded::class, RevertToCentralContext::class); - TenantConfig::$storageToConfigMap = [ + TenantConfigBootstrapper::$storageToConfigMap = [ 'paypal_api_public' => 'services.paypal.public', 'paypal_api_private' => 'services.paypal.private', ]; @@ -68,14 +60,8 @@ test('config is merged and removed', function () { test('the value can be set to multiple config keys', function () { expect(config('services.paypal'))->toBe(null); - config([ - 'tenancy.features' => [TenantConfig::class], - 'tenancy.bootstrappers' => [], - ]); - Event::listen(TenancyInitialized::class, BootstrapTenancy::class); - Event::listen(TenancyEnded::class, RevertToCentralContext::class); - TenantConfig::$storageToConfigMap = [ + TenantConfigBootstrapper::$storageToConfigMap = [ 'paypal_api_public' => [ 'services.paypal.public1', 'services.paypal.public2', diff --git a/tests/Features/ViteBundlerTest.php b/tests/Features/ViteBundlerTest.php index 3934698f..17ee8e08 100644 --- a/tests/Features/ViteBundlerTest.php +++ b/tests/Features/ViteBundlerTest.php @@ -27,6 +27,7 @@ beforeEach(function () { test('vite bundler ensures vite assets use global_asset when asset_helper_override is enabled', function () { config(['tenancy.features' => [ViteBundler::class]]); + tenancy()->bootstrapFeatures(); withBootstrapping(); diff --git a/tests/MaintenanceModeTest.php b/tests/MaintenanceModeTest.php index 9c90f0d3..9959992b 100644 --- a/tests/MaintenanceModeTest.php +++ b/tests/MaintenanceModeTest.php @@ -6,6 +6,8 @@ use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Event; use Stancl\Tenancy\Database\Concerns\MaintenanceMode; use Illuminate\Support\Facades\Route; +use Stancl\Tenancy\Events\TenantMaintenanceModeDisabled; +use Stancl\Tenancy\Events\TenantMaintenanceModeEnabled; use Stancl\Tenancy\Middleware\CheckTenantForMaintenanceMode; use Stancl\Tenancy\Middleware\InitializeTenancyByDomain; use function Stancl\Tenancy\Tests\pest; @@ -38,18 +40,46 @@ test('tenants can be in maintenance mode', function () { pest()->get('http://acme.localhost/foo')->assertStatus(200); }); -test('maintenance mode events are fired', function () { - $tenant = MaintenanceTenant::create(); +test('maintenance mode middleware can be used with universal routes', function () { + Route::get('/foo', function () { + return 'bar'; + })->middleware(['universal', InitializeTenancyByDomain::class, CheckTenantForMaintenanceMode::class]); - Event::fake(); + $tenant = MaintenanceTenant::create(); + $tenant->domains()->create([ + 'domain' => 'acme.localhost', + ]); + + // Revert to central context after each request so that the tenant context + // from the request doesn't persist + $run = function (Closure $callback) { $callback(); tenancy()->end(); }; + + $run(fn () => pest()->get('http://acme.localhost/foo')->assertStatus(200)); + $run(fn () => pest()->get('http://localhost/foo')->assertStatus(200)); $tenant->putDownForMaintenance(); - Event::assertDispatched(\Stancl\Tenancy\Events\TenantMaintenanceModeEnabled::class); + $run(fn () => pest()->get('http://acme.localhost/foo')->assertStatus(503)); + $run(fn () => pest()->get('http://localhost/foo')->assertStatus(200)); // Not affected by a tenant's maintenance mode $tenant->bringUpFromMaintenance(); - Event::assertDispatched(\Stancl\Tenancy\Events\TenantMaintenanceModeDisabled::class); + $run(fn () => pest()->get('http://acme.localhost/foo')->assertStatus(200)); + $run(fn () => pest()->get('http://localhost/foo')->assertStatus(200)); +}); + +test('maintenance mode events are fired', function () { + $tenant = MaintenanceTenant::create(); + + Event::fake([TenantMaintenanceModeEnabled::class, TenantMaintenanceModeDisabled::class]); + + $tenant->putDownForMaintenance(); + + Event::assertDispatched(TenantMaintenanceModeEnabled::class); + + $tenant->bringUpFromMaintenance(); + + Event::assertDispatched(TenantMaintenanceModeDisabled::class); }); test('tenants can be put into maintenance mode using artisan commands', function() { diff --git a/tests/PendingTenantsTest.php b/tests/PendingTenantsTest.php index 3339baaf..a90aceed 100644 --- a/tests/PendingTenantsTest.php +++ b/tests/PendingTenantsTest.php @@ -2,8 +2,12 @@ declare(strict_types=1); +use Illuminate\Database\QueryException; +use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Str; use Stancl\Tenancy\Commands\ClearPendingTenants; use Stancl\Tenancy\Commands\CreatePendingTenants; use Stancl\Tenancy\Events\CreatingPendingTenant; @@ -13,6 +17,13 @@ use Stancl\Tenancy\Events\PullingPendingTenant; use Stancl\Tenancy\Tests\Etc\Tenant; use function Stancl\Tenancy\Tests\pest; +beforeEach($cleanup = function () { + Tenant::$extraCustomColumns = []; + Tenant::$getPendingAttributesUsing = null; +}); + +afterEach($cleanup); + test('tenants are correctly identified as pending', function (){ Tenant::createPending(); @@ -191,3 +202,25 @@ test('commands run for pending tenants too if the with pending option is passed' $artisan->assertExitCode(0); }); + +test('pending tenants can have default attributes for non-nullable columns', function (bool $withPendingAttributes) { + Schema::table('tenants', function (Blueprint $table) { + $table->string('slug')->unique(); + }); + + Tenant::$extraCustomColumns = ['slug']; + if ($withPendingAttributes) Tenant::$getPendingAttributesUsing = fn () => [ + 'slug' => Str::random(8), + ]; + + $fn = fn () => Tenant::createPending(); + + // If there are non-nullable custom columns, and createPending() is called + // on its own without any values passed for those columns (as it would be called + // by the tenants:pending-create artisan command), we expect it to fail, unless + // getPendingAttributes() provides default values for those custom columns. + if ($withPendingAttributes) + expect($fn)->not()->toThrow(QueryException::class); + else + expect($fn)->toThrow(QueryException::class); +})->with([true, false]); 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..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 @@ -56,6 +57,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')); } @@ -99,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]); @@ -117,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]); @@ -147,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'); @@ -176,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]); @@ -201,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]); @@ -249,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; + } +} diff --git a/tests/TenantAssetTest.php b/tests/TenantAssetTest.php index 5c223fe2..ef1cb41f 100644 --- a/tests/TenantAssetTest.php +++ b/tests/TenantAssetTest.php @@ -8,6 +8,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; use Stancl\Tenancy\Actions\CloneRoutesAsTenant; use Stancl\Tenancy\Events\TenancyInitialized; use Stancl\Tenancy\Listeners\BootstrapTenancy; @@ -44,7 +45,7 @@ test('asset can be accessed using the url returned by the tenant asset helper', $tenant = Tenant::create(); tenancy()->initialize($tenant); - $filename = 'testfile' . pest()->randomString(10); + $filename = 'testfile' . Str::random(8); Storage::disk('public')->put($filename, 'bar'); $path = storage_path("app/public/$filename"); @@ -136,7 +137,7 @@ test('TenantAssetController headers are configurable', function () { tenancy()->initialize($tenant); $tenant->createDomain('foo.localhost'); - $filename = 'testfile' . pest()->randomString(10); + $filename = 'testfile' . Str::random(10); Storage::disk('public')->put($filename, 'bar'); $this->withoutExceptionHandling(); diff --git a/tests/TenantDatabaseManagerTest.php b/tests/TenantDatabaseManagerTest.php index c41ea35a..0d83e70e 100644 --- a/tests/TenantDatabaseManagerTest.php +++ b/tests/TenantDatabaseManagerTest.php @@ -29,6 +29,8 @@ use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQ use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLDatabaseManager; use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager; use function Stancl\Tenancy\Tests\pest; +use function Stancl\Tenancy\Tests\withBootstrapping; +use function Stancl\Tenancy\Tests\withTenantDatabases; beforeEach(function () { SQLiteDatabaseManager::$path = null; @@ -43,7 +45,7 @@ test('databases can be created and deleted', function ($driver, $databaseManager "tenancy.database.managers.$driver" => $databaseManager, ]); - $name = 'db' . pest()->randomString(); + $name = 'db' . Str::random(10); $manager = app($databaseManager); @@ -70,7 +72,7 @@ test('dbs can be created when another driver is used for the central db', functi return $event->tenant; })->toListener()); - $database = 'db' . pest()->randomString(); + $database = 'db' . Str::random(10); $mysqlmanager = app(MySQLDatabaseManager::class); $mysqlmanager->setConnection('mysql'); @@ -86,7 +88,7 @@ test('dbs can be created when another driver is used for the central db', functi $postgresManager = app(PostgreSQLDatabaseManager::class); $postgresManager->setConnection('pgsql'); - $database = 'db' . pest()->randomString(); + $database = 'db' . Str::random(10); expect($postgresManager->databaseExists($database))->toBeFalse(); Tenant::create([ @@ -146,18 +148,15 @@ test('db name is prefixed with db path when sqlite is used', function () { expect(database_path('foodb'))->toBe(config('database.connections.tenant.database')); }); -test('sqlite databases use the WAL journal mode by default', function (bool|null $wal) { - $expected = $wal ? 'wal' : 'delete'; - if ($wal !== null) { - SQLiteDatabaseManager::$WAL = $wal; - } else { - // default behavior - $expected = 'wal'; - } - - Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { - return $event->tenant; - })->toListener()); +test('sqlite databases respect the template journal_mode config', function (string $journal_mode) { + withTenantDatabases(); + withBootstrapping(); + config([ + 'database.connections.sqlite.journal_mode' => $journal_mode, + 'tenancy.bootstrappers' => [ + DatabaseTenancyBootstrapper::class, + ], + ]); $tenant = Tenant::create([ 'tenancy_db_connection' => 'sqlite', @@ -170,11 +169,18 @@ test('sqlite databases use the WAL journal mode by default', function (bool|null $db = new PDO('sqlite:' . $dbPath); $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - expect($db->query('pragma journal_mode')->fetch(PDO::FETCH_ASSOC)['journal_mode'])->toBe($expected); + // Before we connect to the DB using Laravel, it will be in default delete mode + expect($db->query('pragma journal_mode')->fetch(PDO::FETCH_ASSOC)['journal_mode'])->toBe('delete'); - // cleanup - SQLiteDatabaseManager::$WAL = true; -})->with([true, false, null]); + // This will trigger the logic in Laravel's SQLiteConnector + $tenant->run(fn () => DB::select('select 1')); + + $db = new PDO('sqlite:' . $dbPath); + $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + // Once we connect to the DB, it will be in the configured journal mode + expect($db->query('pragma journal_mode')->fetch(PDO::FETCH_ASSOC)['journal_mode'])->toBe($journal_mode); +})->with(['delete', 'wal']); test('schema manager uses schema to separate tenant dbs', function () { config([ @@ -239,9 +245,6 @@ test('tenant database can be created and deleted on a foreign server', function 'prefix_indexes' => true, 'strict' => true, 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), - ]) : [], ], ]); @@ -287,9 +290,6 @@ test('tenant database can be created on a foreign server by using the host from 'prefix_indexes' => true, 'strict' => true, 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), - ]) : [], ], ]); @@ -327,9 +327,6 @@ test('database credentials can be provided to PermissionControlledMySQLDatabaseM 'prefix_indexes' => true, 'strict' => true, 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), - ]) : [], ], ]); 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 8c9c4124..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', [ @@ -42,6 +43,8 @@ beforeEach(function () { ], ]); + tenancy()->bootstrapFeatures(); + Event::listen( TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { @@ -292,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); diff --git a/tests/TestCase.php b/tests/TestCase.php index d4f2657b..cbc6f57e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -25,6 +25,7 @@ use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use function Stancl\Tenancy\Tests\pest; use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper; +use Stancl\Tenancy\Bootstrappers\TenantConfigBootstrapper; abstract class TestCase extends \Orchestra\Testbench\TestCase { @@ -143,9 +144,6 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase 'prefix_indexes' => true, 'strict' => true, 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), - ]) : [], ], 'database.connections.sqlite.database' => ':memory:', 'database.connections.mysql.charset' => 'utf8mb4', @@ -193,6 +191,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase $app->singleton(RootUrlBootstrapper::class); $app->singleton(UrlGeneratorBootstrapper::class); $app->singleton(FilesystemTenancyBootstrapper::class); + $app->singleton(TenantConfigBootstrapper::class); } protected function getPackageProviders($app) @@ -236,11 +235,6 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase $app->singleton('Illuminate\Contracts\Console\Kernel', Etc\Console\ConsoleKernel::class); } - public function randomString(int $length = 10) - { - return substr(str_shuffle(str_repeat($x = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', (int) (ceil($length / strlen($x))))), 1, $length); - } - public function assertArrayIsSubset($subset, $array, string $message = ''): void { parent::assertTrue(array_intersect($subset, $array) == $subset, $message);