diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 73f6355b..5ddcd8a6 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -8,14 +8,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - name: Check for todo0 - run: '! grep -r "todo0" --exclude-dir=workflows .' - if: always() - - name: Check for todo1 - run: '! grep -r "todo1" --exclude-dir=workflows .' - if: always() - - name: Check for todo2 - run: '! grep -r "todo2" --exclude-dir=workflows .' + - name: Check for priority todos + run: '! grep -r "todo[0-9]" --exclude-dir=workflows .' if: always() - name: Check for non-todo skip()s in tests run: '! grep -r "skip(" --exclude-dir=workflows tests/ | grep -v "todo"' diff --git a/assets/TenancyServiceProvider.stub.php b/assets/TenancyServiceProvider.stub.php index 84787c0d..1a01e9a8 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. diff --git a/assets/config.php b/assets/config.php index ba503aad..06bceccb 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. diff --git a/docker-compose.yml b/docker-compose.yml index 2d7a6e9f..34bd1cc1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -80,7 +80,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/Bootstrappers/DatabaseTenancyBootstrapper.php b/src/Bootstrappers/DatabaseTenancyBootstrapper.php index 8cc8127b..33ff7b29 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)) { // todo@dbRefactor does this call correctly use the host connection? throw new TenantDatabaseDoesNotExistException($database); } } 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/Migrate.php b/src/Commands/Migrate.php index 5348b509..6ecd6e14 100644 --- a/src/Commands/Migrate.php +++ b/src/Commands/Migrate.php @@ -51,13 +51,24 @@ class Migrate extends MigrateCommand return 1; } - if ($this->getProcesses() > 1) { - return $this->runConcurrently($this->getTenantChunks()->map(function ($chunk) { - return $this->getTenants($chunk); - })); + $originalTemplateConnection = config('tenancy.database.template_tenant_connection'); + + if ($database = $this->input->getOption('database')) { + config(['tenancy.database.template_tenant_connection' => $database]); } - return $this->migrateTenants($this->getTenants()) ? 0 : 1; + if ($this->getProcesses() > 1) { + $code = $this->runConcurrently($this->getTenantChunks()->map(function ($chunk) { + return $this->getTenants($chunk); + })); + } else { + $code = $this->migrateTenants($this->getTenants()) ? 0 : 1; + } + + // Reset the template tenant connection to the original one + config(['tenancy.database.template_tenant_connection' => $originalTemplateConnection]); + + return $code; } protected function childHandle(mixed ...$args): bool diff --git a/src/Commands/MigrateFresh.php b/src/Commands/MigrateFresh.php index 4e89cefd..d4733552 100644 --- a/src/Commands/MigrateFresh.php +++ b/src/Commands/MigrateFresh.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Commands; +use Illuminate\Console\ConfirmableTrait; use Illuminate\Database\Console\Migrations\BaseCommand; use Illuminate\Database\QueryException; use Illuminate\Support\LazyCollection; @@ -17,7 +18,7 @@ use Symfony\Component\Console\Output\OutputInterface as OI; class MigrateFresh extends BaseCommand { - use HasTenantOptions, DealsWithMigrations, ParallelCommand; + use HasTenantOptions, DealsWithMigrations, ParallelCommand, ConfirmableTrait; protected $description = 'Drop all tables and re-run all migrations for tenant(s)'; @@ -27,6 +28,7 @@ class MigrateFresh extends BaseCommand $this->addOption('drop-views', null, InputOption::VALUE_NONE, 'Drop views along with tenant tables.', null); $this->addOption('step', null, InputOption::VALUE_NONE, 'Force the migrations to be run so they can be rolled back individually.'); + $this->addOption('force', null, InputOption::VALUE_NONE, 'Force the command to run when in production.', null); $this->addProcessesOption(); $this->setName('tenants:migrate-fresh'); @@ -34,6 +36,10 @@ class MigrateFresh extends BaseCommand public function handle(): int { + if (! $this->confirmToProceed()) { + return 1; + } + $success = true; if ($this->getProcesses() > 1) { 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/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..7dbbc577 100644 --- a/src/Database/DatabaseConfig.php +++ b/src/Database/DatabaseConfig.php @@ -222,7 +222,7 @@ class DatabaseConfig } /** - * todo@name come up with a better name + * todo@dbRefactor come up with a better name * Get database manager class from the given connection config's driver. * * @throws DatabaseManagerNotRegisteredException diff --git a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php index 933740ed..eca2ef87 100644 --- a/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php +++ b/src/Database/TenantDatabaseManagers/PermissionControlledPostgreSQLSchemaManager.php @@ -30,7 +30,7 @@ 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 + // todo@dbRefactor 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 diff --git a/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php b/src/Database/TenantDatabaseManagers/SQLiteDatabaseManager.php index 64b96fc1..b792f228 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; @@ -19,13 +18,6 @@ class SQLiteDatabaseManager implements TenantDatabaseManager */ 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 @@ -89,25 +81,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager 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 +96,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; } 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..d7c57ac2 100644 --- a/src/Features/DisallowSqliteAttach.php +++ b/src/Features/DisallowSqliteAttach.php @@ -10,14 +10,12 @@ 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) { @@ -39,16 +37,12 @@ class DisallowSqliteAttach implements Feature protected function loadExtension(PDO $pdo): bool { - if (static::$loadExtensionSupported === null) { - static::$loadExtensionSupported = method_exists($pdo, 'loadExtension'); - } + static $loadExtensionSupported = method_exists($pdo, 'loadExtension'); - if (static::$loadExtensionSupported === false) { - return false; - } - if (static::$extensionPath === false) { - return false; - } + if ((! $loadExtensionSupported) || + (static::$extensionPath === false) || + (PHP_INT_SIZE !== 8) + ) return false; $suffix = match (PHP_OS_FAMILY) { 'Linux' => 'so', @@ -61,9 +55,7 @@ class DisallowSqliteAttach implements Feature $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 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..10283da3 100644 --- a/src/Features/TenantConfig.php +++ b/src/Features/TenantConfig.php @@ -12,7 +12,6 @@ use Stancl\Tenancy\Contracts\Feature; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Events\RevertedToCentralContext; use Stancl\Tenancy\Events\TenancyBootstrapped; -use Stancl\Tenancy\Tenancy; class TenantConfig implements Feature { @@ -27,7 +26,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/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/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? */ @@ -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 { @@ -131,12 +153,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 +172,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/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/CommandsTest.php b/tests/CommandsTest.php index 7ebb07a8..a5b3b856 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -27,6 +27,7 @@ use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException; use function Stancl\Tenancy\Tests\pest; +use Stancl\Tenancy\Events\MigratingDatabase; beforeEach(function () { if (file_exists($schemaPath = 'tests/Etc/tenant-schema-test.dump')) { @@ -95,6 +96,60 @@ test('migrate command works with tenants option', function () { expect(Schema::hasTable('users'))->toBeTrue(); }); +test('migrate command uses the passed database option as the template tenant connection', function () { + $originalTemplateConnection = config('tenancy.database.template_tenant_connection'); + + // Add a custom connection that will be used as the template for the tenant connection + // Identical to the default (mysql), just with different charset and collation + config(['database.connections.custom_connection' => [ + "driver" => "mysql", + "url" => "", + "host" => "mysql", + "port" => "3306", + "database" => "main", + "username" => "root", + "password" => "password", + "unix_socket" => "", + "charset" => "latin1", // Different from the default (utf8mb4) + "collation" => "latin1_swedish_ci", // Different from the default (utf8mb4_unicode_ci) + "prefix" => "", + "prefix_indexes" => true, + "strict" => true, + "engine" => null, + "options" => [] + ]]); + + $templateConnectionDuringMigration = null; + $tenantConnectionDuringMigration = null; + + Event::listen(MigratingDatabase::class, function() use (&$templateConnectionDuringMigration, &$tenantConnectionDuringMigration) { + $templateConnectionDuringMigration = config('tenancy.database.template_tenant_connection'); + $tenantConnectionDuringMigration = DB::connection('tenant')->getConfig(); + }); + + // The original tenant template connection config remains default + expect(config('tenancy.database.template_tenant_connection'))->toBe($originalTemplateConnection); + + Tenant::create(); + + // The original template connection is used when the --database option is not passed + pest()->artisan('tenants:migrate'); + expect($templateConnectionDuringMigration)->toBe($originalTemplateConnection); + + Tenant::create(); + + // The migrate command temporarily uses the connection passed in the --database option + pest()->artisan('tenants:migrate', ['--database' => 'custom_connection']); + expect($templateConnectionDuringMigration)->toBe('custom_connection'); + + // The tenant connection during migration actually used custom_connection's config + expect($tenantConnectionDuringMigration['charset'])->toBe('latin1'); + expect($tenantConnectionDuringMigration['collation'])->toBe('latin1_swedish_ci'); + + // The tenant template connection config is restored to the original after migrating + expect(config('tenancy.database.template_tenant_connection'))->toBe($originalTemplateConnection); +}); + test('migrate command only throws exceptions if skip-failing is not passed', function() { Tenant::create(); @@ -311,6 +366,21 @@ test('migrate fresh command works', function () { expect(DB::table('users')->exists())->toBeFalse(); }); +test('migrate fresh command respects force option in production', function () { + // Set environment to production + app()->detectEnvironment(fn() => 'production'); + + Tenant::create(); + + // Without --force in production, command should prompt for confirmation + pest()->artisan('tenants:migrate-fresh') + ->expectsConfirmation('Are you sure you want to run this command?'); + + // With --force, command should succeed without prompting + pest()->artisan('tenants:migrate-fresh', ['--force' => true]) + ->assertSuccessful(); +}); + test('run command with array of tenants works', function () { $tenantId1 = Tenant::create()->getTenantKey(); $tenantId2 = Tenant::create()->getTenantKey(); 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..b3b628e7 100644 --- a/tests/Features/TenantConfigTest.php +++ b/tests/Features/TenantConfigTest.php @@ -11,16 +11,21 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Tests\Etc\Tenant; use function Stancl\Tenancy\Tests\pest; +beforeEach(function () { + config([ + 'tenancy.features' => [TenantConfig::class], + 'tenancy.bootstrappers' => [], + ]); + + tenancy()->bootstrapFeatures(); +}); + afterEach(function () { TenantConfig::$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); @@ -39,10 +44,6 @@ 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); @@ -68,10 +69,6 @@ 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); 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/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..a9f99829 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([ 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 d4f2657b..bdd43fd4 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -236,11 +236,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);