1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2025-12-12 23:14:03 +00:00

Merge branch 'master' into cache-prefix

This commit is contained in:
lukinovec 2023-01-05 15:07:59 +01:00 committed by GitHub
commit f8f0e1e5da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 440 additions and 105 deletions

View file

@ -1,6 +1,7 @@
# add amd64 platform to support Mac M1 # add amd64 platform to support Mac M1
FROM --platform=linux/amd64 shivammathur/node:latest-amd64 FROM --platform=linux/amd64 shivammathur/node:latest-amd64
# todo update this to 8.2 once shivammathur/node supports that
ARG PHP_VERSION=8.1 ARG PHP_VERSION=8.1
WORKDIR /var/www/html WORKDIR /var/www/html

8
INTERNAL.md Normal file
View file

@ -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`

View file

@ -103,6 +103,7 @@ return [
Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class, Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\BatchTenancyBootstrapper::class, Stancl\Tenancy\Bootstrappers\BatchTenancyBootstrapper::class,
// Stancl\Tenancy\Bootstrappers\PrefixCacheTenancyBootstrapper::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 // Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed
], ],

View file

@ -7,7 +7,7 @@ use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Stancl\Tenancy\Tenancy; use Stancl\Tenancy\Tenancy;
class CreateTenantUserImpersonationTokensTable extends Migration return new class extends Migration
{ {
/** /**
* Run the migrations. * Run the migrations.
@ -37,4 +37,4 @@ class CreateTenantUserImpersonationTokensTable extends Migration
{ {
Schema::dropIfExists('tenant_user_impersonation_tokens'); Schema::dropIfExists('tenant_user_impersonation_tokens');
} }
} };

View file

@ -6,7 +6,7 @@ use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
class CreateTenantsTable extends Migration return new class extends Migration
{ {
/** /**
* Run the migrations. * Run the migrations.
@ -34,4 +34,4 @@ class CreateTenantsTable extends Migration
{ {
Schema::dropIfExists('tenants'); Schema::dropIfExists('tenants');
} }
} };

View file

@ -7,7 +7,7 @@ use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Stancl\Tenancy\Tenancy; use Stancl\Tenancy\Tenancy;
class CreateDomainsTable extends Migration return new class extends Migration
{ {
/** /**
* Run the migrations. * Run the migrations.
@ -35,4 +35,4 @@ class CreateDomainsTable extends Migration
{ {
Schema::dropIfExists('domains'); Schema::dropIfExists('domains');
} }
} };

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Controllers\TenantAssetController; use Stancl\Tenancy\Controllers\TenantAssetController;
// todo make this work with path identification
Route::get('/tenancy/assets/{path?}', [TenantAssetController::class, 'asset']) Route::get('/tenancy/assets/{path?}', [TenantAssetController::class, 'asset'])
->where('path', '(.*)') ->where('path', '(.*)')
->name('stancl.tenancy.asset'); ->name('stancl.tenancy.asset');

View file

@ -58,16 +58,16 @@
} }
}, },
"scripts": { "scripts": {
"docker-up": "PHP_VERSION=8.1 docker-compose up -d", "docker-up": "PHP_VERSION=8.2 docker-compose up -d",
"docker-down": "PHP_VERSION=8.1 docker-compose down", "docker-down": "PHP_VERSION=8.2 docker-compose down",
"docker-rebuild": "PHP_VERSION=8.1 docker-compose up -d --no-deps --build", "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", "docker-m1": "ln -s docker-compose-m1.override.yml docker-compose.override.yml",
"coverage": "open coverage/phpunit/html/index.html", "coverage": "open coverage/phpunit/html/index.html",
"phpstan": "vendor/bin/phpstan", "phpstan": "vendor/bin/phpstan",
"phpstan-pro": "vendor/bin/phpstan --pro", "phpstan-pro": "vendor/bin/phpstan --pro",
"cs": "php-cs-fixer fix --config=.php-cs-fixer.php", "cs": "php-cs-fixer fix --config=.php-cs-fixer.php",
"test": "PHP_VERSION=8.1 ./test --no-coverage", "test": "PHP_VERSION=8.2 ./test --no-coverage",
"test-full": "PHP_VERSION=8.1 ./test" "test-full": "PHP_VERSION=8.2 ./test"
}, },
"minimum-stability": "dev", "minimum-stability": "dev",
"prefer-stable": true, "prefer-stable": true,

View file

@ -4,7 +4,7 @@ services:
build: build:
context: . context: .
args: args:
PHP_VERSION: ${PHP_VERSION:-8.1} PHP_VERSION: ${PHP_VERSION:-8.2}
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy

View file

@ -28,7 +28,7 @@ parameters:
paths: paths:
- src/Features/TelescopeTags.php - 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: paths:
- src/helpers.php - src/helpers.php
- -
@ -49,5 +49,9 @@ parameters:
- src/Database/DatabaseConfig.php - src/Database/DatabaseConfig.php
- '#Method Stancl\\Tenancy\\Tenancy::cachedResolvers\(\) should return array#' - '#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 checkMissingIterableValueType: false
treatPhpDocTypesAsCertain: false treatPhpDocTypesAsCertain: false

View file

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Bootstrappers;
use Illuminate\Config\Repository;
use Illuminate\Foundation\Application;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
class MailTenancyBootstrapper implements TenancyBootstrapper
{
/**
* Tenant properties to be mapped to config (similarly to the TenantConfig feature).
*
* For example:
* [
* 'config.key.name' => '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);
}
}
}

View file

@ -10,7 +10,6 @@ use Illuminate\Database\Eloquent\Builder;
class ClearPendingTenants extends Command class ClearPendingTenants extends Command
{ {
protected $signature = 'tenants:pending-clear 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-days= : Deletes all pending tenants older than the amount of days}
{--older-than-hours= : Deletes all pending tenants older than the amount of hours}'; {--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 public function handle(): int
{ {
$this->info('Removing pending tenants.'); $this->components->info('Removing pending tenants.');
$expirationDate = now(); $expirationDate = now();
// We compare the original expiration date to the new one to check if the new one is different later // We compare the original expiration date to the new one to check if the new one is different later
$originalExpirationDate = $expirationDate->copy()->toImmutable(); $originalExpirationDate = $expirationDate->copy()->toImmutable();
// Skip the time constraints if the 'all' option is given $olderThanDays = (int) $this->option('older-than-days');
if (! $this->option('all')) { $olderThanHours = (int) $this->option('older-than-hours');
/** @var ?int $olderThanDays */
$olderThanDays = $this->option('older-than-days');
/** @var ?int $olderThanHours */ if ($olderThanDays && $olderThanHours) {
$olderThanHours = $this->option('older-than-hours'); $this->components->error("Cannot use '--older-than-days' and '--older-than-hours' together. Please, choose only one of these options.");
if ($olderThanDays && $olderThanHours) { return 1; // Exit code for failure
$this->line("<options=bold,reverse;fg=red> 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);
}
} }
$deletedTenantCount = tenancy() if ($olderThanDays) {
->query() $expirationDate->subDays($olderThanDays);
}
if ($olderThanHours) {
$expirationDate->subHours($olderThanHours);
}
$deletedTenantCount = tenancy()->query()
->onlyPending() ->onlyPending()
->when($originalExpirationDate->notEqualTo($expirationDate), function (Builder $query) use ($expirationDate) { ->when($originalExpirationDate->notEqualTo($expirationDate), function (Builder $query) use ($expirationDate) {
$query->where($query->getModel()->getColumnForQuery('pending_since'), '<', $expirationDate->timestamp); $query->where($query->getModel()->getColumnForQuery('pending_since'), '<', $expirationDate->timestamp);
@ -59,7 +50,7 @@ class ClearPendingTenants extends Command
->delete() ->delete()
->count(); ->count();
$this->info($deletedTenantCount . ' pending ' . str('tenant')->plural($deletedTenantCount) . ' deleted.'); $this->components->info($deletedTenantCount . ' pending ' . str('tenant')->plural($deletedTenantCount) . ' deleted.');
return 0; return 0;
} }

View file

@ -14,7 +14,7 @@ class CreatePendingTenants extends Command
public function handle(): int public function handle(): int
{ {
$this->info('Creating pending tenants.'); $this->components->info('Creating pending tenants.');
$maxPendingTenantCount = (int) ($this->option('count') ?? config('tenancy.pending.count')); $maxPendingTenantCount = (int) ($this->option('count') ?? config('tenancy.pending.count'));
$pendingTenantCount = $this->getPendingTenantCount(); $pendingTenantCount = $this->getPendingTenantCount();
@ -30,8 +30,8 @@ class CreatePendingTenants extends Command
$createdCount++; $createdCount++;
} }
$this->info($createdCount . ' ' . str('tenant')->plural($createdCount) . ' created.'); $this->components->info($createdCount . ' pending ' . str('tenant')->plural($createdCount) . ' created.');
$this->info($maxPendingTenantCount . ' ' . str('tenant')->plural($maxPendingTenantCount) . ' ready to be used.'); $this->components->info($maxPendingTenantCount . ' pending ' . str('tenant')->plural($maxPendingTenantCount) . ' ready to be used.');
return 0; return 0;
} }
@ -39,8 +39,7 @@ class CreatePendingTenants extends Command
/** Calculate the number of currently available pending tenants. */ /** Calculate the number of currently available pending tenants. */
protected function getPendingTenantCount(): int protected function getPendingTenantCount(): int
{ {
return tenancy() return tenancy()->query()
->query()
->onlyPending() ->onlyPending()
->count(); ->count();
} }

View file

@ -34,7 +34,7 @@ class Link extends Command
$this->createLinks($tenants); $this->createLinks($tenants);
} }
} catch (Exception $exception) { } catch (Exception $exception) {
$this->error($exception->getMessage()); $this->components->error($exception->getMessage());
return 1; return 1;
} }

View file

@ -5,13 +5,18 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Commands; namespace Stancl\Tenancy\Commands;
use Illuminate\Database\Console\Migrations\FreshCommand; use Illuminate\Database\Console\Migrations\FreshCommand;
use Illuminate\Support\Facades\Schema;
class MigrateFreshOverride extends FreshCommand class MigrateFreshOverride extends FreshCommand
{ {
public function handle() public function handle()
{ {
if (config('tenancy.database.drop_tenant_databases_on_migrate_fresh')) { 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(); return parent::handle();

View file

@ -23,7 +23,7 @@ class TenantDump extends DumpCommand
public function handle(ConnectionResolverInterface $connections, Dispatcher $dispatcher): int public function handle(ConnectionResolverInterface $connections, Dispatcher $dispatcher): int
{ {
if (is_null($this->option('path'))) { 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') $tenant = $this->option('tenant')
@ -41,7 +41,7 @@ class TenantDump extends DumpCommand
return 1; return 1;
} }
parent::handle($connections, $dispatcher); $tenant->run(fn () => parent::handle($connections, $dispatcher));
return 0; return 0;
} }

View file

@ -23,8 +23,7 @@ trait HasTenantOptions
protected function getTenants(): LazyCollection protected function getTenants(): LazyCollection
{ {
return tenancy() return tenancy()->query()
->query()
->when($this->option('tenants'), function ($query) { ->when($this->option('tenants'), function ($query) {
$query->whereIn(tenancy()->model()->getTenantKeyName(), $this->option('tenants')); $query->whereIn(tenancy()->model()->getTenantKeyName(), $this->option('tenants'));
}) })

View file

@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* *
* @see \Stancl\Tenancy\Database\Models\Domain * @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. * @method static __callStatic(string $method, array $parameters) IDE support. This will be a model.
* @mixin \Illuminate\Database\Eloquent\Model * @mixin \Illuminate\Database\Eloquent\Model
*/ */

View file

@ -48,6 +48,23 @@ class UserImpersonation implements Feature
$token->delete(); $token->delete();
session()->put('tenancy_impersonating', true);
return redirect($token->redirect_url); 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');
}
} }

View file

@ -6,7 +6,7 @@ namespace Stancl\Tenancy\Listeners;
use Stancl\Tenancy\Database\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\DatabaseManager; use Stancl\Tenancy\Database\DatabaseManager;
use Stancl\Tenancy\Events\Contracts\TenantEvent; use Stancl\Tenancy\Events\Contracts\TenancyEvent;
class CreateTenantConnection class CreateTenantConnection
{ {
@ -15,11 +15,12 @@ class CreateTenantConnection
) { ) {
} }
public function handle(TenantEvent $event): void public function handle(TenancyEvent $event): void
{ {
/** @var TenantWithDatabase */ /** @var TenantWithDatabase $tenant */
$tenant = $event->tenant; $tenant = $event->tenancy->tenant;
$this->database->purgeTenantConnection();
$this->database->createTenantConnection($tenant); $this->database->createTenantConnection($tenant);
} }
} }

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Listeners;
use Stancl\Tenancy\Database\DatabaseManager;
use Stancl\Tenancy\Events\Contracts\TenancyEvent;
class UseCentralConnection
{
public function __construct(
protected DatabaseManager $database,
) {
}
public function handle(TenancyEvent $event): void
{
$this->database->reconnectToCentral();
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Listeners;
use Stancl\Tenancy\Database\DatabaseManager;
use Stancl\Tenancy\Events\Contracts\TenancyEvent;
class UseTenantConnection
{
public function __construct(
protected DatabaseManager $database,
) {
}
public function handle(TenancyEvent $event): void
{
$this->database->setDefaultConnection('tenant');
}
}

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Resolvers; namespace Stancl\Tenancy\Resolvers;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Stancl\Tenancy\Contracts\Domain; use Stancl\Tenancy\Contracts\Domain;
use Stancl\Tenancy\Contracts\Tenant; use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException; use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedOnDomainException;
@ -39,14 +40,16 @@ class DomainTenantResolver extends Contracts\CachedTenantResolver
protected function setCurrentDomain(Tenant $tenant, string $domain): void protected function setCurrentDomain(Tenant $tenant, string $domain): void
{ {
/** @var Tenant&Model $tenant */
static::$currentDomain = $tenant->domains->where('domain', $domain)->first(); static::$currentDomain = $tenant->domains->where('domain', $domain)->first();
} }
public function getArgsForTenant(Tenant $tenant): array public function getArgsForTenant(Tenant $tenant): array
{ {
/** @var Tenant&Model $tenant */
$tenant->unsetRelation('domains'); $tenant->unsetRelation('domains');
return $tenant->domains->map(function (Domain $domain) { return $tenant->domains->map(function (Domain&Model $domain) {
return [$domain->domain]; return [$domain->domain];
})->toArray(); })->toArray();
} }

View file

@ -118,6 +118,7 @@ class Tenancy
*/ */
public static function find(int|string $id): Tenant|null 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 */ /** @var (Tenant&Model)|null */
$tenant = static::model()->where(static::model()->getTenantKeyName(), $id)->first(); $tenant = static::model()->where(static::model()->getTenantKeyName(), $id)->first();

View file

@ -3,6 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Mail\MailManager;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Stancl\JobPipeline\JobPipeline; use Stancl\JobPipeline\JobPipeline;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
@ -24,6 +25,7 @@ use Stancl\Tenancy\Jobs\RemoveStorageSymlinks;
use Stancl\Tenancy\Listeners\BootstrapTenancy; use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\DeleteTenantStorage; use Stancl\Tenancy\Listeners\DeleteTenantStorage;
use Stancl\Tenancy\Listeners\RevertToCentralContext; use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
@ -331,20 +333,55 @@ test('local storage public urls are generated correctly', function() {
expect(File::isDirectory($tenantStoragePath))->toBeFalse(); 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 function getDiskPrefix(string $disk): string
{ {
/** @var FilesystemAdapter $disk */ /** @var FilesystemAdapter $disk */
$disk = Storage::disk($disk); $disk = Storage::disk($disk);
$adapter = $disk->getAdapter(); $adapter = $disk->getAdapter();
$prefix = invade(invade($adapter)->prefixer)->prefix;
$prefixer = (new ReflectionObject($adapter))->getProperty('prefixer'); return $prefix;
$prefixer->setAccessible(true);
// reflection -> instance
$prefixer = $prefixer->getValue($adapter);
$prefix = (new ReflectionProperty($prefixer, 'prefix'));
$prefix->setAccessible(true);
return $prefix->getValue($prefixer);
} }

View file

@ -25,7 +25,7 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
beforeEach(function () { beforeEach(function () {
if (file_exists($schemaPath = database_path('schema/tenant-schema.dump'))) { if (file_exists($schemaPath = 'tests/Etc/tenant-schema-test.dump')) {
unlink($schemaPath); unlink($schemaPath);
} }
@ -111,28 +111,44 @@ test('migrate command loads schema state', function () {
test('dump command works', function () { test('dump command works', function () {
$tenant = Tenant::create(); $tenant = Tenant::create();
$schemaPath = 'tests/Etc/tenant-schema-test.dump';
Artisan::call('tenants:migrate'); Artisan::call('tenants:migrate');
tenancy()->initialize($tenant); expect($schemaPath)->not()->toBeFile();
Artisan::call('tenants:dump --path="tests/Etc/tenant-schema-test.dump"'); Artisan::call('tenants:dump ' . "--tenant='$tenant->id' --path='$schemaPath'");
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');
expect($schemaPath)->toBeFile(); 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']); config(['tenancy.migration_parameters.--schema-path' => 'tests/Etc/tenant-schema.dump']);
$tenant = Tenant::create(); $tenant = Tenant::create();
@ -146,6 +162,7 @@ test('migrate command uses the correct schema path by default', function () {
tenancy()->initialize($tenant); 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 // Check for both tables to see if missing migrations also get executed
expect(Schema::hasTable('schema_users'))->toBeTrue(); expect(Schema::hasTable('schema_users'))->toBeTrue();
expect(Schema::hasTable('users'))->toBeTrue(); expect(Schema::hasTable('users'))->toBeTrue();
@ -355,7 +372,7 @@ function runCommandWorks(): void
Artisan::call('tenants:migrate', ['--tenants' => [$id]]); Artisan::call('tenants:migrate', ['--tenants' => [$id]]);
pest()->artisan("tenants:run --tenants=$id 'foo foo --b=bar --c=xyz' ") 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('foo')
->expectsOutput('xyz'); ->expectsOutput('xyz');
} }

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Tests\Etc\Console; namespace Stancl\Tenancy\Tests\Etc\Console;
use Illuminate\Support\Str;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class ExampleCommand extends Command class ExampleCommand extends Command
@ -22,14 +23,13 @@ class ExampleCommand extends Command
*/ */
public function handle() public function handle()
{ {
User::create([ $id = User::create([
'id' => 999, 'name' => 'Test user',
'name' => 'Test command', 'email' => Str::random(8) . '@example.com',
'email' => 'test@command.com',
'password' => bcrypt('password'), '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->argument('a'));
$this->line($this->option('c')); $this->line($this->option('c'));
} }

72
tests/MailTest.php Normal file
View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
use Illuminate\Mail\MailManager;
use Illuminate\Support\Facades\Event;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper;
beforeEach(function() {
config(['mail.default' => '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);
});

45
tests/ManualModeTest.php Normal file
View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Event;
use Stancl\JobPipeline\JobPipeline;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Events\TenantCreated;
use Stancl\Tenancy\Jobs\CreateDatabase;
use Stancl\Tenancy\Listeners\CreateTenantConnection;
use Stancl\Tenancy\Listeners\UseCentralConnection;
use Stancl\Tenancy\Listeners\UseTenantConnection;
use \Stancl\Tenancy\Tests\Etc\Tenant;
test('manual tenancy initialization works', function () {
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->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'));
});

View file

@ -67,23 +67,6 @@ test('CreatePendingTenants command cannot run with both time constraints', funct
->assertFailed(); ->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 () { test('tenancy can check if there are any pending tenants', function () {
expect(Tenant::onlyPending()->exists())->toBeFalse(); expect(Tenant::onlyPending()->exists())->toBeFalse();

View file

@ -83,6 +83,19 @@ test('tenant user can be impersonated on a tenant domain', function () {
pest()->get('http://foo.localhost/dashboard') pest()->get('http://foo.localhost/dashboard')
->assertSuccessful() ->assertSuccessful()
->assertSee('You are logged in as Joe'); ->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 () { 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') pest()->get('/acme/dashboard')
->assertSuccessful() ->assertSuccessful()
->assertSee('You are logged in as Joe'); ->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 () { test('tokens have a limited ttl', function () {

View file

@ -14,6 +14,7 @@ use Stancl\Tenancy\Facades\GlobalCache;
use Stancl\Tenancy\Facades\Tenancy; use Stancl\Tenancy\Facades\Tenancy;
use Stancl\Tenancy\TenancyServiceProvider; use Stancl\Tenancy\TenancyServiceProvider;
use Stancl\Tenancy\Tests\Etc\Tenant; use Stancl\Tenancy\Tests\Etc\Tenant;
use Stancl\Tenancy\Bootstrappers\MailTenancyBootstrapper;
abstract class TestCase extends \Orchestra\Testbench\TestCase abstract class TestCase extends \Orchestra\Testbench\TestCase
{ {
@ -104,6 +105,7 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
'--force' => true, '--force' => true,
], ],
'tenancy.bootstrappers.redis' => RedisTenancyBootstrapper::class, // todo1 change this to []? two tests in TenantDatabaseManagerTest are failing with that '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' => [ 'queue.connections.central' => [
'driver' => 'sync', 'driver' => 'sync',
'central' => true, '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(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(PrefixCacheTenancyBootstrapper::class); // todo (Samuel) use proper approach eg config for singleton registration
$app->singleton(MailTenancyBootstrapper::class);
} }
protected function getPackageProviders($app) protected function getPackageProviders($app)