diff --git a/Dockerfile b/Dockerfile index 0ced8009..73a052d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ # add amd64 platform to support Mac M1 FROM --platform=linux/amd64 shivammathur/node:latest-amd64 +# todo update this to 8.2 once shivammathur/node supports that ARG PHP_VERSION=8.1 WORKDIR /var/www/html diff --git a/INTERNAL.md b/INTERNAL.md new file mode 100644 index 00000000..4b3297dd --- /dev/null +++ b/INTERNAL.md @@ -0,0 +1,8 @@ +# Internal development notes + +## Updating the docker image used by the GH action + +1. Login in to Docker Hub: `docker login -u archtechx -p` +2. Build the image (probably shut down docker-compose containers first): `docker-compose build --no-cache` +3. Tag a new image: `docker tag tenancy_test archtechx/tenancy:latest` +4. Push the image: `docker push archtechx/tenancy:latest` diff --git a/assets/config.php b/assets/config.php index 999bdc90..2c016c25 100644 --- a/assets/config.php +++ b/assets/config.php @@ -103,6 +103,7 @@ return [ Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class, Stancl\Tenancy\Bootstrappers\BatchTenancyBootstrapper::class, // Stancl\Tenancy\Bootstrappers\PrefixCacheTenancyBootstrapper::class, + // Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper::class, // Queueing mail requires using QueueTenancyBootstrapper with $forceRefresh set to true // Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed ], diff --git a/assets/impersonation-migrations/2020_05_15_000010_create_tenant_user_impersonation_tokens_table.php b/assets/impersonation-migrations/2020_05_15_000010_create_tenant_user_impersonation_tokens_table.php index 7bcc3e75..c720160a 100644 --- a/assets/impersonation-migrations/2020_05_15_000010_create_tenant_user_impersonation_tokens_table.php +++ b/assets/impersonation-migrations/2020_05_15_000010_create_tenant_user_impersonation_tokens_table.php @@ -7,7 +7,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Stancl\Tenancy\Tenancy; -class CreateTenantUserImpersonationTokensTable extends Migration +return new class extends Migration { /** * Run the migrations. @@ -37,4 +37,4 @@ class CreateTenantUserImpersonationTokensTable extends Migration { Schema::dropIfExists('tenant_user_impersonation_tokens'); } -} +}; diff --git a/assets/migrations/2019_09_15_000010_create_tenants_table.php b/assets/migrations/2019_09_15_000010_create_tenants_table.php index ec730651..a923f2c8 100644 --- a/assets/migrations/2019_09_15_000010_create_tenants_table.php +++ b/assets/migrations/2019_09_15_000010_create_tenants_table.php @@ -6,7 +6,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -class CreateTenantsTable extends Migration +return new class extends Migration { /** * Run the migrations. @@ -34,4 +34,4 @@ class CreateTenantsTable extends Migration { Schema::dropIfExists('tenants'); } -} +}; diff --git a/assets/migrations/2019_09_15_000020_create_domains_table.php b/assets/migrations/2019_09_15_000020_create_domains_table.php index 511e6cc9..ac238830 100644 --- a/assets/migrations/2019_09_15_000020_create_domains_table.php +++ b/assets/migrations/2019_09_15_000020_create_domains_table.php @@ -7,7 +7,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Stancl\Tenancy\Tenancy; -class CreateDomainsTable extends Migration +return new class extends Migration { /** * Run the migrations. @@ -35,4 +35,4 @@ class CreateDomainsTable extends Migration { Schema::dropIfExists('domains'); } -} +}; diff --git a/assets/routes.php b/assets/routes.php index a27f782d..a9c09797 100644 --- a/assets/routes.php +++ b/assets/routes.php @@ -5,6 +5,7 @@ declare(strict_types=1); use Illuminate\Support\Facades\Route; use Stancl\Tenancy\Controllers\TenantAssetController; +// todo make this work with path identification Route::get('/tenancy/assets/{path?}', [TenantAssetController::class, 'asset']) ->where('path', '(.*)') ->name('stancl.tenancy.asset'); diff --git a/composer.json b/composer.json index 68f16f25..098b1cc4 100644 --- a/composer.json +++ b/composer.json @@ -58,16 +58,16 @@ } }, "scripts": { - "docker-up": "PHP_VERSION=8.1 docker-compose up -d", - "docker-down": "PHP_VERSION=8.1 docker-compose down", - "docker-rebuild": "PHP_VERSION=8.1 docker-compose up -d --no-deps --build", + "docker-up": "PHP_VERSION=8.2 docker-compose up -d", + "docker-down": "PHP_VERSION=8.2 docker-compose down", + "docker-rebuild": "PHP_VERSION=8.2 docker-compose up -d --no-deps --build", "docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml", "coverage": "open coverage/phpunit/html/index.html", "phpstan": "vendor/bin/phpstan", "phpstan-pro": "vendor/bin/phpstan --pro", "cs": "php-cs-fixer fix --config=.php-cs-fixer.php", - "test": "PHP_VERSION=8.1 ./test --no-coverage", - "test-full": "PHP_VERSION=8.1 ./test" + "test": "PHP_VERSION=8.2 ./test --no-coverage", + "test-full": "PHP_VERSION=8.2 ./test" }, "minimum-stability": "dev", "prefer-stable": true, diff --git a/docker-compose.yml b/docker-compose.yml index 116b48f1..465b36cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: build: context: . args: - PHP_VERSION: ${PHP_VERSION:-8.1} + PHP_VERSION: ${PHP_VERSION:-8.2} depends_on: mysql: condition: service_healthy diff --git a/phpstan.neon b/phpstan.neon index a6bce96d..6a864833 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -28,7 +28,7 @@ parameters: paths: - src/Features/TelescopeTags.php - - message: '#Parameter \#1 \$key of method Illuminate\\Contracts\\Cache\\Repository::put\(\) expects string#' + message: '#Parameter \#1 \$key of method Illuminate\\Cache\\Repository::put\(\) expects#' paths: - src/helpers.php - @@ -49,5 +49,9 @@ parameters: - src/Database/DatabaseConfig.php - '#Method Stancl\\Tenancy\\Tenancy::cachedResolvers\(\) should return array#' + # php 8.2 + # - '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$tenancy#' + # - '#Access to an undefined property Stancl\\Tenancy\\Middleware\\IdentificationMiddleware\:\:\$resolver#' + checkMissingIterableValueType: false treatPhpDocTypesAsCertain: false diff --git a/src/Bootstrappers/MailTenancyBootstrapper.php b/src/Bootstrappers/MailTenancyBootstrapper.php new file mode 100644 index 00000000..7f15f547 --- /dev/null +++ b/src/Bootstrappers/MailTenancyBootstrapper.php @@ -0,0 +1,79 @@ + 'tenant_property', + * ] + */ + public static array $credentialsMap = []; + + public static string|null $mailer = null; + + protected array $originalConfig = []; + + public static array $mapPresets = [ + 'smtp' => [ + 'mail.mailers.smtp.host' => 'smtp_host', + 'mail.mailers.smtp.port' => 'smtp_port', + 'mail.mailers.smtp.username' => 'smtp_username', + 'mail.mailers.smtp.password' => 'smtp_password', + ], + ]; + + public function __construct( + protected Repository $config, + protected Application $app + ) { + static::$mailer ??= $config->get('mail.default'); + static::$credentialsMap = array_merge(static::$credentialsMap, static::$mapPresets[static::$mailer] ?? []); + } + + public function bootstrap(Tenant $tenant): void + { + // Forget the mail manager instance to clear the cached mailers + $this->app->forgetInstance('mail.manager'); + + $this->setConfig($tenant); + } + + public function revert(): void + { + $this->unsetConfig(); + + $this->app->forgetInstance('mail.manager'); + } + + protected function setConfig(Tenant $tenant): void + { + foreach (static::$credentialsMap as $configKey => $storageKey) { + $override = $tenant->$storageKey; + + if (array_key_exists($storageKey, $tenant->getAttributes())) { + $this->originalConfig[$configKey] ??= $this->config->get($configKey); + + $this->config->set($configKey, $override); + } + } + } + + protected function unsetConfig(): void + { + foreach ($this->originalConfig as $key => $value) { + $this->config->set($key, $value); + } + } +} diff --git a/src/Commands/ClearPendingTenants.php b/src/Commands/ClearPendingTenants.php index 19d31195..0e27a209 100644 --- a/src/Commands/ClearPendingTenants.php +++ b/src/Commands/ClearPendingTenants.php @@ -10,7 +10,6 @@ use Illuminate\Database\Eloquent\Builder; class ClearPendingTenants extends Command { protected $signature = 'tenants:pending-clear - {--all : Override the default settings and deletes all pending tenants} {--older-than-days= : Deletes all pending tenants older than the amount of days} {--older-than-hours= : Deletes all pending tenants older than the amount of hours}'; @@ -18,38 +17,30 @@ class ClearPendingTenants extends Command public function handle(): int { - $this->info('Removing pending tenants.'); + $this->components->info('Removing pending tenants.'); $expirationDate = now(); // We compare the original expiration date to the new one to check if the new one is different later $originalExpirationDate = $expirationDate->copy()->toImmutable(); - // Skip the time constraints if the 'all' option is given - if (! $this->option('all')) { - /** @var ?int $olderThanDays */ - $olderThanDays = $this->option('older-than-days'); + $olderThanDays = (int) $this->option('older-than-days'); + $olderThanHours = (int) $this->option('older-than-hours'); - /** @var ?int $olderThanHours */ - $olderThanHours = $this->option('older-than-hours'); + if ($olderThanDays && $olderThanHours) { + $this->components->error("Cannot use '--older-than-days' and '--older-than-hours' together. Please, choose only one of these options."); - if ($olderThanDays && $olderThanHours) { - $this->line(" Cannot use '--older-than-days' and '--older-than-hours' together \n"); // todo@cli refactor all of these styled command outputs to use $this->components - $this->line('Please, choose only one of these options.'); - - return 1; // Exit code for failure - } - - if ($olderThanDays) { - $expirationDate->subDays($olderThanDays); - } - - if ($olderThanHours) { - $expirationDate->subHours($olderThanHours); - } + return 1; // Exit code for failure } - $deletedTenantCount = tenancy() - ->query() + if ($olderThanDays) { + $expirationDate->subDays($olderThanDays); + } + + if ($olderThanHours) { + $expirationDate->subHours($olderThanHours); + } + + $deletedTenantCount = tenancy()->query() ->onlyPending() ->when($originalExpirationDate->notEqualTo($expirationDate), function (Builder $query) use ($expirationDate) { $query->where($query->getModel()->getColumnForQuery('pending_since'), '<', $expirationDate->timestamp); @@ -59,7 +50,7 @@ class ClearPendingTenants extends Command ->delete() ->count(); - $this->info($deletedTenantCount . ' pending ' . str('tenant')->plural($deletedTenantCount) . ' deleted.'); + $this->components->info($deletedTenantCount . ' pending ' . str('tenant')->plural($deletedTenantCount) . ' deleted.'); return 0; } diff --git a/src/Commands/CreatePendingTenants.php b/src/Commands/CreatePendingTenants.php index 7b2c7934..c37b8bd7 100644 --- a/src/Commands/CreatePendingTenants.php +++ b/src/Commands/CreatePendingTenants.php @@ -14,7 +14,7 @@ class CreatePendingTenants extends Command public function handle(): int { - $this->info('Creating pending tenants.'); + $this->components->info('Creating pending tenants.'); $maxPendingTenantCount = (int) ($this->option('count') ?? config('tenancy.pending.count')); $pendingTenantCount = $this->getPendingTenantCount(); @@ -30,8 +30,8 @@ class CreatePendingTenants extends Command $createdCount++; } - $this->info($createdCount . ' ' . str('tenant')->plural($createdCount) . ' created.'); - $this->info($maxPendingTenantCount . ' ' . str('tenant')->plural($maxPendingTenantCount) . ' ready to be used.'); + $this->components->info($createdCount . ' pending ' . str('tenant')->plural($createdCount) . ' created.'); + $this->components->info($maxPendingTenantCount . ' pending ' . str('tenant')->plural($maxPendingTenantCount) . ' ready to be used.'); return 0; } @@ -39,8 +39,7 @@ class CreatePendingTenants extends Command /** Calculate the number of currently available pending tenants. */ protected function getPendingTenantCount(): int { - return tenancy() - ->query() + return tenancy()->query() ->onlyPending() ->count(); } diff --git a/src/Commands/Link.php b/src/Commands/Link.php index a6dd6c5f..d49cc7f2 100644 --- a/src/Commands/Link.php +++ b/src/Commands/Link.php @@ -34,7 +34,7 @@ class Link extends Command $this->createLinks($tenants); } } catch (Exception $exception) { - $this->error($exception->getMessage()); + $this->components->error($exception->getMessage()); return 1; } diff --git a/src/Commands/MigrateFreshOverride.php b/src/Commands/MigrateFreshOverride.php index 88e9e21e..f2fd70b0 100644 --- a/src/Commands/MigrateFreshOverride.php +++ b/src/Commands/MigrateFreshOverride.php @@ -5,13 +5,18 @@ declare(strict_types=1); namespace Stancl\Tenancy\Commands; use Illuminate\Database\Console\Migrations\FreshCommand; +use Illuminate\Support\Facades\Schema; class MigrateFreshOverride extends FreshCommand { public function handle() { if (config('tenancy.database.drop_tenant_databases_on_migrate_fresh')) { - tenancy()->model()::cursor()->each->delete(); + $tenantModel = tenancy()->model(); + + if (Schema::hasTable($tenantModel->getTable())) { + $tenantModel::cursor()->each->delete(); + } } return parent::handle(); diff --git a/src/Commands/TenantDump.php b/src/Commands/TenantDump.php index 3f957bdd..c7bd9b99 100644 --- a/src/Commands/TenantDump.php +++ b/src/Commands/TenantDump.php @@ -23,7 +23,7 @@ class TenantDump extends DumpCommand public function handle(ConnectionResolverInterface $connections, Dispatcher $dispatcher): int { if (is_null($this->option('path'))) { - $this->input->setOption('path', database_path('schema/tenant-schema.dump')); + $this->input->setOption('path', config('tenancy.migration_parameters.--schema-path') ?? database_path('schema/tenant-schema.dump')); } $tenant = $this->option('tenant') @@ -41,7 +41,7 @@ class TenantDump extends DumpCommand return 1; } - parent::handle($connections, $dispatcher); + $tenant->run(fn () => parent::handle($connections, $dispatcher)); return 0; } diff --git a/src/Concerns/HasTenantOptions.php b/src/Concerns/HasTenantOptions.php index f8a763a7..b558da64 100644 --- a/src/Concerns/HasTenantOptions.php +++ b/src/Concerns/HasTenantOptions.php @@ -23,8 +23,7 @@ trait HasTenantOptions protected function getTenants(): LazyCollection { - return tenancy() - ->query() + return tenancy()->query() ->when($this->option('tenants'), function ($query) { $query->whereIn(tenancy()->model()->getTenantKeyName(), $this->option('tenants')); }) diff --git a/src/Contracts/Domain.php b/src/Contracts/Domain.php index a9a19a50..cfe89f43 100644 --- a/src/Contracts/Domain.php +++ b/src/Contracts/Domain.php @@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * * @see \Stancl\Tenancy\Database\Models\Domain * - * @method __call(string $method, array $parameters) IDE support. This will be a model. + * @method __call(string $method, array $parameters) IDE support. This will be a model. // todo check if we can remove these now * @method static __callStatic(string $method, array $parameters) IDE support. This will be a model. * @mixin \Illuminate\Database\Eloquent\Model */ diff --git a/src/Features/UserImpersonation.php b/src/Features/UserImpersonation.php index 4c9bb104..608bed07 100644 --- a/src/Features/UserImpersonation.php +++ b/src/Features/UserImpersonation.php @@ -48,6 +48,23 @@ class UserImpersonation implements Feature $token->delete(); + session()->put('tenancy_impersonating', true); + return redirect($token->redirect_url); } + + public static function isImpersonating(): bool + { + return session()->has('tenancy_impersonating'); + } + + /** + * Logout from the current domain and forget impersonation session. + */ + public static function leave(): void // todo possibly rename + { + auth()->logout(); + + session()->forget('tenancy_impersonating'); + } } diff --git a/src/Listeners/CreateTenantConnection.php b/src/Listeners/CreateTenantConnection.php index b4983d32..6af18a10 100644 --- a/src/Listeners/CreateTenantConnection.php +++ b/src/Listeners/CreateTenantConnection.php @@ -6,7 +6,7 @@ namespace Stancl\Tenancy\Listeners; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\DatabaseManager; -use Stancl\Tenancy\Events\Contracts\TenantEvent; +use Stancl\Tenancy\Events\Contracts\TenancyEvent; class CreateTenantConnection { @@ -15,11 +15,12 @@ class CreateTenantConnection ) { } - public function handle(TenantEvent $event): void + public function handle(TenancyEvent $event): void { - /** @var TenantWithDatabase */ - $tenant = $event->tenant; + /** @var TenantWithDatabase $tenant */ + $tenant = $event->tenancy->tenant; + $this->database->purgeTenantConnection(); $this->database->createTenantConnection($tenant); } } diff --git a/src/Listeners/UseCentralConnection.php b/src/Listeners/UseCentralConnection.php new file mode 100644 index 00000000..716a5148 --- /dev/null +++ b/src/Listeners/UseCentralConnection.php @@ -0,0 +1,21 @@ +database->reconnectToCentral(); + } +} diff --git a/src/Listeners/UseTenantConnection.php b/src/Listeners/UseTenantConnection.php new file mode 100644 index 00000000..a4c12108 --- /dev/null +++ b/src/Listeners/UseTenantConnection.php @@ -0,0 +1,21 @@ +database->setDefaultConnection('tenant'); + } +} diff --git a/src/Resolvers/DomainTenantResolver.php b/src/Resolvers/DomainTenantResolver.php index 2163febe..ceecd0b6 100644 --- a/src/Resolvers/DomainTenantResolver.php +++ b/src/Resolvers/DomainTenantResolver.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Resolvers; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; use Stancl\Tenancy\Contracts\Domain; use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException; @@ -39,14 +40,16 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver protected function setCurrentDomain(Tenant $tenant, string $domain): void { + /** @var Tenant&Model $tenant */ static::$currentDomain = $tenant->domains->where('domain', $domain)->first(); } public function getArgsForTenant(Tenant $tenant): array { + /** @var Tenant&Model $tenant */ $tenant->unsetRelation('domains'); - return $tenant->domains->map(function (Domain $domain) { + return $tenant->domains->map(function (Domain&Model $domain) { return [$domain->domain]; })->toArray(); } diff --git a/src/Tenancy.php b/src/Tenancy.php index e8187dd8..991f9234 100644 --- a/src/Tenancy.php +++ b/src/Tenancy.php @@ -118,6 +118,7 @@ class Tenancy */ public static function find(int|string $id): Tenant|null { + // todo update all syntax like this once we're fully on PHP 8.2 /** @var (Tenant&Model)|null */ $tenant = static::model()->where(static::model()->getTenantKeyName(), $id)->first(); diff --git a/tests/BootstrapperTest.php b/tests/BootstrapperTest.php index 7ad65c48..a3b07b16 100644 --- a/tests/BootstrapperTest.php +++ b/tests/BootstrapperTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Illuminate\Support\Str; +use Illuminate\Mail\MailManager; use Illuminate\Support\Facades\DB; use Stancl\JobPipeline\JobPipeline; use Illuminate\Support\Facades\File; @@ -24,6 +25,7 @@ use Stancl\Tenancy\Jobs\RemoveStorageSymlinks; use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\DeleteTenantStorage; use Stancl\Tenancy\Listeners\RevertToCentralContext; +use Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; @@ -331,20 +333,55 @@ test('local storage public urls are generated correctly', function() { expect(File::isDirectory($tenantStoragePath))->toBeFalse(); }); +test('MailTenancyBootstrapper maps tenant mail credentials to config as specified in the $credentialsMap property and makes the mailer use tenant credentials', function() { + MailTenancyBootstrapper::$credentialsMap = [ + 'mail.mailers.smtp.username' => 'smtp_username', + 'mail.mailers.smtp.password' => 'smtp_password' + ]; + + config([ + 'mail.default' => 'smtp', + 'mail.mailers.smtp.username' => $defaultUsername = 'default username', + 'mail.mailers.smtp.password' => 'no password' + ]); + + $tenant = Tenant::create(['smtp_password' => $password = 'testing password']); + + tenancy()->initialize($tenant); + + expect(array_key_exists('smtp_password', tenant()->getAttributes()))->toBeTrue(); + expect(array_key_exists('smtp_host', tenant()->getAttributes()))->toBeFalse(); + expect(config('mail.mailers.smtp.username'))->toBe($defaultUsername); + expect(config('mail.mailers.smtp.password'))->toBe(tenant()->smtp_password); + + // Assert that the current mailer uses tenant's smtp_password + assertMailerTransportUsesPassword($password); +}); + +test('MailTenancyBootstrapper reverts the config and mailer credentials to default when tenancy ends', function() { + MailTenancyBootstrapper::$credentialsMap = ['mail.mailers.smtp.password' => 'smtp_password']; + config(['mail.default' => 'smtp', 'mail.mailers.smtp.password' => $defaultPassword = 'no password']); + + tenancy()->initialize(Tenant::create(['smtp_password' => $tenantPassword = 'testing password'])); + + expect(config('mail.mailers.smtp.password'))->toBe($tenantPassword); + + assertMailerTransportUsesPassword($tenantPassword); + + tenancy()->end(); + + expect(config('mail.mailers.smtp.password'))->toBe($defaultPassword); + + // Assert that the current mailer uses the default SMTP password + assertMailerTransportUsesPassword($defaultPassword); +}); + function getDiskPrefix(string $disk): string { /** @var FilesystemAdapter $disk */ $disk = Storage::disk($disk); $adapter = $disk->getAdapter(); + $prefix = invade(invade($adapter)->prefixer)->prefix; - $prefixer = (new ReflectionObject($adapter))->getProperty('prefixer'); - $prefixer->setAccessible(true); - - // reflection -> instance - $prefixer = $prefixer->getValue($adapter); - - $prefix = (new ReflectionProperty($prefixer, 'prefix')); - $prefix->setAccessible(true); - - return $prefix->getValue($prefixer); + return $prefix; } diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index 95672753..7d6f0884 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -25,7 +25,7 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; beforeEach(function () { - if (file_exists($schemaPath = database_path('schema/tenant-schema.dump'))) { + if (file_exists($schemaPath = 'tests/Etc/tenant-schema-test.dump')) { unlink($schemaPath); } @@ -111,28 +111,44 @@ test('migrate command loads schema state', function () { test('dump command works', function () { $tenant = Tenant::create(); + $schemaPath = 'tests/Etc/tenant-schema-test.dump'; + Artisan::call('tenants:migrate'); - tenancy()->initialize($tenant); + expect($schemaPath)->not()->toBeFile(); - Artisan::call('tenants:dump --path="tests/Etc/tenant-schema-test.dump"'); - expect('tests/Etc/tenant-schema-test.dump')->toBeFile(); -}); - -test('tenant dump file gets created as tenant-schema.dump in the database schema folder by default', function() { - config(['tenancy.migration_parameters.--schema-path' => $schemaPath = database_path('schema/tenant-schema.dump')]); - - $tenant = Tenant::create(); - Artisan::call('tenants:migrate'); - - tenancy()->initialize($tenant); - - Artisan::call('tenants:dump'); + Artisan::call('tenants:dump ' . "--tenant='$tenant->id' --path='$schemaPath'"); expect($schemaPath)->toBeFile(); }); -test('migrate command uses the correct schema path by default', function () { +test('dump command generates dump at the passed path', function() { + $tenant = Tenant::create(); + + Artisan::call('tenants:migrate'); + + expect($schemaPath = 'tests/Etc/tenant-schema-test.dump')->not()->toBeFile(); + + Artisan::call("tenants:dump --tenant='$tenant->id' --path='$schemaPath'"); + + expect($schemaPath)->toBeFile(); +}); + +test('dump command generates dump at the path specified in the tenancy migration parameters config', function() { + config(['tenancy.migration_parameters.--schema-path' => $schemaPath = 'tests/Etc/tenant-schema-test.dump']); + + $tenant = Tenant::create(); + + Artisan::call('tenants:migrate'); + + expect($schemaPath)->not()->toBeFile(); + + Artisan::call("tenants:dump --tenant='$tenant->id'"); + + expect($schemaPath)->toBeFile(); +}); + +test('migrate command correctly uses the schema dump located at the configured schema path by default', function () { config(['tenancy.migration_parameters.--schema-path' => 'tests/Etc/tenant-schema.dump']); $tenant = Tenant::create(); @@ -146,6 +162,7 @@ test('migrate command uses the correct schema path by default', function () { tenancy()->initialize($tenant); + // schema_users is a table included in the tests/Etc/tenant-schema dump // Check for both tables to see if missing migrations also get executed expect(Schema::hasTable('schema_users'))->toBeTrue(); expect(Schema::hasTable('users'))->toBeTrue(); @@ -355,7 +372,7 @@ function runCommandWorks(): void Artisan::call('tenants:migrate', ['--tenants' => [$id]]); pest()->artisan("tenants:run --tenants=$id 'foo foo --b=bar --c=xyz' ") - ->expectsOutput("User's name is Test command") + ->expectsOutput("User's name is Test user") ->expectsOutput('foo') ->expectsOutput('xyz'); } diff --git a/tests/Etc/Console/ExampleCommand.php b/tests/Etc/Console/ExampleCommand.php index 72263b37..cdd7b551 100644 --- a/tests/Etc/Console/ExampleCommand.php +++ b/tests/Etc/Console/ExampleCommand.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Stancl\Tenancy\Tests\Etc\Console; +use Illuminate\Support\Str; use Illuminate\Console\Command; class ExampleCommand extends Command @@ -22,14 +23,13 @@ class ExampleCommand extends Command */ public function handle() { - User::create([ - 'id' => 999, - 'name' => 'Test command', - 'email' => 'test@command.com', + $id = User::create([ + 'name' => 'Test user', + 'email' => Str::random(8) . '@example.com', 'password' => bcrypt('password'), - ]); + ])->id; - $this->line("User's name is " . User::find(999)->name); + $this->line("User's name is " . User::find($id)->name); $this->line($this->argument('a')); $this->line($this->option('c')); } diff --git a/tests/MailTest.php b/tests/MailTest.php new file mode 100644 index 00000000..544fda1b --- /dev/null +++ b/tests/MailTest.php @@ -0,0 +1,72 @@ + 'smtp']); + + Event::listen(TenancyInitialized::class, BootstrapTenancy::class); + Event::listen(TenancyEnded::class, RevertToCentralContext::class); +}); + +// Initialize tenancy as $tenant and assert that the smtp mailer's transport has the correct password +function assertMailerTransportUsesPassword(string|null $password) { + $manager = app(MailManager::class); + $mailer = invade($manager)->get('smtp'); + $mailerPassword = invade($mailer->getSymfonyTransport())->password; + + expect($mailerPassword)->toBe((string) $password); +}; + +test('mailer transport uses the correct credentials', function() { + config(['mail.default' => 'smtp', 'mail.mailers.smtp.password' => $defaultPassword = 'DEFAULT']); + MailTenancyBootstrapper::$credentialsMap = ['mail.mailers.smtp.password' => 'smtp_password']; + + tenancy()->initialize($tenant = Tenant::create()); + assertMailerTransportUsesPassword($defaultPassword); // $tenant->smtp_password is not set, so the default password should be used + tenancy()->end(); + + // Assert mailer uses the updated password + $tenant->update(['smtp_password' => $newPassword = 'changed']); + + tenancy()->initialize($tenant); + assertMailerTransportUsesPassword($newPassword); + tenancy()->end(); + + // Assert mailer uses the correct password after switching to a different tenant + tenancy()->initialize(Tenant::create(['smtp_password' => $newTenantPassword = 'updated'])); + assertMailerTransportUsesPassword($newTenantPassword); + tenancy()->end(); + + // Assert mailer uses the default password after tenancy ends + assertMailerTransportUsesPassword($defaultPassword); +}); + + +test('initializing and ending tenancy binds a fresh MailManager instance without cached mailers', function() { + $mailers = fn() => invade(app(MailManager::class))->mailers; + + app(MailManager::class)->mailer('smtp'); + + expect($mailers())->toHaveCount(1); + + tenancy()->initialize(Tenant::create()); + + expect($mailers())->toHaveCount(0); + + app(MailManager::class)->mailer('smtp'); + + expect($mailers())->toHaveCount(1); + + tenancy()->end(); + + expect($mailers())->toHaveCount(0); +}); diff --git a/tests/ManualModeTest.php b/tests/ManualModeTest.php new file mode 100644 index 00000000..fe1ba9a6 --- /dev/null +++ b/tests/ManualModeTest.php @@ -0,0 +1,45 @@ +send(function (TenantCreated $event) { + return $event->tenant; + })->toListener()); + + Event::listen(TenancyInitialized::class, CreateTenantConnection::class); + Event::listen(TenancyInitialized::class, UseTenantConnection::class); + Event::listen(TenancyEnded::class, UseCentralConnection::class); + + $tenant = Tenant::create(); + + expect(app('db')->getDefaultConnection())->toBe('central'); + expect(array_keys(app('db')->getConnections()))->toBe(['central', 'tenant_host_connection']); + pest()->assertArrayNotHasKey('tenant', config('database.connections')); + + tenancy()->initialize($tenant); + + // Trigger creation of the tenant connection + createUsersTable(); + + expect(app('db')->getDefaultConnection())->toBe('tenant'); + expect(array_keys(app('db')->getConnections()))->toBe(['central', 'tenant']); + pest()->assertArrayHasKey('tenant', config('database.connections')); + + tenancy()->end(); + + expect(array_keys(app('db')->getConnections()))->toBe(['central']); + expect(config('database.connections.tenant'))->toBeNull(); + expect(app('db')->getDefaultConnection())->toBe(config('tenancy.database.central_connection')); +}); diff --git a/tests/PendingTenantsTest.php b/tests/PendingTenantsTest.php index 8dbda9ee..26fd5c34 100644 --- a/tests/PendingTenantsTest.php +++ b/tests/PendingTenantsTest.php @@ -67,23 +67,6 @@ test('CreatePendingTenants command cannot run with both time constraints', funct ->assertFailed(); }); -test('CreatePendingTenants commands all option overrides any config constraints', function () { - Tenant::createPending(); - Tenant::createPending(); - - tenancy()->model()->query()->onlyPending()->first()->update([ - 'pending_since' => now()->subDays(10) - ]); - - config(['tenancy.pending.older_than_days' => 4]); - - Artisan::call(ClearPendingTenants::class, [ - '--all' => true - ]); - - expect(Tenant::onlyPending()->count())->toBe(0); -}); - test('tenancy can check if there are any pending tenants', function () { expect(Tenant::onlyPending()->exists())->toBeFalse(); diff --git a/tests/TenantUserImpersonationTest.php b/tests/TenantUserImpersonationTest.php index 0fcb9022..1e72c604 100644 --- a/tests/TenantUserImpersonationTest.php +++ b/tests/TenantUserImpersonationTest.php @@ -83,6 +83,19 @@ test('tenant user can be impersonated on a tenant domain', function () { pest()->get('http://foo.localhost/dashboard') ->assertSuccessful() ->assertSee('You are logged in as Joe'); + + expect(UserImpersonation::isImpersonating())->toBeTrue(); + expect(session('tenancy_impersonating'))->toBeTrue(); + + // Leave impersonation + UserImpersonation::leave(); + + expect(UserImpersonation::isImpersonating())->toBeFalse(); + expect(session('tenancy_impersonating'))->toBeNull(); + + // Assert can't access the tenant dashboard + pest()->get('http://foo.localhost/dashboard') + ->assertRedirect('http://foo.localhost/login'); }); test('tenant user can be impersonated on a tenant path', function () { @@ -116,6 +129,19 @@ test('tenant user can be impersonated on a tenant path', function () { pest()->get('/acme/dashboard') ->assertSuccessful() ->assertSee('You are logged in as Joe'); + + expect(UserImpersonation::isImpersonating())->toBeTrue(); + expect(session('tenancy_impersonating'))->toBeTrue(); + + // Leave impersonation + UserImpersonation::leave(); + + expect(UserImpersonation::isImpersonating())->toBeFalse(); + expect(session('tenancy_impersonating'))->toBeNull(); + + // Assert can't access the tenant dashboard + pest()->get('/acme/dashboard') + ->assertRedirect('/login'); }); test('tokens have a limited ttl', function () { diff --git a/tests/TestCase.php b/tests/TestCase.php index 0dde576d..60329d78 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -14,6 +14,7 @@ use Stancl\Tenancy\Facades\GlobalCache; use Stancl\Tenancy\Facades\Tenancy; use Stancl\Tenancy\TenancyServiceProvider; use Stancl\Tenancy\Tests\Etc\Tenant; +use Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper; abstract class TestCase extends \Orchestra\Testbench\TestCase { @@ -104,6 +105,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase '--force' => true, ], 'tenancy.bootstrappers.redis' => RedisTenancyBootstrapper::class, // todo1 change this to []? two tests in TenantDatabaseManagerTest are failing with that + 'tenancy.bootstrappers.mail' => MailTenancyBootstrapper::class, 'queue.connections.central' => [ 'driver' => 'sync', 'central' => true, @@ -114,6 +116,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase $app->singleton(RedisTenancyBootstrapper::class); // todo (Samuel) use proper approach eg config for singleton registration $app->singleton(PrefixCacheTenancyBootstrapper::class); // todo (Samuel) use proper approach eg config for singleton registration + $app->singleton(MailTenancyBootstrapper::class); } protected function getPackageProviders($app)