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..ca7c20f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,6 @@ jobs: matrix: include: - laravel: "^12.0" - php: "8.4" 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..76af44d9 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.0RC2 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/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 84787c0d..e0b69e6e 100644 --- a/assets/TenancyServiceProvider.stub.php +++ b/assets/TenancyServiceProvider.stub.php @@ -21,6 +21,21 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByPath; use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData; use Stancl\Tenancy\Bootstrappers\Integrations\FortifyRouteBootstrapper; +/** + * 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 +57,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 +80,7 @@ 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), ], Events\TenantMaintenanceModeEnabled::class => [], diff --git a/assets/config.php b/assets/config.php index ba503aad..d01cbff7 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. @@ -155,6 +170,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 +179,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, @@ -404,7 +421,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, diff --git a/composer.json b/composer.json index 2eab8837..393d0d8a 100644 --- a/composer.json +++ b/composer.json @@ -65,13 +65,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/src/Actions/CloneRoutesAsTenant.php b/src/Actions/CloneRoutesAsTenant.php index ec60d880..f1cb1450 100644 --- a/src/Actions/CloneRoutesAsTenant.php +++ b/src/Actions/CloneRoutesAsTenant.php @@ -86,6 +86,7 @@ class CloneRoutesAsTenant { 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) protected Closure|null $shouldClone = null; @@ -177,6 +178,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 { @@ -226,7 +234,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 */ diff --git a/src/Bootstrappers/CacheTenancyBootstrapper.php b/src/Bootstrappers/CacheTenancyBootstrapper.php index a66aa9f8..20e09816 100644 --- a/src/Bootstrappers/CacheTenancyBootstrapper.php +++ b/src/Bootstrappers/CacheTenancyBootstrapper.php @@ -99,11 +99,21 @@ 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)) { + if (app()->environment('production')) { + // We only throw this exception in prod to make configuration a little easier. Developers + // may have scope_sessions set to true while using different session drivers e.g. in tests. + // Previously we just silently ignored this, however since session scoping is of high importance + // in production, we make sure to notify the developer, by throwing an exception, that session + // scoping isn't happening as expected/configured due to an incompatible session driver. + throw new Exception('Session driver [' . $this->config->get('session.driver') . '] cannot be scoped by tenancy.cache.scope_session'); + } + } else { + // Scoping sessions using this bootstrapper implicitly adds the session store to $names + $names[] = $this->getSessionCacheStoreName(); + } } $names = array_unique($names); @@ -112,6 +122,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/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/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/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/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/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/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/HasPending.php b/src/Database/Concerns/HasPending.php index ffb35f0c..34a66544 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,62 @@ trait HasPending */ public static function createPending(array $attributes = []): Model&Tenant { - $tenant = static::create($attributes); - - 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($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 + /** + * 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/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..ac478d07 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, diff --git a/src/Features/ViteBundler.php b/src/Features/ViteBundler.php index 96f379b7..003984f7 100644 --- a/src/Features/ViteBundler.php +++ b/src/Features/ViteBundler.php @@ -5,22 +5,19 @@ declare(strict_types=1); namespace Stancl\Tenancy\Features; use Illuminate\Foundation\Application; +use Illuminate\Support\Facades\Vite; use Stancl\Tenancy\Contracts\Feature; -use Stancl\Tenancy\Overrides\Vite; -use Stancl\Tenancy\Tenancy; class ViteBundler implements Feature { - /** @var Application */ - protected $app; + public function __construct( + protected Application $app, + ) {} - public function __construct(Application $app) + public function bootstrap(): void { - $this->app = $app; - } - - public function bootstrap(Tenancy $tenancy): void - { - $this->app->singleton(\Illuminate\Foundation\Vite::class, Vite::class); + 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/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..88ae54f3 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,7 +125,7 @@ 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.'); } diff --git a/src/Overrides/Vite.php b/src/Overrides/Vite.php deleted file mode 100644 index 66bc9268..00000000 --- a/src/Overrides/Vite.php +++ /dev/null @@ -1,22 +0,0 @@ - + */ 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; @@ -131,12 +155,12 @@ class Tenancy /** @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 +174,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..a7f27e63 100644 --- a/src/TenancyServiceProvider.php +++ b/src/TenancyServiceProvider.php @@ -40,15 +40,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; @@ -176,6 +167,11 @@ class TenancyServiceProvider extends ServiceProvider 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/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/Bootstrappers/FilesystemTenancyBootstrapperTest.php b/tests/Bootstrappers/FilesystemTenancyBootstrapperTest.php index d6b6a231..857e0eac 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, diff --git a/tests/CloneActionTest.php b/tests/CloneActionTest.php index 656ad327..28a8ccd3 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 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/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 8254e8cc..17ee8e08 100644 --- a/tests/Features/ViteBundlerTest.php +++ b/tests/Features/ViteBundlerTest.php @@ -3,27 +3,42 @@ declare(strict_types=1); use Illuminate\Foundation\Vite; -use Stancl\Tenancy\Tests\Etc\Tenant; -use Stancl\Tenancy\Overrides\Vite as StanclVite; +use Illuminate\Support\Facades\File; +use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Features\ViteBundler; +use Stancl\Tenancy\Tests\Etc\Tenant; -test('vite helper uses our custom class', function() { - $vite = app(Vite::class); - - expect($vite)->toBeInstanceOf(Vite::class); - expect($vite)->not()->toBeInstanceOf(StanclVite::class); +use function Stancl\Tenancy\Tests\withBootstrapping; +beforeEach(function () { config([ - 'tenancy.features' => [ViteBundler::class], + 'tenancy.filesystem.asset_helper_override' => true, + 'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class], ]); - $tenant = Tenant::create(); - - tenancy()->initialize($tenant); - - app()->forgetInstance(Vite::class); - - $vite = app(Vite::class); - - expect($vite)->toBeInstanceOf(StanclVite::class); + File::ensureDirectoryExists(dirname($manifestPath = public_path('build/manifest.json'))); + File::put($manifestPath, json_encode([ + 'foo' => [ + 'file' => 'assets/foo-AbC123.js', + 'src' => 'js/foo.js', + ], + ])); +}); + +test('vite bundler ensures vite assets use global_asset when asset_helper_override is enabled', function () { + config(['tenancy.features' => [ViteBundler::class]]); + tenancy()->bootstrapFeatures(); + + withBootstrapping(); + + tenancy()->initialize(Tenant::create()); + + // Not what we want + expect(asset('foo'))->toBe(route('stancl.tenancy.asset', ['path' => 'foo'])); + + $viteAssetUrl = app(Vite::class)->asset('foo'); + $expectedGlobalUrl = global_asset('build/assets/foo-AbC123.js'); + + expect($viteAssetUrl)->toBe($expectedGlobalUrl); + expect($viteAssetUrl)->toBe('http://localhost/build/assets/foo-AbC123.js'); }); 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/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/TenantUserImpersonationTest.php b/tests/TenantUserImpersonationTest.php index 8c9c4124..48fbe691 100644 --- a/tests/TenantUserImpersonationTest.php +++ b/tests/TenantUserImpersonationTest.php @@ -42,6 +42,8 @@ beforeEach(function () { ], ]); + tenancy()->bootstrapFeatures(); + Event::listen( TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) { diff --git a/tests/TestCase.php b/tests/TestCase.php index 0aca40e4..ea480735 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -25,6 +25,7 @@ use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\LogTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper; +use Stancl\Tenancy\Bootstrappers\TenantConfigBootstrapper; use function Stancl\Tenancy\Tests\pest; @@ -145,9 +146,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', @@ -196,6 +194,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase $app->singleton(UrlGeneratorBootstrapper::class); $app->singleton(FilesystemTenancyBootstrapper::class); $app->singleton(LogTenancyBootstrapper::class); + $app->singleton(TenantConfigBootstrapper::class); } protected function getPackageProviders($app) @@ -239,11 +238,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);